diff --git a/internal/api/client_test.go b/internal/api/client_test.go index a840258..e6f4cc5 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -289,6 +289,7 @@ func TestFunctionMethodsUseGeneratedRoutes(t *testing.T) { } var batchFilenames []string var updateBody map[string]bool + var invokeBody map[string]any var schedulerCreateBody map[string]any var schedulerUpdateBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -338,6 +339,9 @@ func TestFunctionMethodsUseGeneratedRoutes(t *testing.T) { case r.Method == http.MethodPatch && r.URL.Path == "/projects/"+projectIDText+"/functions/"+functionIDText: require.NoError(t, json.NewDecoder(r.Body).Decode(&updateBody)) writeAPIJSON(t, w, http.StatusOK, functionResponse(functionIDText, projectIDText, "hello")) + case r.Method == http.MethodPost && r.URL.Path == "/functions/"+functionIDText+"/invoke": + require.NoError(t, json.NewDecoder(r.Body).Decode(&invokeBody)) + writeAPIJSON(t, w, http.StatusOK, map[string]any{"ok": true}) case r.Method == http.MethodGet && r.URL.Path == "/functions/runtimes": writeAPIJSON(t, w, http.StatusOK, map[string]any{ "runtimes": []any{ @@ -439,6 +443,14 @@ func TestFunctionMethodsUseGeneratedRoutes(t *testing.T) { assert.Equal(t, "hello", updated.Name) assert.Equal(t, map[string]bool{"is_public": true}, updateBody) + invoked, err := client.InvokeFunction(context.Background(), functionID, FunctionInvokeInput{ + Payload: map[string]any{"k": "v"}, + }) + require.NoError(t, err) + require.NotNil(t, invoked) + assert.Equal(t, true, (*invoked)["ok"]) + assert.Equal(t, map[string]any{"payload": map[string]any{"k": "v"}}, invokeBody) + runtimes, err := client.ListFunctionRuntimes(context.Background()) require.NoError(t, err) assert.Equal(t, "nodejs24.x", runtimes[0].Name) @@ -498,6 +510,7 @@ func TestFunctionMethodsUseGeneratedRoutes(t *testing.T) { "GET /projects/" + projectIDText + "/functions/" + functionIDText, "DELETE /projects/" + projectIDText + "/functions/" + functionIDText, "PATCH /projects/" + projectIDText + "/functions/" + functionIDText, + "POST /functions/" + functionIDText + "/invoke", "GET /functions/runtimes", "GET /projects/" + projectIDText + "/functions/" + functionIDText + "/deployments?page=3&limit=10", "GET /projects/" + projectIDText + "/functions/" + functionIDText + "/logs?limit=50&next_token=fn-next", @@ -527,6 +540,20 @@ func TestDeleteFunctionAcceptsAsyncAndNoContent(t *testing.T) { } } +func TestInvokeFunctionErrorsNormalize(t *testing.T) { + functionID := mustProjectID(t, "22222222-2222-4222-8222-222222222222") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeAPIJSON(t, w, http.StatusTooManyRequests, map[string]string{"error": "rate limited"}) + })) + defer server.Close() + + client, err := NewClient(server.URL, "", WithHTTPClient(server.Client())) + require.NoError(t, err) + + _, err = client.InvokeFunction(context.Background(), functionID, FunctionInvokeInput{}) + require.ErrorContains(t, err, "HTTP 429: rate limited") +} + func mustProjectID(t *testing.T, value string) uuid.UUID { t.Helper() id, err := uuid.Parse(value) diff --git a/internal/api/functions.go b/internal/api/functions.go index 4d23124..6cdb3e1 100644 --- a/internal/api/functions.go +++ b/internal/api/functions.go @@ -31,6 +31,11 @@ type FunctionSchedulerInput struct { Enabled *bool } +// FunctionInvokeInput contains one function invocation request. +type FunctionInvokeInput struct { + Payload map[string]any +} + // ListFunctions lists one function page for a project. func (c *Client) ListFunctions(ctx context.Context, projectID uuid.UUID, page, limit int) (*apiclient.PaginatedFunctions, error) { resp, err := c.client.ListFunctionsWithResponse(ctx, projectID, &apiclient.ListFunctionsParams{ @@ -107,6 +112,24 @@ func (c *Client) UpdateFunctionVisibility(ctx context.Context, projectID, functi return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON400, resp.JSON404) } +// InvokeFunction invokes one function by ID. +func (c *Client) InvokeFunction(ctx context.Context, functionID uuid.UUID, input FunctionInvokeInput) (*apiclient.FunctionInvocationResponse, error) { + body := apiclient.InvokeFunctionJSONRequestBody{} + if input.Payload != nil { + payload := input.Payload + body.Payload = &payload + } + + resp, err := c.client.InvokeFunctionWithResponse(ctx, functionID, body) + if err != nil { + return nil, err + } + if resp.JSONDefault != nil && resp.StatusCode() >= 200 && resp.StatusCode() < 300 { + return resp.JSONDefault, nil + } + return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON404, resp.JSON429, resp.JSON503) +} + // ListFunctionRuntimes returns the function runtime catalog. func (c *Client) ListFunctionRuntimes(ctx context.Context) ([]apiclient.FunctionRuntimeOption, error) { resp, err := c.client.ListFunctionRuntimesWithResponse(ctx) diff --git a/internal/cmd/functions/alias.go b/internal/cmd/functions/alias.go new file mode 100644 index 0000000..dc3fa95 --- /dev/null +++ b/internal/cmd/functions/alias.go @@ -0,0 +1,121 @@ +package functions + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + + clifunction "github.com/Kong/volcano-cli/internal/function" + "github.com/Kong/volcano-cli/internal/output" + cliruntime "github.com/Kong/volcano-cli/internal/runtime" +) + +type aliasOptions struct { + deps cliruntime.Deps + alias string + functionID string + out io.Writer +} + +func newAlias(deps cliruntime.Deps) *cobra.Command { + cmd := &cobra.Command{ + Use: "alias", + Short: "Manage function invoke aliases", + Long: "Manage per-user aliases used by functions invoke.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(newAliasSet(deps)) + cmd.AddCommand(newAliasList(deps)) + cmd.AddCommand(newAliasDelete(deps)) + return cmd +} + +func newAliasSet(deps cliruntime.Deps) *cobra.Command { + return &cobra.Command{ + Use: "set ", + Short: "Set a function invoke alias", + Example: fmt.Sprintf(` %s`, + cliruntime.CommandPath(deps, "functions alias set hello 33333333-3333-4333-8333-333333333333")), + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runAliasSet(cmd.Context(), aliasOptions{ + deps: deps, + alias: strings.TrimSpace(args[0]), + functionID: strings.TrimSpace(args[1]), + out: cmd.OutOrStdout(), + }) + }, + } +} + +func newAliasList(deps cliruntime.Deps) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List function invoke aliases", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runAliasList(cmd.Context(), aliasOptions{ + deps: deps, + out: cmd.OutOrStdout(), + }) + }, + } +} + +func newAliasDelete(deps cliruntime.Deps) *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Delete a function invoke alias", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAliasDelete(cmd.Context(), aliasOptions{ + deps: deps, + alias: strings.TrimSpace(args[0]), + out: cmd.OutOrStdout(), + }) + }, + } +} + +func runAliasSet(ctx context.Context, opts aliasOptions) error { + alias, err := clifunction.NewService(opts.deps).SetAlias(ctx, opts.alias, opts.functionID) + if err != nil { + return err + } + fmt.Fprintf(opts.out, "Alias: %s\n", alias.Name) + fmt.Fprintf(opts.out, "Function ID: %s\n", alias.FunctionID) + output.Success(opts.out, "Set function alias %q", alias.Name) + return nil +} + +func runAliasList(ctx context.Context, opts aliasOptions) error { + aliases, err := clifunction.NewService(opts.deps).ListAliases(ctx) + if err != nil { + return err + } + if len(aliases) == 0 { + fmt.Fprintln(opts.out, "No function aliases configured") + return nil + } + + fmt.Fprintf(opts.out, "%-24s %-36s\n", "Alias", "Function ID") + fmt.Fprintln(opts.out, strings.Repeat("-", 62)) + for _, alias := range aliases { + fmt.Fprintf(opts.out, "%-24s %-36s\n", alias.Name, alias.FunctionID) + } + return nil +} + +func runAliasDelete(ctx context.Context, opts aliasOptions) error { + if err := clifunction.NewService(opts.deps).DeleteAlias(ctx, opts.alias); err != nil { + return err + } + output.Success(opts.out, "Deleted function alias %q", opts.alias) + return nil +} diff --git a/internal/cmd/functions/functions.go b/internal/cmd/functions/functions.go index 71ffe5d..73ae352 100644 --- a/internal/cmd/functions/functions.go +++ b/internal/cmd/functions/functions.go @@ -30,6 +30,8 @@ func newWithOptions(deps cliruntime.Deps, opts commandOptions) *cobra.Command { cmd.AddCommand(newDeploy(deps, opts.batchDeployAll)) cmd.AddCommand(newList(deps)) cmd.AddCommand(newGet(deps)) + cmd.AddCommand(newInvoke(deps)) + cmd.AddCommand(newAlias(deps)) cmd.AddCommand(newDelete(deps)) cmd.AddCommand(newUpdate(deps)) cmd.AddCommand(newLogs(deps)) diff --git a/internal/cmd/functions/invoke.go b/internal/cmd/functions/invoke.go new file mode 100644 index 0000000..10cb3f3 --- /dev/null +++ b/internal/cmd/functions/invoke.go @@ -0,0 +1,112 @@ +package functions + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/google/uuid" + "github.com/spf13/cobra" + + clifunction "github.com/Kong/volcano-cli/internal/function" + cliruntime "github.com/Kong/volcano-cli/internal/runtime" +) + +type invokeOptions struct { + deps cliruntime.Deps + identifier string + functionID string + payload string + jsonOutput bool + out io.Writer +} + +func newInvoke(deps cliruntime.Deps) *cobra.Command { + opts := invokeOptions{} + cmd := &cobra.Command{ + Use: "invoke [name]", + Short: "Invoke a function", + Long: "Invoke a deployed function by alias, name, path, or ID.", + Example: fmt.Sprintf(` %s + %s + %s`, + cliruntime.CommandPath(deps, `functions invoke hello --payload '{"name":"Ada"}'`), + cliruntime.CommandPath(deps, `functions invoke hello --json`), + cliruntime.CommandPath(deps, `functions invoke --id 33333333-3333-4333-8333-333333333333`)), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.deps = deps + if len(args) == 1 { + opts.identifier = strings.TrimSpace(args[0]) + } + opts.out = cmd.OutOrStdout() + return runInvoke(cmd.Context(), opts) + }, + } + cmd.Flags().StringVar(&opts.functionID, "id", "", "Function ID to invoke directly") + cmd.Flags().StringVar(&opts.payload, "payload", "", "Inline JSON object passed as the invocation payload") + cmd.Flags().BoolVar(&opts.jsonOutput, "json", false, "Print compact JSON output") + return cmd +} + +func runInvoke(ctx context.Context, opts invokeOptions) error { + hasName := strings.TrimSpace(opts.identifier) != "" + hasID := strings.TrimSpace(opts.functionID) != "" + switch { + case hasName && hasID: + return errors.New("specify either a function name or --id, not both") + case !hasName && !hasID: + return errors.New("specify a function name or --id") + } + + payload, err := loadInvokePayload(opts.payload) + if err != nil { + return err + } + + service := clifunction.NewService(opts.deps) + var resp any + if hasID { + functionID, err := uuid.Parse(strings.TrimSpace(opts.functionID)) + if err != nil { + return fmt.Errorf("invalid function ID %q: %w", opts.functionID, err) + } + resp, err = service.InvokeByID(ctx, functionID, payload) + if err != nil { + return err + } + } else { + resp, err = service.Invoke(ctx, opts.identifier, payload) + if err != nil { + return err + } + } + + return writeInvocationResponse(opts.out, resp, opts.jsonOutput) +} + +func loadInvokePayload(value string) (map[string]any, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, nil + } + var payload map[string]any + if err := json.Unmarshal([]byte(value), &payload); err != nil { + return nil, fmt.Errorf("payload must be a JSON object: %w", err) + } + if payload == nil { + return nil, errors.New("payload must be a JSON object") + } + return payload, nil +} + +func writeInvocationResponse(w io.Writer, resp any, compact bool) error { + encoder := json.NewEncoder(w) + if !compact { + encoder.SetIndent("", " ") + } + return encoder.Encode(resp) +} diff --git a/internal/cmd/functions/invoke_test.go b/internal/cmd/functions/invoke_test.go new file mode 100644 index 0000000..2d86278 --- /dev/null +++ b/internal/cmd/functions/invoke_test.go @@ -0,0 +1,211 @@ +package functions + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cliconfig "github.com/Kong/volcano-cli/internal/config" + cliruntime "github.com/Kong/volcano-cli/internal/runtime" +) + +func TestFunctionsInvokeByIDSkipsNameResolution(t *testing.T) { + setFunctionCommandTestHome(t) + saveFunctionCommandTestConfig(t) + + var invokeBody map[string]any + var listHits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer token", r.Header.Get("Authorization")) + switch { + case r.Method == http.MethodPost && r.URL.Path == "/functions/"+functionID+"/invoke": + require.NoError(t, json.NewDecoder(r.Body).Decode(&invokeBody)) + writeFunctionCommandJSON(t, w, http.StatusOK, map[string]any{"ok": true}) + case r.Method == http.MethodGet && r.URL.Path == "/projects/"+functionProjectID+"/functions": + listHits++ + http.NotFound(w, r) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + out, err := executeFunctionsCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "invoke", "--id", functionID, "--payload", `{"k":"v"}`) + require.NoError(t, err) + assert.Equal(t, map[string]any{"payload": map[string]any{"k": "v"}}, invokeBody) + assert.Equal(t, 0, listHits) + assert.Contains(t, out, "{\n \"ok\": true\n}\n") +} + +func TestFunctionsInvokeByAliasTakesPrecedenceOverNameResolution(t *testing.T) { + setFunctionCommandTestHome(t) + saveFunctionCommandTestConfig(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer token", r.Header.Get("Authorization")) + switch { + case r.Method == http.MethodPost && r.URL.Path == "/functions/"+otherFunctionID+"/invoke": + writeFunctionCommandJSON(t, w, http.StatusOK, map[string]any{"aliased": true}) + case r.Method == http.MethodGet && r.URL.Path == "/projects/"+functionProjectID+"/functions": + t.Fatalf("alias invoke should not list functions") + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + cfg, err := cliconfig.Load() + require.NoError(t, err) + cfg.SetFunctionAlias(cliconfig.FunctionAliasScope(server.URL, functionProjectID), "hello", otherFunctionID) + require.NoError(t, cfg.Save()) + + out, err := executeFunctionsCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "invoke", "hello", "--json") + require.NoError(t, err) + assert.JSONEq(t, `{"aliased":true}`, out) +} + +func TestFunctionsInvokeByNameFallsBackToFunctionResolution(t *testing.T) { + setFunctionCommandTestHome(t) + saveFunctionCommandTestConfig(t) + + var requests []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer token", r.Header.Get("Authorization")) + requests = append(requests, r.Method+" "+r.URL.RequestURI()) + switch { + case r.Method == http.MethodGet && r.URL.Path == "/projects/"+functionProjectID+"/functions": + writeFunctionCommandJSON(t, w, http.StatusOK, map[string]any{ + "data": []any{functionCommandPayload(functionID, "hello")}, + "has_more": false, + "page": 1, + "limit": 100, + "total": 1, + }) + case r.Method == http.MethodPost && r.URL.Path == "/functions/"+functionID+"/invoke": + writeFunctionCommandJSON(t, w, http.StatusOK, map[string]any{"name": "hello"}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + out, err := executeFunctionsCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "invoke", "volcano/functions/hello.js", "--json") + require.NoError(t, err) + assert.JSONEq(t, `{"name":"hello"}`, out) + assert.Equal(t, []string{ + "GET /projects/" + functionProjectID + "/functions?page=1&limit=100", + "POST /functions/" + functionID + "/invoke", + }, requests) +} + +func TestFunctionsInvokeLocalUsesServiceKeyForInvokeOnly(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("VOLCANO_TOKEN", "cloud-token") + t.Setenv("VOLCANO_PROJECT_ID", "99999999-9999-4999-8999-999999999999") + t.Setenv("VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID", "") + + var sawListAuth string + var sawInvokeAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/projects/"+functionProjectID+"/functions": + sawListAuth = r.Header.Get("Authorization") + writeFunctionCommandJSON(t, w, http.StatusOK, map[string]any{ + "data": []any{functionCommandPayload(functionID, "hello")}, + "has_more": false, + "page": 1, + "limit": 100, + "total": 1, + }) + case r.Method == http.MethodPost && r.URL.Path == "/functions/"+functionID+"/invoke": + sawInvokeAuth = r.Header.Get("Authorization") + writeFunctionCommandJSON(t, w, http.StatusOK, map[string]any{"ok": true}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + deps := cliruntime.Deps{ + HTTPClient: server.Client(), + ConfigLoader: func() (*cliconfig.Config, error) { + return &cliconfig.Config{ + APIBaseURL: server.URL, + UserToken: "local-token", + AnonKey: "local-anon-key", + ServiceKey: "local-service-key", + CurrentProject: &cliconfig.ProjectConfig{ + ID: functionProjectID, + Name: "local-dev", + }, + IgnoreEnv: true, + }, nil + }, + } + + out, err := executeFunctionsCommand(t, NewLocal(deps), "invoke", "hello", "--json") + require.NoError(t, err) + assert.JSONEq(t, `{"ok":true}`, out) + assert.Equal(t, "Bearer local-token", sawListAuth) + assert.Equal(t, "Bearer local-service-key", sawInvokeAuth) +} + +func TestFunctionsInvokeRejectsInvalidTargetsAndPayload(t *testing.T) { + for _, tc := range []struct { + name string + args []string + want string + }{ + {name: "missing target", args: []string{"invoke"}, want: "specify a function name or --id"}, + {name: "name and id", args: []string{"invoke", "hello", "--id", functionID}, want: "specify either a function name or --id, not both"}, + {name: "bad payload", args: []string{"invoke", "hello", "--payload", "not-json"}, want: "payload must be a JSON object"}, + {name: "array payload", args: []string{"invoke", "hello", "--payload", "[]"}, want: "payload must be a JSON object"}, + {name: "bad id", args: []string{"invoke", "--id", "not-a-uuid"}, want: "invalid function ID"}, + } { + t.Run(tc.name, func(t *testing.T) { + out, err := executeFunctionsCommand(t, New(cliruntime.Deps{}), tc.args...) + require.Error(t, err) + assert.Contains(t, out, "Error:") + assert.ErrorContains(t, err, tc.want) + }) + } +} + +func TestFunctionsAliasSetListDelete(t *testing.T) { + setFunctionCommandTestHome(t) + saveFunctionCommandTestConfig(t) + + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + cmd := New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}) + + out, err := executeFunctionsCommand(t, cmd, "alias", "set", "hello", functionID) + require.NoError(t, err) + assert.Contains(t, out, "Alias: hello") + assert.Contains(t, out, "Function ID: "+functionID) + assert.Contains(t, out, `Set function alias "hello"`) + + cfg, err := cliconfig.Load() + require.NoError(t, err) + got, ok := cfg.FunctionAlias(cliconfig.FunctionAliasScope(server.URL, functionProjectID), "hello") + require.True(t, ok) + assert.Equal(t, functionID, got) + + out, err = executeFunctionsCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "alias", "list") + require.NoError(t, err) + assert.Contains(t, out, "Alias") + assert.Contains(t, out, "hello") + assert.Contains(t, out, functionID) + + out, err = executeFunctionsCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "alias", "delete", "hello") + require.NoError(t, err) + assert.Contains(t, out, `Deleted function alias "hello"`) + + out, err = executeFunctionsCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "alias", "list") + require.NoError(t, err) + assert.Contains(t, out, "No function aliases configured") +} diff --git a/internal/cmd/local/local.go b/internal/cmd/local/local.go index ee82d37..8f9c36c 100644 --- a/internal/cmd/local/local.go +++ b/internal/cmd/local/local.go @@ -76,10 +76,18 @@ func withLocalConfig(deps cliruntime.Deps, cache *infoCache) cliruntime.Deps { return nil, err } + persisted, err := cliconfig.Load() + if err != nil { + return nil, err + } + return &cliconfig.Config{ - APIBaseURL: info.APIURL, - UserToken: info.UserToken, - UserID: info.UserID, + APIBaseURL: info.APIURL, + UserToken: info.UserToken, + UserID: info.UserID, + AnonKey: info.AnonKey, + ServiceKey: info.ServiceKey, + FunctionAliases: persisted.FunctionAliases, CurrentProject: &cliconfig.ProjectConfig{ ID: info.ProjectID, Name: info.ProjectName, diff --git a/internal/cmd/root/root_test.go b/internal/cmd/root/root_test.go index 2cbdf31..f0416fd 100644 --- a/internal/cmd/root/root_test.go +++ b/internal/cmd/root/root_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + cliconfig "github.com/Kong/volcano-cli/internal/config" "github.com/Kong/volcano-cli/internal/localmode" cliruntime "github.com/Kong/volcano-cli/internal/runtime" ) @@ -115,6 +116,39 @@ func TestDirectFunctionCommandUsesLocalMetadata(t *testing.T) { assert.Equal(t, 0, cloudHits) } +func TestDirectFunctionInvokeUsesLocalAliasConfig(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("VOLCANO_TOKEN", "cloud-token") + t.Setenv("VOLCANO_PROJECT_ID", "99999999-9999-4999-8999-999999999999") + t.Setenv("VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID", "") + + aliasFunctionID := "44444444-4444-4444-8444-444444444444" + localServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer local-service-key", r.Header.Get("Authorization")) + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/functions/"+aliasFunctionID+"/invoke", r.URL.Path) + writeRootCommandJSON(t, w, http.StatusOK, map[string]any{"local": true}) + })) + defer localServer.Close() + + cfg := cliconfig.Default() + cfg.SetFunctionAlias(cliconfig.FunctionAliasScope(localServer.URL, "22222222-2222-4222-8222-222222222222"), "hello", aliasFunctionID) + require.NoError(t, cfg.Save()) + + deps := cliruntime.Deps{ + HTTPClient: localServer.Client(), + LocalCommandRunner: localmode.CommandRunnerFunc(func(_ context.Context, name string, args ...string) ([]byte, error) { + assert.Equal(t, "docker", name) + assert.Equal(t, []string{"exec", "volcano-server", "/app/volcano-hosting", "local", "info", "--format", "json"}, args) + return []byte(rootLocalInfoJSON(localServer.URL)), nil + }), + } + + out, err := executeRootCommandWithDeps(t, deps, "functions", "invoke", "hello", "--json") + require.NoError(t, err) + assert.JSONEq(t, `{"local":true}`, out) +} + func TestCloudFunctionCommandUsesCloudAPI(t *testing.T) { t.Setenv("HOME", t.TempDir()) @@ -145,6 +179,13 @@ func TestCloudFunctionHelpUsesCloudPaths(t *testing.T) { assert.NotContains(t, out, "volcano functions deploy --all") } +func TestCloudFunctionInvokeHelpUsesCloudPaths(t *testing.T) { + out, err := executeRootCommand(t, "cloud", "functions", "invoke", "--help") + require.NoError(t, err) + assert.Contains(t, out, "volcano cloud functions invoke hello") + assert.NotContains(t, out, "volcano functions invoke hello") +} + func TestCloudFrontendHelpUsesCloudPaths(t *testing.T) { out, err := executeRootCommand(t, "cloud", "frontends", "deploy", "--help") require.NoError(t, err) @@ -257,6 +298,7 @@ func rootLocalInfoJSON(apiURL string) string { "project_name": "local-dev", "user_id": "local-user", "user_token": "local-token", + "anon_key": "local-anon-key", "service_key": "local-service-key", "default_database_name": "app", "default_database_region": "metadata-region", diff --git a/internal/config/config.go b/internal/config/config.go index 66c1d7d..0681303 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,7 +42,12 @@ type Config struct { APIBaseURL string `json:"-"` UserToken string `json:"user_token,omitempty"` UserID string `json:"user_id,omitempty"` + AnonKey string `json:"-"` + ServiceKey string `json:"-"` CurrentProject *ProjectConfig `json:"current_project,omitempty"` + // FunctionAliases stores per-user function invoke aliases by API URL and + // project ID scope. Scope keys are produced by FunctionAliasScope. + FunctionAliases map[string]map[string]string `json:"function_aliases,omitempty"` // IgnoreEnv disables environment overrides for synthetic command configs. IgnoreEnv bool `json:"-"` } @@ -149,6 +154,19 @@ func (c *Config) Token() string { return c.UserToken } +// FunctionInvokeToken returns the token used for runtime function invocation. +// Local mode supplies service and anon keys for invoke endpoints; cloud falls +// back to the normal configured token until a project invoke key is available. +func (c *Config) FunctionInvokeToken() string { + if strings.TrimSpace(c.ServiceKey) != "" { + return c.ServiceKey + } + if strings.TrimSpace(c.AnonKey) != "" { + return c.AnonKey + } + return c.Token() +} + // ProjectID returns the current project ID, with VOLCANO_PROJECT_ID taking precedence unless env overrides are disabled. func (c *Config) ProjectID() string { if projectID := os.Getenv(envProjectID); !c.IgnoreEnv && projectID != "" { @@ -172,6 +190,60 @@ func (c *Config) APIURL() string { return compiledDefaultAPIURL } +// FunctionAliasScope returns the config key for aliases bound to one API URL +// and project ID. The API URL is trimmed so trailing slashes do not split scopes. +func FunctionAliasScope(apiURL, projectID string) string { + return strings.TrimRight(strings.TrimSpace(apiURL), "/") + "|" + strings.TrimSpace(projectID) +} + +// FunctionAlias returns the function ID configured for alias in the given scope. +func (c *Config) FunctionAlias(scope, alias string) (string, bool) { + if c == nil || c.FunctionAliases == nil { + return "", false + } + aliases := c.FunctionAliases[strings.TrimSpace(scope)] + if aliases == nil { + return "", false + } + functionID, ok := aliases[strings.TrimSpace(alias)] + return functionID, ok +} + +// SetFunctionAlias stores alias in the given scope. +func (c *Config) SetFunctionAlias(scope, alias, functionID string) { + scope = strings.TrimSpace(scope) + alias = strings.TrimSpace(alias) + functionID = strings.TrimSpace(functionID) + if c.FunctionAliases == nil { + c.FunctionAliases = map[string]map[string]string{} + } + if c.FunctionAliases[scope] == nil { + c.FunctionAliases[scope] = map[string]string{} + } + c.FunctionAliases[scope][alias] = functionID +} + +// DeleteFunctionAlias removes alias from the given scope and reports whether it existed. +func (c *Config) DeleteFunctionAlias(scope, alias string) bool { + if c == nil || c.FunctionAliases == nil { + return false + } + scope = strings.TrimSpace(scope) + alias = strings.TrimSpace(alias) + aliases := c.FunctionAliases[scope] + if aliases == nil { + return false + } + if _, ok := aliases[alias]; !ok { + return false + } + delete(aliases, alias) + if len(aliases) == 0 { + delete(c.FunctionAliases, scope) + } + return true +} + // RequireAuth returns an old-CLI-compatible error when no token is available. func (c *Config) RequireAuth() error { if c.Token() == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d224df2..6ae3083 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -58,6 +58,8 @@ func TestSaveOmitsRuntimeOnlyAPIURL(t *testing.T) { cfg := &Config{ APIBaseURL: "http://localhost:8000", UserToken: "file-token", + AnonKey: "local-anon-key", + ServiceKey: "local-service-key", } require.NoError(t, cfg.Save()) @@ -67,13 +69,64 @@ func TestSaveOmitsRuntimeOnlyAPIURL(t *testing.T) { require.NoError(t, err) assert.NotContains(t, string(data), "api_url") assert.NotContains(t, string(data), "http://localhost:8000") + assert.NotContains(t, string(data), "local-anon-key") + assert.NotContains(t, string(data), "local-service-key") loaded, err := Load() require.NoError(t, err) assert.Empty(t, loaded.APIBaseURL) + assert.Empty(t, loaded.AnonKey) + assert.Empty(t, loaded.ServiceKey) assert.Equal(t, "file-token", loaded.UserToken) } +func TestFunctionInvokeTokenPrefersServiceKey(t *testing.T) { + cfg := &Config{ + UserToken: "user-token", + AnonKey: "anon-key", + ServiceKey: "service-key", + } + assert.Equal(t, "service-key", cfg.FunctionInvokeToken()) + + cfg.ServiceKey = "" + assert.Equal(t, "anon-key", cfg.FunctionInvokeToken()) + + cfg.AnonKey = "" + assert.Equal(t, "user-token", cfg.FunctionInvokeToken()) +} + +func TestFunctionAliasesPersistByScope(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + scope := FunctionAliasScope("http://localhost:8000/", "project-123") + otherScope := FunctionAliasScope("https://api.volcano.dev", "project-123") + cfg := &Config{} + cfg.SetFunctionAlias(scope, "hello", "33333333-3333-4333-8333-333333333333") + cfg.SetFunctionAlias(otherScope, "hello", "44444444-4444-4444-8444-444444444444") + require.NoError(t, cfg.Save()) + + loaded, err := Load() + require.NoError(t, err) + + got, ok := loaded.FunctionAlias(scope, "hello") + require.True(t, ok) + assert.Equal(t, "33333333-3333-4333-8333-333333333333", got) + got, ok = loaded.FunctionAlias(otherScope, "hello") + require.True(t, ok) + assert.Equal(t, "44444444-4444-4444-8444-444444444444", got) + assert.Equal(t, "http://localhost:8000|project-123", scope) +} + +func TestDeleteFunctionAliasCleansEmptyScope(t *testing.T) { + scope := FunctionAliasScope("https://api.volcano.dev", "project-123") + cfg := &Config{} + cfg.SetFunctionAlias(scope, "hello", "33333333-3333-4333-8333-333333333333") + + assert.True(t, cfg.DeleteFunctionAlias(scope, "hello")) + assert.False(t, cfg.DeleteFunctionAlias(scope, "hello")) + assert.Empty(t, cfg.FunctionAliases) +} + func TestSaveRepairsExistingConfigPermissions(t *testing.T) { t.Setenv("HOME", t.TempDir()) diff --git a/internal/function/function.go b/internal/function/function.go index b041033..47b3e4c 100644 --- a/internal/function/function.go +++ b/internal/function/function.go @@ -6,12 +6,14 @@ import ( "errors" "fmt" "path/filepath" + "sort" "strings" "github.com/google/uuid" "github.com/Kong/volcano-cli/internal/api" "github.com/Kong/volcano-cli/internal/apiclient" + cliconfig "github.com/Kong/volcano-cli/internal/config" cliruntime "github.com/Kong/volcano-cli/internal/runtime" clisession "github.com/Kong/volcano-cli/internal/session" ) @@ -21,6 +23,12 @@ type Service struct { sessions clisession.Factory } +// Alias describes one configured function invoke alias. +type Alias struct { + Name string + FunctionID string +} + // NewService returns a function service. func NewService(deps cliruntime.Deps) Service { return Service{sessions: clisession.NewFactory(deps)} @@ -184,6 +192,121 @@ func (s Service) UpdateVisibility(ctx context.Context, identifier string, isPubl return updated, nil } +// Invoke invokes one function by alias, normalized name/path, or UUID. +func (s Service) Invoke(ctx context.Context, identifier string, payload map[string]any) (*apiclient.FunctionInvocationResponse, error) { + authenticated, err := s.sessions.CurrentProject() + if err != nil { + return nil, err + } + + functionID, err := resolveInvokeFunctionID(ctx, authenticated, identifier) + if errors.Is(err, api.ErrNotFound) { + return nil, fmt.Errorf("function %q not found", identifier) + } + if err != nil { + return nil, fmt.Errorf("failed to resolve function %q: %w", identifier, err) + } + + invokeAPI, err := authenticated.APIWithToken(authenticated.Config.FunctionInvokeToken()) + if err != nil { + return nil, err + } + resp, err := invokeAPI.InvokeFunction(ctx, functionID, api.FunctionInvokeInput{Payload: payload}) + if err != nil { + return nil, fmt.Errorf("failed to invoke function %q: %w", identifier, err) + } + return resp, nil +} + +// InvokeByID invokes one function by ID without list-based name resolution. +func (s Service) InvokeByID(ctx context.Context, functionID uuid.UUID, payload map[string]any) (*apiclient.FunctionInvocationResponse, error) { + authenticated, err := s.sessions.CurrentProject() + if err != nil { + return nil, err + } + + invokeAPI, err := authenticated.APIWithToken(authenticated.Config.FunctionInvokeToken()) + if err != nil { + return nil, err + } + resp, err := invokeAPI.InvokeFunction(ctx, functionID, api.FunctionInvokeInput{Payload: payload}) + if err != nil { + return nil, fmt.Errorf("failed to invoke function %q: %w", functionID.String(), err) + } + return resp, nil +} + +// ListAliases returns configured function invoke aliases for the current target. +func (s Service) ListAliases(ctx context.Context) ([]Alias, error) { + authenticated, err := s.sessions.CurrentProject() + if err != nil { + return nil, err + } + + scope := functionAliasScope(authenticated) + configured := authenticated.Config.FunctionAliases[scope] + aliases := make([]Alias, 0, len(configured)) + for name, functionID := range configured { + aliases = append(aliases, Alias{Name: name, FunctionID: functionID}) + } + sort.Slice(aliases, func(i, j int) bool { + return aliases[i].Name < aliases[j].Name + }) + return aliases, nil +} + +// SetAlias configures one function invoke alias for the current target. +func (s Service) SetAlias(ctx context.Context, alias, functionIDText string) (Alias, error) { + authenticated, err := s.sessions.CurrentProject() + if err != nil { + return Alias{}, err + } + + alias = normalizeTargetFunction(alias) + if alias == "" { + return Alias{}, errors.New("function alias cannot be empty") + } + functionID, err := uuid.Parse(strings.TrimSpace(functionIDText)) + if err != nil { + return Alias{}, fmt.Errorf("invalid function ID %q: %w", functionIDText, err) + } + + cfg, err := cliconfig.Load() + if err != nil { + return Alias{}, err + } + cfg.SetFunctionAlias(functionAliasScope(authenticated), alias, functionID.String()) + if err := cfg.Save(); err != nil { + return Alias{}, err + } + return Alias{Name: alias, FunctionID: functionID.String()}, nil +} + +// DeleteAlias removes one function invoke alias for the current target. +func (s Service) DeleteAlias(ctx context.Context, alias string) error { + authenticated, err := s.sessions.CurrentProject() + if err != nil { + return err + } + + alias = normalizeTargetFunction(alias) + if alias == "" { + return errors.New("function alias cannot be empty") + } + + cfg, err := cliconfig.Load() + if err != nil { + return err + } + if !cfg.DeleteFunctionAlias(functionAliasScope(authenticated), alias) { + return fmt.Errorf("function alias %q not found", alias) + } + if err := cfg.Save(); err != nil { + return err + } + return nil +} + // ListSchedulers returns schedulers configured for a function. func (s Service) ListSchedulers(ctx context.Context, identifier string) (*apiclient.Function, *apiclient.FunctionSchedulerListResponse, error) { authenticated, err := s.sessions.CurrentProject() @@ -338,6 +461,31 @@ func (s Service) DeploymentLogs(ctx context.Context, functionID, deploymentID uu return logs, nil } +func resolveInvokeFunctionID(ctx context.Context, authenticated *clisession.ProjectSession, identifier string) (uuid.UUID, error) { + target := normalizeTargetFunction(identifier) + if target == "" { + return uuid.Nil, errors.New("function identifier cannot be empty") + } + + if functionIDText, ok := authenticated.Config.FunctionAlias(functionAliasScope(authenticated), target); ok { + functionID, err := uuid.Parse(functionIDText) + if err != nil { + return uuid.Nil, fmt.Errorf("function alias %q has invalid function ID %q: %w", target, functionIDText, err) + } + return functionID, nil + } + + function, err := resolveFunction(ctx, authenticated, identifier) + if err != nil { + return uuid.Nil, err + } + return function.Id, nil +} + +func functionAliasScope(authenticated *clisession.ProjectSession) string { + return cliconfig.FunctionAliasScope(authenticated.APIURL, authenticated.ProjectID.String()) +} + func resolveFunction(ctx context.Context, authenticated *clisession.ProjectSession, identifier string) (*apiclient.Function, error) { target := normalizeTargetFunction(identifier) if target == "" { diff --git a/internal/session/session.go b/internal/session/session.go index 39d578e..9ff8e05 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -30,6 +30,7 @@ type ProjectSession struct { Config *config.Config API *api.Client ProjectID uuid.UUID + APIURL string apiClientForToken func(string) (*api.Client, error) } @@ -119,6 +120,7 @@ func (f Factory) CurrentProject() (*ProjectSession, error) { Config: authenticated.Config, API: authenticated.API, ProjectID: projectID, + APIURL: authenticated.apiURL, apiClientForToken: func(token string) (*api.Client, error) { return f.APIClient(authenticated.apiURL, token) },