diff --git a/internal/api/service_keys.go b/internal/api/service_keys.go new file mode 100644 index 0000000..f98916d --- /dev/null +++ b/internal/api/service_keys.go @@ -0,0 +1,45 @@ +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. When permissions is +// non-empty the key is scoped to exactly those operations; otherwise the server +// grants full access (["*"]). +func (c *Client) CreateServiceKey(ctx context.Context, projectID uuid.UUID, name string, permissions []string) (*apiclient.ServiceKey, error) { + body := apiclient.CreateServiceKeyJSONRequestBody{Name: name} + if len(permissions) > 0 { + body.Permissions = &permissions + } + resp, err := c.client.CreateServiceKeyWithResponse(ctx, projectID, body) + 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/apiclient/client.gen.go b/internal/apiclient/client.gen.go index ddd7495..837ae08 100644 --- a/internal/apiclient/client.gen.go +++ b/internal/apiclient/client.gen.go @@ -1124,6 +1124,12 @@ type SchedulerId = openapi_types.UUID // VariableName defines model for VariableName. type VariableName = string +// BandwidthCapExceeded defines model for BandwidthCapExceeded. +type BandwidthCapExceeded = Error + +// DatabaseQueryCapExceeded defines model for DatabaseQueryCapExceeded. +type DatabaseQueryCapExceeded = Error + // anonKeyContextKey is the context key for AnonKey security scheme type anonKeyContextKey string @@ -1773,6 +1779,12 @@ type CreateServiceKeyJSONBody struct { // Name Descriptive name for the key (e.g., "admin-dashboard", "background-jobs"). // Can only contain letters, numbers, underscores, and hyphens. Name string `json:"name"` + + // Permissions Optional least-privilege scope for the key. When omitted, the key + // is granted full access (["*"]) for backward compatibility. Provide + // an explicit list (e.g. ["functions.invoke", "storage.download"]) to + // restrict the operations the key may perform. "*" grants everything. + Permissions *[]string `json:"permissions,omitempty"` } // ListStorageObjectsAdminParams defines parameters for ListStorageObjectsAdmin. @@ -14755,6 +14767,7 @@ type QueryDatabaseDeleteClientResponse struct { JSON401 *Error JSON403 *Error JSON404 *Error + JSON429 *DatabaseQueryCapExceeded } // Status returns HTTPResponse.Status @@ -14794,6 +14807,7 @@ type QueryDatabaseInsertClientResponse struct { JSON401 *Error JSON403 *Error JSON404 *Error + JSON429 *DatabaseQueryCapExceeded } // Status returns HTTPResponse.Status @@ -14834,6 +14848,7 @@ type QueryDatabaseSelectClientResponse struct { JSON401 *Error JSON403 *Error JSON404 *Error + JSON429 *DatabaseQueryCapExceeded } // Status returns HTTPResponse.Status @@ -14873,6 +14888,7 @@ type QueryDatabaseUpdateClientResponse struct { JSON401 *Error JSON403 *Error JSON404 *Error + JSON429 *DatabaseQueryCapExceeded } // Status returns HTTPResponse.Status @@ -16389,6 +16405,7 @@ type CreateEmailTemplateClientResponse struct { HTTPResponse *http.Response JSON201 *EmailTemplate JSON400 *Error + JSON403 *Error } // Status returns HTTPResponse.Status @@ -16418,6 +16435,7 @@ func (r CreateEmailTemplateClientResponse) ContentType() string { type DeleteEmailTemplateClientResponse struct { Body []byte HTTPResponse *http.Response + JSON403 *Error } // Status returns HTTPResponse.Status @@ -16478,6 +16496,7 @@ type UpdateEmailTemplateClientResponse struct { Body []byte HTTPResponse *http.Response JSON200 *EmailTemplate + JSON403 *Error } // Status returns HTTPResponse.Status @@ -18387,6 +18406,7 @@ func (r UpdateVariableClientResponse) ContentType() string { type DownloadPublicFileClientResponse struct { Body []byte HTTPResponse *http.Response + JSON429 *BandwidthCapExceeded } // Status returns HTTPResponse.Status @@ -18417,6 +18437,7 @@ type ListStorageObjectsClientResponse struct { Body []byte HTTPResponse *http.Response JSON200 *StorageListResponse + JSON429 *BandwidthCapExceeded } // Status returns HTTPResponse.Status @@ -18447,6 +18468,7 @@ type CopyStorageObjectClientResponse struct { Body []byte HTTPResponse *http.Response JSON201 *StorageObject + JSON429 *BandwidthCapExceeded } // Status returns HTTPResponse.Status @@ -18477,6 +18499,7 @@ type MoveStorageObjectClientResponse struct { Body []byte HTTPResponse *http.Response JSON200 *StorageObject + JSON429 *BandwidthCapExceeded } // Status returns HTTPResponse.Status @@ -18506,6 +18529,7 @@ func (r MoveStorageObjectClientResponse) ContentType() string { type DeleteStorageObjectClientResponse struct { Body []byte HTTPResponse *http.Response + JSON429 *BandwidthCapExceeded } // Status returns HTTPResponse.Status @@ -18536,6 +18560,7 @@ type DownloadStorageObjectClientResponse struct { Body []byte HTTPResponse *http.Response JSON200 *UploadSessionStatusResponse + JSON429 *BandwidthCapExceeded } // Status returns HTTPResponse.Status @@ -18567,6 +18592,7 @@ type UploadStorageObjectClientResponse struct { HTTPResponse *http.Response JSON200 *CompleteUploadSessionResponse JSON201 *UploadStorageObject201JSONResponseBody + JSON429 *BandwidthCapExceeded } // Status returns HTTPResponse.Status @@ -21693,6 +21719,13 @@ func ParseQueryDatabaseDeleteClientResponse(rsp *http.Response) (*QueryDatabaseD } response.JSON404 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest DatabaseQueryCapExceeded + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + } return response, nil @@ -21752,6 +21785,13 @@ func ParseQueryDatabaseInsertClientResponse(rsp *http.Response) (*QueryDatabaseI } response.JSON404 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest DatabaseQueryCapExceeded + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + } return response, nil @@ -21812,6 +21852,13 @@ func ParseQueryDatabaseSelectClientResponse(rsp *http.Response) (*QueryDatabaseS } response.JSON404 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest DatabaseQueryCapExceeded + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + } return response, nil @@ -21871,6 +21918,13 @@ func ParseQueryDatabaseUpdateClientResponse(rsp *http.Response) (*QueryDatabaseU } response.JSON404 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest DatabaseQueryCapExceeded + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + } return response, nil @@ -23333,6 +23387,13 @@ func ParseCreateEmailTemplateClientResponse(rsp *http.Response) (*CreateEmailTem } response.JSON400 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + } return response, nil @@ -23351,6 +23412,16 @@ func ParseDeleteEmailTemplateClientResponse(rsp *http.Response) (*DeleteEmailTem HTTPResponse: rsp, } + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + } + return response, nil } @@ -23401,6 +23472,13 @@ func ParseUpdateEmailTemplateClientResponse(rsp *http.Response) (*UpdateEmailTem } response.JSON200 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + } return response, nil @@ -25624,6 +25702,16 @@ func ParseDownloadPublicFileClientResponse(rsp *http.Response) (*DownloadPublicF HTTPResponse: rsp, } + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest BandwidthCapExceeded + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + } + return response, nil } @@ -25648,6 +25736,13 @@ func ParseListStorageObjectsClientResponse(rsp *http.Response) (*ListStorageObje } response.JSON200 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest BandwidthCapExceeded + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + } return response, nil @@ -25674,6 +25769,13 @@ func ParseCopyStorageObjectClientResponse(rsp *http.Response) (*CopyStorageObjec } response.JSON201 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest BandwidthCapExceeded + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + } return response, nil @@ -25700,6 +25802,13 @@ func ParseMoveStorageObjectClientResponse(rsp *http.Response) (*MoveStorageObjec } response.JSON200 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest BandwidthCapExceeded + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + } return response, nil @@ -25718,6 +25827,16 @@ func ParseDeleteStorageObjectClientResponse(rsp *http.Response) (*DeleteStorageO HTTPResponse: rsp, } + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest BandwidthCapExceeded + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + } + return response, nil } @@ -25742,6 +25861,13 @@ func ParseDownloadStorageObjectClientResponse(rsp *http.Response) (*DownloadStor } response.JSON200 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest BandwidthCapExceeded + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + case rsp.StatusCode == 200: // Content-type (application/octet-stream) unsupported @@ -25778,6 +25904,13 @@ func ParseUploadStorageObjectClientResponse(rsp *http.Response) (*UploadStorageO } response.JSON201 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest BandwidthCapExceeded + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + } return response, nil diff --git a/internal/apiclient/common/common.gen.go b/internal/apiclient/common/common.gen.go index f4ecbdd..a42ba1a 100644 --- a/internal/apiclient/common/common.gen.go +++ b/internal/apiclient/common/common.gen.go @@ -714,6 +714,21 @@ func (e LiveLogLevel) Valid() bool { } } +// Defines values for LogDatabaseRequestResourceType. +const ( + LogDatabaseRequestResourceTypeDatabase LogDatabaseRequestResourceType = "database" +) + +// Valid indicates whether the value is a known member of the LogDatabaseRequestResourceType enum. +func (e LogDatabaseRequestResourceType) Valid() bool { + switch e { + case LogDatabaseRequestResourceTypeDatabase: + return true + default: + return false + } +} + // Defines values for LogDeploymentStage. const ( Compile LogDeploymentStage = "compile" @@ -764,6 +779,7 @@ func (e LogFunctionRequestResourceType) Valid() bool { // Defines values for LogResourceType. const ( + LogResourceTypeDatabase LogResourceType = "database" LogResourceTypeFrontend LogResourceType = "frontend" LogResourceTypeFunction LogResourceType = "function" ) @@ -771,6 +787,8 @@ const ( // Valid indicates whether the value is a known member of the LogResourceType enum. func (e LogResourceType) Valid() bool { switch e { + case LogResourceTypeDatabase: + return true case LogResourceTypeFrontend: return true case LogResourceTypeFunction: @@ -2067,6 +2085,18 @@ type LogActivityResponse struct { Total int `json:"total"` } +// LogDatabaseRequestResource Database runtime log resource selector. Deployment logs are not supported for databases. +type LogDatabaseRequestResource struct { + // Ids Optional database identifiers. Omit or send an empty array to include every database in the project. + Ids *[]openapi_types.UUID `json:"ids,omitempty"` + + // Type Resource type to read logs for. + Type LogDatabaseRequestResourceType `json:"type"` +} + +// LogDatabaseRequestResourceType Resource type to read logs for. +type LogDatabaseRequestResourceType string + // LogDeployment Deployment context associated with a historical deployment log event. type LogDeployment struct { // Id Deployment ID associated with the log event. @@ -2155,7 +2185,7 @@ type LogRequestResource struct { // LogResource Resource that owns a historical log event. type LogResource struct { - // Id Function or frontend ID that owns the log event. + // Id Resource ID that owns the log event. Id openapi_types.UUID `json:"id"` // Name Resource name associated with the log event, when available. @@ -2263,16 +2293,21 @@ type LogStreamRequest struct { // MetricUsageData Usage data for one metric across totals, daily, and hourly windows. type MetricUsageData struct { + // AllTime Lifetime cumulative usage across every month for this metric + AllTime int64 `json:"all_time"` + // Daily Last 30 days of daily usage points Daily []UsageDataPoint `json:"daily"` // Hourly Last 24 hours of hourly usage points Hourly []UsageDataPoint `json:"hourly"` - // Metric Metric name (for example, "Function Invocations", "Frontend Requests", + // Metric Metric name (for example, "Function & Frontend Invocations", "Frontend Requests", // "CodeBuild Build Seconds", "Bandwidth Ingress (Bytes)", "Bandwidth Egress (Bytes)", - // or "Bandwidth Total (Bytes)"). Bandwidth metrics are reported in bytes; - // "Bandwidth Total (Bytes)" is derived (ingress + egress) and is not billed separately. + // "Bandwidth Total (Bytes)", or "Database Storage (Bytes)"). Byte-based metrics are + // reported in bytes. "Bandwidth Total (Bytes)" is derived (ingress + egress) and + // is not billed separately. "Database Storage (Bytes)" is a current observed gauge, + // not a cumulative counter. Metric string `json:"metric"` // Total Total usage for the current usage month @@ -2757,7 +2792,9 @@ type ServiceKey struct { // Name Descriptive name for the key Name string `json:"name"` - // Permissions Always ["*"] for full admin access + // Permissions Operations this key may perform. ["*"] grants full admin access + // (default for keys created without an explicit scope). Scoped keys + // list specific permissions, e.g. ["functions.invoke"]. Permissions []string `json:"permissions"` ProjectId *openapi_types.UUID `json:"project_id,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"` @@ -3257,6 +3294,34 @@ func (t *LogRequestResource) MergeLogFrontendRequestResource(v LogFrontendReques return err } +// AsLogDatabaseRequestResource returns the union data inside the LogRequestResource as a LogDatabaseRequestResource +func (t LogRequestResource) AsLogDatabaseRequestResource() (LogDatabaseRequestResource, error) { + var body LogDatabaseRequestResource + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromLogDatabaseRequestResource overwrites any union data inside the LogRequestResource as the provided LogDatabaseRequestResource +func (t *LogRequestResource) FromLogDatabaseRequestResource(v LogDatabaseRequestResource) error { + v.Type = "database" + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeLogDatabaseRequestResource performs a merge with any union data inside the LogRequestResource, using the provided LogDatabaseRequestResource +func (t *LogRequestResource) MergeLogDatabaseRequestResource(v LogDatabaseRequestResource) error { + v.Type = "database" + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + func (t LogRequestResource) Discriminator() (string, error) { var discriminator struct { Discriminator string `json:"type"` @@ -3271,6 +3336,8 @@ func (t LogRequestResource) ValueByDiscriminator() (interface{}, error) { return nil, err } switch discriminator { + case "database": + return t.AsLogDatabaseRequestResource() case "frontend": return t.AsLogFrontendRequestResource() case "function": 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..00e0eb2 --- /dev/null +++ b/internal/dataplane/service_key.go @@ -0,0 +1,145 @@ +// Package dataplane obtains project data-plane credentials for cloud commands. +package dataplane + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "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" + +// cliDataPlanePermissions is the least-privilege scope requested for the reserved +// data-plane key: function invocation and storage object I/O only. It must cover +// every operation the CLI performs with this key (copy/move/set-visibility map +// to storage.upload server-side), and nothing else. +var cliDataPlanePermissions = []string{ + "functions.invoke", + "storage.upload", + "storage.download", + "storage.list", + "storage.delete", +} + +// 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 "", errors.New("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, cliDataPlanePermissions) + 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, 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..d455a45 --- /dev/null +++ b/internal/dataplane/service_key_test.go @@ -0,0 +1,167 @@ +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) + // Derive the expected scope from the source of truth so the two cannot drift. + expectedPermissions := make([]any, len(cliDataPlanePermissions)) + for i, p := range cliDataPlanePermissions { + expectedPermissions[i] = p + } + assert.Equal(t, map[string]any{ + "name": CLIServiceKeyName, + "permissions": expectedPermissions, + }, 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. diff --git a/internal/localmode/assets/docker-compose.template.yml b/internal/localmode/assets/docker-compose.template.yml index d7f7a11..7f86c52 100644 --- a/internal/localmode/assets/docker-compose.template.yml +++ b/internal/localmode/assets/docker-compose.template.yml @@ -82,9 +82,9 @@ services: AWS_REGION: us-east-1 AWS_ACCESS_KEY_ID: local AWS_SECRET_ACCESS_KEY: local - REDIS_TIMEOUT: "60" - USAGE_SYNC_INTERVAL: "30" - USAGE_SYNC_LOCK_TTL: "30" + REDIS_TIMEOUT: "60s" + USAGE_SYNC_INTERVAL: "30s" + USAGE_SYNC_LOCK_TTL: "30s" SOURCE_ARCHIVE_SIZE_LIMIT_MB: "256" LAMBDA_TARGET_CONTAINER_SIZE_LIMIT_MB: "4096" PORT: "8000"