diff --git a/internal/api/service_keys.go b/internal/api/service_keys.go new file mode 100644 index 0000000..168b964 --- /dev/null +++ b/internal/api/service_keys.go @@ -0,0 +1,39 @@ +package api + +import ( + "context" + + "github.com/google/uuid" + + "github.com/Kong/volcano-cli/internal/apiclient" +) + +// ListServiceKeys returns one service-key page for a project. +func (c *Client) ListServiceKeys(ctx context.Context, projectID uuid.UUID, page, limit int) (*apiclient.PaginatedServiceKeys, error) { + resp, err := c.client.ListServiceKeysWithResponse(ctx, projectID, &apiclient.ListServiceKeysParams{ + Page: &page, + Limit: &limit, + }) + if err != nil { + return nil, err + } + return apiResult(resp.StatusCode(), resp.Body, resp.JSON200) +} + +// CreateServiceKey creates one service key in a project. +func (c *Client) CreateServiceKey(ctx context.Context, projectID uuid.UUID, name string) (*apiclient.ServiceKey, error) { + resp, err := c.client.CreateServiceKeyWithResponse(ctx, projectID, apiclient.CreateServiceKeyJSONRequestBody{Name: name}) + if err != nil { + return nil, err + } + return apiResult(resp.StatusCode(), resp.Body, resp.JSON201) +} + +// GetServiceKey returns one service key by ID. +func (c *Client) GetServiceKey(ctx context.Context, projectID, keyID uuid.UUID) (*apiclient.ServiceKey, error) { + resp, err := c.client.GetServiceKeyWithResponse(ctx, projectID, keyID) + if err != nil { + return nil, err + } + return apiResult(resp.StatusCode(), resp.Body, resp.JSON200) +} diff --git a/internal/cmd/cloud/cloud.go b/internal/cmd/cloud/cloud.go index 8305a21..24558a8 100644 --- a/internal/cmd/cloud/cloud.go +++ b/internal/cmd/cloud/cloud.go @@ -11,6 +11,7 @@ import ( functionscmd "github.com/Kong/volcano-cli/internal/cmd/functions" storagecmd "github.com/Kong/volcano-cli/internal/cmd/storage" variablescmd "github.com/Kong/volcano-cli/internal/cmd/variables" + "github.com/Kong/volcano-cli/internal/dataplane" cliruntime "github.com/Kong/volcano-cli/internal/runtime" ) @@ -33,12 +34,13 @@ func New(deps cliruntime.Deps) *cobra.Command { // NewResourceCommands returns cloud resource commands. func NewResourceCommands(deps cliruntime.Deps) []*cobra.Command { deps.CommandPathPrefix = "volcano cloud" + dataPlaneKeys := dataplane.NewService(deps) return []*cobra.Command{ configcmd.New(deps), databasescmd.New(deps), frontendscmd.New(deps), - functionscmd.New(deps), - storagecmd.New(deps), + functionscmd.NewWithOptions(deps, functionscmd.WithInvokeTokenProvider(dataPlaneKeys.ServiceKeyForProject)), + storagecmd.NewWithOptions(deps, storagecmd.WithObjectTokenProvider(dataPlaneKeys.ServiceKey)), variablescmd.New(deps), } } diff --git a/internal/cmd/cloud/cloud_test.go b/internal/cmd/cloud/cloud_test.go new file mode 100644 index 0000000..a2e5732 --- /dev/null +++ b/internal/cmd/cloud/cloud_test.go @@ -0,0 +1,161 @@ +package cloud + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/spf13/cobra" + "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" +) + +const ( + cloudProjectID = "33333333-3333-4333-8333-333333333333" + cloudBucketID = "44444444-4444-4444-8444-444444444444" + cloudObjectID = "66666666-6666-4666-8666-666666666666" + cloudFunctionID = "77777777-7777-4777-8777-777777777777" +) + +func TestCloudStorageObjectCommandsUseCLIServiceKey(t *testing.T) { + setCloudCommandTestHome(t) + saveCloudCommandTestConfig(t) + + var serviceKeyListHits int + var storageListAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/projects/"+cloudProjectID+"/service-keys": + serviceKeyListHits++ + assert.Equal(t, "Bearer platform-token", r.Header.Get("Authorization")) + writeCloudCommandJSON(t, w, http.StatusOK, map[string]any{ + "data": []any{ + cloudServiceKeyPayload("11111111-1111-4111-8111-111111111111", "sk-storage"), + }, + "has_more": false, + "page": 1, + "limit": 100, + "total": 1, + }) + case r.Method == http.MethodGet && r.URL.Path == "/storage/uploads": + storageListAuth = r.Header.Get("Authorization") + writeCloudCommandJSON(t, w, http.StatusOK, map[string]any{ + "objects": []any{cloudObjectPayload("hello.txt")}, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + out, err := executeCloudCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "storage", "object", "list", "uploads") + require.NoError(t, err) + assert.Contains(t, out, "hello.txt") + assert.Equal(t, 1, serviceKeyListHits) + assert.Equal(t, "Bearer sk-storage", storageListAuth) +} + +func TestCloudFunctionInvokeUsesCLIServiceKey(t *testing.T) { + setCloudCommandTestHome(t) + saveCloudCommandTestConfig(t) + + var serviceKeyListHits int + var invokeAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/projects/"+cloudProjectID+"/service-keys": + serviceKeyListHits++ + assert.Equal(t, "Bearer platform-token", r.Header.Get("Authorization")) + writeCloudCommandJSON(t, w, http.StatusOK, map[string]any{ + "data": []any{ + cloudServiceKeyPayload("22222222-2222-4222-8222-222222222222", "sk-functions"), + }, + "has_more": false, + "page": 1, + "limit": 100, + "total": 1, + }) + case r.Method == http.MethodPost && r.URL.Path == "/functions/"+cloudFunctionID+"/invoke": + invokeAuth = r.Header.Get("Authorization") + writeCloudCommandJSON(t, w, http.StatusOK, map[string]any{"ok": true}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + out, err := executeCloudCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "functions", "invoke", "--id", cloudFunctionID, "--json") + require.NoError(t, err) + assert.JSONEq(t, `{"ok":true}`, out) + assert.Equal(t, 1, serviceKeyListHits) + assert.Equal(t, "Bearer sk-functions", invokeAuth) +} + +func executeCloudCommand(t *testing.T, cmd *cobra.Command, args ...string) (string, error) { + t.Helper() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), err +} + +func setCloudCommandTestHome(t *testing.T) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("VOLCANO_TOKEN", "") + t.Setenv("VOLCANO_PROJECT_ID", "") + t.Setenv("VOLCANO_API_URL", "") + t.Setenv("VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID", "") +} + +func saveCloudCommandTestConfig(t *testing.T) { + t.Helper() + cfg := &cliconfig.Config{ + UserToken: "platform-token", + CurrentProject: &cliconfig.ProjectConfig{ + ID: cloudProjectID, + Name: "Gamma", + }, + } + require.NoError(t, cfg.Save()) +} + +func writeCloudCommandJSON(t *testing.T, w http.ResponseWriter, status int, value any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + require.NoError(t, json.NewEncoder(w).Encode(value)) +} + +func cloudServiceKeyPayload(id, keyValue string) map[string]any { + return map[string]any{ + "id": id, + "project_id": cloudProjectID, + "name": "volcano-cli-data-plane", + "key_prefix": keyValue[:min(len(keyValue), 12)], + "key_value": keyValue, + "permissions": []string{"*"}, + "created_at": "2026-05-20T00:00:00Z", + "updated_at": "2026-05-20T00:00:00Z", + } +} + +func cloudObjectPayload(name string) map[string]any { + return map[string]any{ + "id": cloudObjectID, + "bucket_id": cloudBucketID, + "name": name, + "size": 12, + "mime_type": "text/plain", + "is_public": false, + "created_at": "2026-05-20T00:00:00Z", + "updated_at": "2026-05-20T00:00:00Z", + } +} diff --git a/internal/cmd/functions/functions.go b/internal/cmd/functions/functions.go index 73ae352..734e21e 100644 --- a/internal/cmd/functions/functions.go +++ b/internal/cmd/functions/functions.go @@ -4,12 +4,32 @@ import ( "github.com/spf13/cobra" schedulerscmd "github.com/Kong/volcano-cli/internal/cmd/functions/schedulers" + clifunction "github.com/Kong/volcano-cli/internal/function" cliruntime "github.com/Kong/volcano-cli/internal/runtime" ) +// Option configures function command behavior. +type Option func(*commandOptions) + +// WithInvokeTokenProvider configures the bearer token source for function invoke routes. +func WithInvokeTokenProvider(provider clifunction.InvokeTokenProvider) Option { + return func(opts *commandOptions) { + opts.functionOptions = append(opts.functionOptions, clifunction.WithInvokeTokenProvider(provider)) + } +} + // New returns the functions command. func New(deps cliruntime.Deps) *cobra.Command { - return newWithOptions(deps, commandOptions{batchDeployAll: true}) + return NewWithOptions(deps) +} + +// NewWithOptions returns the functions command with custom function behavior. +func NewWithOptions(deps cliruntime.Deps, options ...Option) *cobra.Command { + opts := commandOptions{batchDeployAll: true} + for _, option := range options { + option(&opts) + } + return newWithOptions(deps, opts) } // NewLocal returns the functions command for local-mode projects. @@ -18,7 +38,8 @@ func NewLocal(deps cliruntime.Deps) *cobra.Command { } type commandOptions struct { - batchDeployAll bool + batchDeployAll bool + functionOptions []clifunction.Option } func newWithOptions(deps cliruntime.Deps, opts commandOptions) *cobra.Command { @@ -30,7 +51,7 @@ 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(newInvoke(deps, opts.functionOptions...)) cmd.AddCommand(newAlias(deps)) cmd.AddCommand(newDelete(deps)) cmd.AddCommand(newUpdate(deps)) diff --git a/internal/cmd/functions/invoke.go b/internal/cmd/functions/invoke.go index c31d266..688b2a2 100644 --- a/internal/cmd/functions/invoke.go +++ b/internal/cmd/functions/invoke.go @@ -16,16 +16,17 @@ import ( ) type invokeOptions struct { - deps cliruntime.Deps - identifier string - functionID string - payload string - hasPayload bool - jsonOutput bool - out io.Writer + deps cliruntime.Deps + functionOptions []clifunction.Option + identifier string + functionID string + payload string + hasPayload bool + jsonOutput bool + out io.Writer } -func newInvoke(deps cliruntime.Deps) *cobra.Command { +func newInvoke(deps cliruntime.Deps, functionOptions ...clifunction.Option) *cobra.Command { opts := invokeOptions{} cmd := &cobra.Command{ Use: "invoke [name]", @@ -40,6 +41,7 @@ func newInvoke(deps cliruntime.Deps) *cobra.Command { Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { opts.deps = deps + opts.functionOptions = functionOptions if len(args) == 1 { opts.identifier = strings.TrimSpace(args[0]) } @@ -73,7 +75,7 @@ func runInvoke(ctx context.Context, opts invokeOptions) error { } } - service := clifunction.NewService(opts.deps) + service := clifunction.NewService(opts.deps, opts.functionOptions...) var resp any if hasID { var functionID uuid.UUID diff --git a/internal/dataplane/service_key.go b/internal/dataplane/service_key.go new file mode 100644 index 0000000..abc41a1 --- /dev/null +++ b/internal/dataplane/service_key.go @@ -0,0 +1,134 @@ +// Package dataplane obtains project data-plane credentials for cloud commands. +package dataplane + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + + "github.com/Kong/volcano-cli/internal/api" + "github.com/Kong/volcano-cli/internal/apiclient" + cliruntime "github.com/Kong/volcano-cli/internal/runtime" + clisession "github.com/Kong/volcano-cli/internal/session" +) + +// CLIServiceKeyName is the reserved project service key used by cloud data-plane +// commands when the platform token cannot call the runtime route directly. +const CLIServiceKeyName = "volcano-cli-data-plane" + +// Service obtains data-plane credentials for the current cloud project. +type Service struct { + sessions clisession.Factory + keyName string +} + +// NewService returns a data-plane credential service. +func NewService(deps cliruntime.Deps) Service { + return Service{ + sessions: clisession.NewFactory(deps), + keyName: CLIServiceKeyName, + } +} + +// ServiceKey returns the reserved service key for the current project, creating +// it when it does not already exist. +func (s Service) ServiceKey(ctx context.Context) (string, error) { + project, err := s.sessions.CurrentProject() + if err != nil { + return "", err + } + return s.ServiceKeyForProject(ctx, project) +} + +// ServiceKeyForProject returns the reserved service key for project, creating it +// when it does not already exist. +func (s Service) ServiceKeyForProject(ctx context.Context, project *clisession.ProjectSession) (string, error) { + if project == nil { + return "", fmt.Errorf("project session is required") + } + name := s.serviceKeyName() + key, found, err := s.findServiceKey(ctx, project, name) + if err != nil { + return "", err + } + if found { + return s.serviceKeyValue(ctx, project, key) + } + + created, err := project.API.CreateServiceKey(ctx, project.ProjectID, name) + if api.Status(err) == http.StatusConflict { + return s.serviceKeyAfterCreateConflict(ctx, project, name) + } + if err != nil { + return "", fmt.Errorf("failed to create CLI service key %q: %w", name, err) + } + return serviceKeyPlaintext(created, name) +} + +func (s Service) serviceKeyName() string { + name := strings.TrimSpace(s.keyName) + if name == "" { + return CLIServiceKeyName + } + return name +} + +func (s Service) serviceKeyAfterCreateConflict(ctx context.Context, project *clisession.ProjectSession, name string) (string, error) { + key, found, err := s.findServiceKey(ctx, project, name) + if err != nil { + return "", err + } + if !found { + return "", fmt.Errorf("CLI service key %q already exists but could not be loaded", name) + } + return s.serviceKeyValue(ctx, project, key) +} + +func (s Service) findServiceKey(ctx context.Context, project *clisession.ProjectSession, name string) (*apiclient.ServiceKey, bool, error) { + for page := api.DefaultPage; ; page++ { + keys, err := project.API.ListServiceKeys(ctx, project.ProjectID, page, api.DefaultLimit) + if err != nil { + return nil, false, fmt.Errorf("failed to list service keys: %w", err) + } + if keys == nil { + return nil, false, nil + } + for i := range keys.Data { + if strings.EqualFold(keys.Data[i].Name, name) { + return &keys.Data[i], true, nil + } + } + if !keys.HasMore { + return nil, false, nil + } + } +} + +func (s Service) serviceKeyValue(ctx context.Context, project *clisession.ProjectSession, key *apiclient.ServiceKey) (string, error) { + if value, ok := serviceKeyPlaintextOK(key); ok { + return value, nil + } + loaded, err := project.API.GetServiceKey(ctx, project.ProjectID, uuid.UUID(key.Id)) + if err != nil { + return "", fmt.Errorf("failed to load CLI service key %q: %w", key.Name, err) + } + return serviceKeyPlaintext(loaded, key.Name) +} + +func serviceKeyPlaintext(key *apiclient.ServiceKey, name string) (string, error) { + if value, ok := serviceKeyPlaintextOK(key); ok { + return value, nil + } + return "", fmt.Errorf("service key %q did not include key material", name) +} + +func serviceKeyPlaintextOK(key *apiclient.ServiceKey) (string, bool) { + if key == nil || key.KeyValue == nil { + return "", false + } + value := strings.TrimSpace(*key.KeyValue) + return value, value != "" +} diff --git a/internal/dataplane/service_key_test.go b/internal/dataplane/service_key_test.go new file mode 100644 index 0000000..3fc4f30 --- /dev/null +++ b/internal/dataplane/service_key_test.go @@ -0,0 +1,159 @@ +package dataplane + +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" +) + +const testProjectID = "33333333-3333-4333-8333-333333333333" + +func TestServiceKeyReturnsExistingCLIKey(t *testing.T) { + setServiceKeyTestHome(t) + saveServiceKeyTestConfig(t) + + var listHits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer platform-token", r.Header.Get("Authorization")) + switch { + case r.Method == http.MethodGet && r.URL.Path == "/projects/"+testProjectID+"/service-keys": + listHits++ + assert.Equal(t, "1", r.URL.Query().Get("page")) + assert.Equal(t, "100", r.URL.Query().Get("limit")) + writeServiceKeyJSON(t, w, http.StatusOK, map[string]any{ + "data": []any{ + serviceKeyPayload("11111111-1111-4111-8111-111111111111", CLIServiceKeyName, "sk-existing"), + }, + "has_more": false, + "page": 1, + "limit": 100, + "total": 1, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + service := NewService(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}) + key, err := service.ServiceKey(t.Context()) + require.NoError(t, err) + assert.Equal(t, "sk-existing", key) + assert.Equal(t, 1, listHits) +} + +func TestServiceKeyCreatesMissingCLIKey(t *testing.T) { + setServiceKeyTestHome(t) + saveServiceKeyTestConfig(t) + + var createBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer platform-token", r.Header.Get("Authorization")) + switch { + case r.Method == http.MethodGet && r.URL.Path == "/projects/"+testProjectID+"/service-keys": + writeServiceKeyJSON(t, w, http.StatusOK, map[string]any{ + "data": []any{}, + "has_more": false, + "page": 1, + "limit": 100, + "total": 0, + }) + case r.Method == http.MethodPost && r.URL.Path == "/projects/"+testProjectID+"/service-keys": + require.NoError(t, json.NewDecoder(r.Body).Decode(&createBody)) + writeServiceKeyJSON(t, w, http.StatusCreated, serviceKeyPayload("22222222-2222-4222-8222-222222222222", CLIServiceKeyName, "sk-created")) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + service := NewService(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}) + key, err := service.ServiceKey(t.Context()) + require.NoError(t, err) + assert.Equal(t, "sk-created", key) + assert.Equal(t, map[string]any{"name": CLIServiceKeyName}, createBody) +} + +func TestServiceKeyReloadsAfterCreateConflict(t *testing.T) { + setServiceKeyTestHome(t) + saveServiceKeyTestConfig(t) + + listHits := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer platform-token", r.Header.Get("Authorization")) + switch { + case r.Method == http.MethodGet && r.URL.Path == "/projects/"+testProjectID+"/service-keys": + listHits++ + data := []any{} + if listHits > 1 { + data = append(data, serviceKeyPayload("33333333-3333-4333-8333-333333333333", CLIServiceKeyName, "sk-raced")) + } + writeServiceKeyJSON(t, w, http.StatusOK, map[string]any{ + "data": data, + "has_more": false, + "page": 1, + "limit": 100, + "total": len(data), + }) + case r.Method == http.MethodPost && r.URL.Path == "/projects/"+testProjectID+"/service-keys": + writeServiceKeyJSON(t, w, http.StatusConflict, map[string]any{"error": "already exists"}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + service := NewService(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}) + key, err := service.ServiceKey(t.Context()) + require.NoError(t, err) + assert.Equal(t, "sk-raced", key) + assert.Equal(t, 2, listHits) +} + +func setServiceKeyTestHome(t *testing.T) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("VOLCANO_TOKEN", "") + t.Setenv("VOLCANO_PROJECT_ID", "") + t.Setenv("VOLCANO_API_URL", "") + t.Setenv("VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID", "") +} + +func saveServiceKeyTestConfig(t *testing.T) { + t.Helper() + cfg := &cliconfig.Config{ + UserToken: "platform-token", + CurrentProject: &cliconfig.ProjectConfig{ + ID: testProjectID, + Name: "Gamma", + }, + } + require.NoError(t, cfg.Save()) +} + +func writeServiceKeyJSON(t *testing.T, w http.ResponseWriter, status int, value any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + require.NoError(t, json.NewEncoder(w).Encode(value)) +} + +func serviceKeyPayload(id, name, keyValue string) map[string]any { + return map[string]any{ + "id": id, + "project_id": testProjectID, + "name": name, + "key_prefix": keyValue[:min(len(keyValue), 12)], + "key_value": keyValue, + "permissions": []string{"*"}, + "created_at": "2026-05-20T00:00:00Z", + "updated_at": "2026-05-20T00:00:00Z", + } +} diff --git a/internal/function/function.go b/internal/function/function.go index 6b9b2e3..394d520 100644 --- a/internal/function/function.go +++ b/internal/function/function.go @@ -18,9 +18,23 @@ import ( clisession "github.com/Kong/volcano-cli/internal/session" ) +// InvokeTokenProvider returns the bearer token to use for function invoke routes. +type InvokeTokenProvider func(context.Context, *clisession.ProjectSession) (string, error) + // Service performs authenticated Volcano function workflows. type Service struct { - sessions clisession.Factory + sessions clisession.Factory + invokeTokenProvider InvokeTokenProvider +} + +// Option configures function workflows. +type Option func(*Service) + +// WithInvokeTokenProvider configures the bearer token source for function invoke routes. +func WithInvokeTokenProvider(provider InvokeTokenProvider) Option { + return func(s *Service) { + s.invokeTokenProvider = provider + } } // Alias describes one configured function invoke alias. @@ -30,8 +44,12 @@ type Alias struct { } // NewService returns a function service. -func NewService(deps cliruntime.Deps) Service { - return Service{sessions: clisession.NewFactory(deps)} +func NewService(deps cliruntime.Deps, opts ...Option) Service { + service := Service{sessions: clisession.NewFactory(deps)} + for _, opt := range opts { + opt(&service) + } + return service } // ListPage returns one function page in the current project. @@ -207,7 +225,7 @@ func (s Service) Invoke(ctx context.Context, identifier string, payload map[stri return nil, fmt.Errorf("failed to resolve function %q: %w", identifier, err) } - invokeAPI, err := authenticated.APIWithToken(authenticated.Config.FunctionInvokeToken()) + invokeAPI, err := s.invokeAPI(ctx, authenticated) if err != nil { return nil, err } @@ -220,20 +238,52 @@ func (s Service) Invoke(ctx context.Context, identifier string, payload map[stri // 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.Authenticated() + invokeAPI, err := s.invokeAPIForID(ctx) 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 +} + +func (s Service) invokeAPIForID(ctx context.Context) (*api.Client, error) { + if s.invokeTokenProvider == nil { + authenticated, err := s.sessions.Authenticated() + if err != nil { + return nil, err + } + return authenticated.APIWithToken(authenticated.Config.FunctionInvokeToken()) + } - invokeAPI, err := authenticated.APIWithToken(authenticated.Config.FunctionInvokeToken()) + project, err := s.sessions.CurrentProject() if err != nil { return nil, err } - resp, err := invokeAPI.InvokeFunction(ctx, functionID, api.FunctionInvokeInput{Payload: payload}) + return s.invokeAPI(ctx, project) +} + +func (s Service) invokeAPI(ctx context.Context, project *clisession.ProjectSession) (*api.Client, error) { + token, err := s.invokeToken(ctx, project) if err != nil { - return nil, fmt.Errorf("failed to invoke function %q: %w", functionID.String(), err) + return nil, err } - return resp, nil + return project.APIWithToken(token) +} + +func (s Service) invokeToken(ctx context.Context, project *clisession.ProjectSession) (string, error) { + if project == nil || project.Config == nil { + return "", errors.New("project session is required") + } + if strings.TrimSpace(project.Config.ServiceKey) != "" || strings.TrimSpace(project.Config.AnonKey) != "" { + return project.Config.FunctionInvokeToken(), nil + } + if s.invokeTokenProvider != nil { + return s.invokeTokenProvider(ctx, project) + } + return project.Config.FunctionInvokeToken(), nil } // ListAliases returns configured function invoke aliases for the current target.