diff --git a/.golangci.yml b/.golangci.yml index 83740af..58b327f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -60,6 +60,11 @@ linters: excludes: - G104 # duplicates errcheck - G304 # CLI legitimately reads user-supplied file paths + misspell: + ignore-rules: + # API field name (auth enable_anonymous_signins); same exception as + # the volcano-hosting lint config. + - signins testifylint: disable: # require inside httptest handlers exits the handler goroutine but diff --git a/README.md b/README.md index 128b57f..1ad95c1 100644 --- a/README.md +++ b/README.md @@ -83,16 +83,22 @@ just like `volcano login` — so you normally don't need anything else. Set ## Project configuration (`volcano-config.yaml`) -`volcano config deploy` reconciles declarative project configuration -(`volcano/volcano-config.yaml` or `./volcano-config.yaml`) against the active -target — the same manifest applies to local mode and cloud. - -Functions may declare scheduled invocations. `name` and `cron` are required; -`enabled` (default `true`), `payload`, and `regions` are optional. A function -entry is valid if it sets `public` **or** declares at least one scheduler. +`volcano config deploy` uploads a declarative manifest +(`volcano/volcano-config.yaml` or `./volcano-config.yaml`) to the server, which +validates and reconciles the full project configuration — project settings, +database assertions, variables, buckets and policies, realtime, the complete +auth configuration (providers, email, templates, managed pages), function +visibility and schedulers, and frontend custom domains. The same manifest +applies to local mode and cloud. `volcano config pull` downloads the current +configuration as a canonical manifest rendered by the server. ```yaml version: 1 +variables: + - name: STRIPE_SECRET_KEY + value: ${STRIPE_SECRET_KEY} # interpolated from the CLI environment +realtime: + enabled: true functions: - name: hello public: false @@ -101,22 +107,32 @@ functions: cron: "*/5 * * * *" enabled: true payload: { job: refresh } - regions: [us-east-1] # omit to let the server pick one deployed region ``` -Reconciliation follows one rule that mirrors the server: **fields you declare -are enforced; fields you omit are left server-managed.** An omitted `enabled`, -`payload`, or `regions` keeps whatever the scheduler already has on the server -(on first create the server applies its defaults — `enabled: true`, an empty -payload, and one chosen region). In particular, `config deploy` will not -re-enable a scheduler you disabled out of band unless the manifest sets -`enabled: true`. `cron` is always required and enforced. - -Reconciliation is also **non-destructive**: it creates and updates the -schedulers a function declares (matched by `name`, preserving the scheduler ID) -but never deletes or disables one. A scheduler the manifest no longer declares -is left running; to remove or disable one, use the imperative commands -(`volcano functions schedulers delete` / `disable`). +Key semantics (see the hosted manifest reference for the full schema): + +- **Declared config sections are the source of truth.** Variables, bucket + policies, OAuth providers, email templates, and function schedulers are + fully synced when declared: entries absent from the manifest are deleted. + Omitted sections and fields keep their server values. +- **Functions, frontends, databases, and buckets are never created or deleted + through the manifest** — only their configuration is updated. A manifest + entry for a resource that does not exist is skipped with a warning; a + deployed resource missing from a declared section is reported too. +- `${ENV_VAR}` references are interpolated before upload; a reference to an + unset variable is an error, and `$$` produces a literal `$`. +- `volcano config deploy --dry-run` prints the projected actions without + changing anything. Validation failures (including plan-gate violations) + exit non-zero with the server's error list, and nothing is applied. +- Write-only secrets (SMTP password, OAuth client secrets, TLS material) are + omitted from `config pull` exports; keep them in your environment and set + them via `${ENV_VAR}` interpolation. + +Behavior changes from older CLI releases: buckets are no longer auto-created, +an omitted `policies` key now leaves a bucket's policies untouched (previously +it deleted them all), schedulers are now deleted by omission within a declared +`schedulers` list, and the scheduler `regions` field is no longer supported +(placement is managed by the server). ## Contributing diff --git a/internal/api/frontends.go b/internal/api/frontends.go index 66e8bef..a8986ed 100644 --- a/internal/api/frontends.go +++ b/internal/api/frontends.go @@ -171,7 +171,7 @@ func (c *Client) StreamFrontendDeploymentLogs(ctx context.Context, projectID, fr func (c *Client) CreateFrontendCustomDomain(ctx context.Context, projectID, frontendID uuid.UUID, input FrontendCustomDomainInput) (*apiclient.FrontendCustomDomainResponse, error) { body := apiclient.CreateFrontendCustomDomainJSONRequestBody{ Domain: strings.TrimSpace(input.Domain), - Tls: apiclient.FrontendCustomDomainTLSConfig{ + Tls: apicommon.FrontendCustomDomainTLSConfig{ CertificatePem: strings.TrimSpace(input.CertificatePEM), Mode: apicommon.FrontendCustomDomainTLSConfigModeByoc, PrivateKeyPem: strings.TrimSpace(input.PrivateKeyPEM), diff --git a/internal/api/functions.go b/internal/api/functions.go index ca4075e..e0a4668 100644 --- a/internal/api/functions.go +++ b/internal/api/functions.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/Kong/volcano-cli/internal/apiclient" + apicommon "github.com/Kong/volcano-cli/internal/apiclient/common" "github.com/Kong/volcano-cli/internal/archive" ) @@ -130,7 +131,7 @@ func (c *Client) InvokeFunction(ctx context.Context, functionID uuid.UUID, input } // ListFunctionRuntimes returns the function runtime catalog. -func (c *Client) ListFunctionRuntimes(ctx context.Context) ([]apiclient.FunctionRuntimeOption, error) { +func (c *Client) ListFunctionRuntimes(ctx context.Context) ([]apicommon.FunctionRuntimeOption, error) { resp, err := c.client.ListFunctionRuntimesWithResponse(ctx) if err != nil { return nil, err @@ -249,7 +250,7 @@ func (c *Client) CreateFunctionScheduler(ctx context.Context, projectID, functio body := apiclient.CreateFunctionSchedulerJSONRequestBody{ Name: input.Name, Enabled: input.Enabled, - Schedule: apiclient.ScheduleRequest{ + Schedule: apicommon.ScheduleRequest{ CronExpression: input.CronExpression, }, } @@ -281,7 +282,7 @@ func (c *Client) UpdateFunctionScheduler(ctx context.Context, projectID, functio body.Name = &name } if input.CronExpression != "" { - body.Schedule = &apiclient.ScheduleRequest{CronExpression: input.CronExpression} + body.Schedule = &apicommon.ScheduleRequest{CronExpression: input.CronExpression} } if input.Payload != nil { payload := input.Payload diff --git a/internal/api/log_stream.go b/internal/api/log_stream.go index ba85d89..bc5772a 100644 --- a/internal/api/log_stream.go +++ b/internal/api/log_stream.go @@ -13,7 +13,7 @@ import ( "github.com/google/uuid" - "github.com/Kong/volcano-cli/internal/apiclient" + apicommon "github.com/Kong/volcano-cli/internal/apiclient/common" ) const logStreamScannerMaxTokenSize = 1024 * 1024 @@ -29,7 +29,7 @@ type ProjectLogStreamEvent struct { ID string Type string Data string - Log *apiclient.LogSearchEvent + Log *apicommon.LogSearchEvent Warning string } @@ -108,7 +108,7 @@ func (s *ProjectLogStream) nextRaw() (*ProjectLogStreamEvent, error) { func parseProjectLogStreamEvent(event *ProjectLogStreamEvent) (*ProjectLogStreamEvent, error) { switch event.Type { case "log": - var logEvent apiclient.LogSearchEvent + var logEvent apicommon.LogSearchEvent if err := json.Unmarshal([]byte(event.Data), &logEvent); err != nil { return nil, fmt.Errorf("failed to decode log stream event: %w", err) } diff --git a/internal/api/project_config.go b/internal/api/project_config.go new file mode 100644 index 0000000..2bc9216 --- /dev/null +++ b/internal/api/project_config.go @@ -0,0 +1,61 @@ +package api + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + + "github.com/google/uuid" + + "github.com/Kong/volcano-cli/internal/apiclient" + apicommon "github.com/Kong/volcano-cli/internal/apiclient/common" +) + +// ProjectConfigValidationError is returned when the server rejects a config +// manifest with 422. Nothing was applied; Errors carries the full list. +type ProjectConfigValidationError struct { + Errors []apicommon.ProjectConfigValidationError +} + +func (e *ProjectConfigValidationError) Error() string { + return fmt.Sprintf("configuration validation failed with %d error(s); nothing was applied", len(e.Errors)) +} + +// ApplyProjectConfig uploads a full configuration manifest (pre-encoded JSON) +// and returns the server's per-resource apply report. +func (c *Client) ApplyProjectConfig(ctx context.Context, projectID uuid.UUID, manifestJSON []byte, dryRun bool) (*apicommon.ProjectConfigApplyResult, error) { + params := &apiclient.ApplyProjectConfigParams{} + if dryRun { + params.DryRun = &dryRun + } + resp, err := c.client.ApplyProjectConfigWithBodyWithResponse(ctx, projectID, params, "application/json", bytes.NewReader(manifestJSON)) + if err != nil { + return nil, err + } + if resp.JSON422 != nil { + return nil, &ProjectConfigValidationError{Errors: resp.JSON422.Errors} + } + return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON400, resp.JSON401, resp.JSON404, resp.JSON409) +} + +// GetProjectConfigYAML downloads the project configuration as the canonical +// YAML manifest rendered by the server, returned verbatim. +func (c *Client) GetProjectConfigYAML(ctx context.Context, projectID uuid.UUID) ([]byte, error) { + format := apiclient.Yaml + resp, err := c.client.GetProjectConfig(ctx, projectID, &apiclient.GetProjectConfigParams{Format: &format}) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, apiError(resp.StatusCode, body) + } + return body, nil +} diff --git a/internal/apiclient/client.gen.go b/internal/apiclient/client.gen.go index 837ae08..d5d49d5 100644 --- a/internal/apiclient/client.gen.go +++ b/internal/apiclient/client.gen.go @@ -103,6 +103,7 @@ func (e AuthOAuthAuthorizeParamsProvider) Valid() bool { // Defines values for CallOAuthProviderAPIParamsProvider. const ( + CallOAuthProviderAPIParamsProviderApple CallOAuthProviderAPIParamsProvider = "apple" CallOAuthProviderAPIParamsProviderGithub CallOAuthProviderAPIParamsProvider = "github" CallOAuthProviderAPIParamsProviderGoogle CallOAuthProviderAPIParamsProvider = "google" CallOAuthProviderAPIParamsProviderMicrosoft CallOAuthProviderAPIParamsProvider = "microsoft" @@ -111,6 +112,8 @@ const ( // Valid indicates whether the value is a known member of the CallOAuthProviderAPIParamsProvider enum. func (e CallOAuthProviderAPIParamsProvider) Valid() bool { switch e { + case CallOAuthProviderAPIParamsProviderApple: + return true case CallOAuthProviderAPIParamsProviderGithub: return true case CallOAuthProviderAPIParamsProviderGoogle: @@ -124,26 +127,17 @@ func (e CallOAuthProviderAPIParamsProvider) Valid() bool { // Defines values for CallOAuthProviderAPIJSONBodyMethod. const ( - DELETE CallOAuthProviderAPIJSONBodyMethod = "DELETE" - GET CallOAuthProviderAPIJSONBodyMethod = "GET" - PATCH CallOAuthProviderAPIJSONBodyMethod = "PATCH" - POST CallOAuthProviderAPIJSONBodyMethod = "POST" - PUT CallOAuthProviderAPIJSONBodyMethod = "PUT" + GET CallOAuthProviderAPIJSONBodyMethod = "GET" + POST CallOAuthProviderAPIJSONBodyMethod = "POST" ) // Valid indicates whether the value is a known member of the CallOAuthProviderAPIJSONBodyMethod enum. func (e CallOAuthProviderAPIJSONBodyMethod) Valid() bool { switch e { - case DELETE: - return true case GET: return true - case PATCH: - return true case POST: return true - case PUT: - return true default: return false } @@ -500,6 +494,24 @@ func (e RenderDefaultManagedAuthPageParamsAction) Valid() bool { } } +// Defines values for GetProjectConfigParamsFormat. +const ( + Json GetProjectConfigParamsFormat = "json" + Yaml GetProjectConfigParamsFormat = "yaml" +) + +// Valid indicates whether the value is a known member of the GetProjectConfigParamsFormat enum. +func (e GetProjectConfigParamsFormat) Valid() bool { + switch e { + case Json: + return true + case Yaml: + return true + default: + return false + } +} + // Defines values for GetDatabaseStatsParamsGranularity. const ( Daily GetDatabaseStatsParamsGranularity = "daily" @@ -752,9 +764,6 @@ type AnonKey = externalRef0.AnonKey // AuthConfig defines model for AuthConfig. type AuthConfig = externalRef0.AuthConfig -// AuthHostedPage defines model for AuthHostedPage. -type AuthHostedPage = externalRef0.AuthHostedPage - // AuthHostedPageResponse defines model for AuthHostedPageResponse. type AuthHostedPageResponse = externalRef0.AuthHostedPageResponse @@ -770,9 +779,6 @@ type AuthUser = externalRef0.AuthUser // BanUserResponse Response when banning a user type BanUserResponse = externalRef0.BanUserResponse -// BatchFunctionDeployFailure defines model for BatchFunctionDeployFailure. -type BatchFunctionDeployFailure = externalRef0.BatchFunctionDeployFailure - // BatchFunctionDeployResponse defines model for BatchFunctionDeployResponse. type BatchFunctionDeployResponse = externalRef0.BatchFunctionDeployResponse @@ -819,6 +825,9 @@ type CreateVariableRequest = externalRef0.CreateVariableRequest // Database PostgreSQL database with automatic scalability and security features. type Database = externalRef0.Database +// DatabaseQueryPerformanceResponse defines model for DatabaseQueryPerformanceResponse. +type DatabaseQueryPerformanceResponse = externalRef0.DatabaseQueryPerformanceResponse + // DatabaseStats defines model for DatabaseStats. type DatabaseStats = externalRef0.DatabaseStats @@ -837,33 +846,12 @@ type Frontend = externalRef0.Frontend // FrontendCustomDomainResponse defines model for FrontendCustomDomainResponse. type FrontendCustomDomainResponse = externalRef0.FrontendCustomDomainResponse -// FrontendCustomDomainTLSConfig defines model for FrontendCustomDomainTLSConfig. -type FrontendCustomDomainTLSConfig = externalRef0.FrontendCustomDomainTLSConfig - -// FrontendDeployment defines model for FrontendDeployment. -type FrontendDeployment = externalRef0.FrontendDeployment - -// FrontendDomainRoutingRecord defines model for FrontendDomainRoutingRecord. -type FrontendDomainRoutingRecord = externalRef0.FrontendDomainRoutingRecord - -// FrontendDomainVerificationRecord defines model for FrontendDomainVerificationRecord. -type FrontendDomainVerificationRecord = externalRef0.FrontendDomainVerificationRecord - -// FrontendUsageDailyEntry One day of request and error counts for a single frontend. -type FrontendUsageDailyEntry = externalRef0.FrontendUsageDailyEntry - -// FrontendUsageData Monthly frontend request totals grouped by frontend. -type FrontendUsageData = externalRef0.FrontendUsageData - // FrontendUsageHistoryResponse Zero-filled daily series of request + error counts for a single frontend, oldest first. type FrontendUsageHistoryResponse = externalRef0.FrontendUsageHistoryResponse // Function defines model for Function. type Function = externalRef0.Function -// FunctionDeployment defines model for FunctionDeployment. -type FunctionDeployment = externalRef0.FunctionDeployment - // FunctionInvocationRequest defines model for FunctionInvocationRequest. type FunctionInvocationRequest = externalRef0.FunctionInvocationRequest @@ -873,9 +861,6 @@ type FunctionInvocationResponse = externalRef0.FunctionInvocationResponse // FunctionRegion defines model for FunctionRegion. type FunctionRegion = externalRef0.FunctionRegion -// FunctionRuntimeOption defines model for FunctionRuntimeOption. -type FunctionRuntimeOption = externalRef0.FunctionRuntimeOption - // FunctionRuntimesResponse defines model for FunctionRuntimesResponse. type FunctionRuntimesResponse = externalRef0.FunctionRuntimesResponse @@ -900,24 +885,12 @@ type HostedLoginOptionsResponse = externalRef0.HostedLoginOptionsResponse // HostedRenderablePageType defines model for HostedRenderablePageType. type HostedRenderablePageType = externalRef0.HostedRenderablePageType -// LiveLogLevel Normalized function runtime log level. -type LiveLogLevel = externalRef0.LiveLogLevel - -// LogActivityBucket Log-event counts for one activity time bucket. -type LogActivityBucket = externalRef0.LogActivityBucket - // LogActivityRequest Activity request for bucketed log counts. type LogActivityRequest = externalRef0.LogActivityRequest // LogActivityResponse Bucketed runtime log activity. type LogActivityResponse = externalRef0.LogActivityResponse -// LogEvent Normalized historical log event returned by paginated log APIs. -type LogEvent = externalRef0.LogEvent - -// LogSearchEvent defines model for LogSearchEvent. -type LogSearchEvent = externalRef0.LogSearchEvent - // LogSearchRequest Search request for project logs. type LogSearchRequest = externalRef0.LogSearchRequest @@ -927,9 +900,6 @@ type LogSearchResponse = externalRef0.LogSearchResponse // LogStreamRequest Stream request for live project logs. Text search, pagination cursors, and fixed end times are not supported. type LogStreamRequest = externalRef0.LogStreamRequest -// MetricUsageData Usage data for one metric across totals, daily, and hourly windows. -type MetricUsageData = externalRef0.MetricUsageData - // OAuthConfig defines model for OAuthConfig. type OAuthConfig = externalRef0.OAuthConfig @@ -975,11 +945,26 @@ type PlatformExchangeResponse = externalRef0.PlatformExchangeResponse // Project defines model for Project. type Project = externalRef0.Project -// ProjectFrontendCustomDomain defines model for ProjectFrontendCustomDomain. -type ProjectFrontendCustomDomain = externalRef0.ProjectFrontendCustomDomain +// ProjectConfig Declarative project configuration manifest (the JSON form of +// volcano-config.yaml). Omitted sections are left untouched. Within +// declared entries, omitted optional fields keep their current server +// values (patch semantics). Declared collection keys are fully synced to +// the manifest: `variables`, `buckets[].policies`, `auth.providers.oauth`, +// `auth.email.templates`, and `functions[].schedulers` are reconciled to +// exactly match, deleting resources absent from the manifest. Functions, +// frontends, databases, and buckets are never created or deleted through +// this manifest; entries referencing resources that do not exist are +// skipped and reported. +type ProjectConfig = externalRef0.ProjectConfig + +// ProjectConfigApplyResult Per-resource report for a project config apply (or dry run). +type ProjectConfigApplyResult = externalRef0.ProjectConfigApplyResult -// ProjectFrontendDeployment defines model for ProjectFrontendDeployment. -type ProjectFrontendDeployment = externalRef0.ProjectFrontendDeployment +// ProjectConfigValidationErrorResponse Returned when manifest validation fails. Nothing was applied. +type ProjectConfigValidationErrorResponse = externalRef0.ProjectConfigValidationErrorResponse + +// ProjectHealthResponse defines model for ProjectHealthResponse. +type ProjectHealthResponse = externalRef0.ProjectHealthResponse // ProjectUsageResponse Aggregated usage metrics for a project. type ProjectUsageResponse = externalRef0.ProjectUsageResponse @@ -988,18 +973,12 @@ type ProjectUsageResponse = externalRef0.ProjectUsageResponse // Note: Message size and channels per connection are plan-based (not configurable). type RealtimeConfig = externalRef0.RealtimeConfig -// RealtimePlanLimits Plan-based limits for realtime features -type RealtimePlanLimits = externalRef0.RealtimePlanLimits - // RealtimeStats Realtime usage statistics for a project type RealtimeStats = externalRef0.RealtimeStats // ResolveFunctionResponse defines model for ResolveFunctionResponse. type ResolveFunctionResponse = externalRef0.ResolveFunctionResponse -// ScheduleRequest defines model for ScheduleRequest. -type ScheduleRequest = externalRef0.ScheduleRequest - // ServiceKey Service role key for admin operations. // **WARNING:** Bypasses all RLS - backend use only! type ServiceKey = externalRef0.ServiceKey @@ -1088,9 +1067,6 @@ type UploadSessionPart = externalRef0.UploadSessionPart // UploadSessionStatusResponse Status of an upload session type UploadSessionStatusResponse = externalRef0.UploadSessionStatusResponse -// UsageDataPoint A single timestamped usage value. -type UsageDataPoint = externalRef0.UsageDataPoint - // Variable defines model for Variable. type Variable = externalRef0.Variable @@ -1100,9 +1076,6 @@ type BucketName = string // DatabaseName defines model for DatabaseName. type DatabaseName = string -// DeploymentId defines model for DeploymentId. -type DeploymentId = openapi_types.UUID - // FrontendId defines model for FrontendId. type FrontendId = openapi_types.UUID @@ -1204,17 +1177,16 @@ type AuthOAuthAuthorizeParamsProvider string // CallOAuthProviderAPIJSONBody defines parameters for CallOAuthProviderAPI. type CallOAuthProviderAPIJSONBody struct { - // Body Request body for POST/PUT/PATCH requests + // Body Request body for POST requests Body *map[string]interface{} `json:"body,omitempty"` - // Headers Additional headers to include - Headers *map[string]string `json:"headers,omitempty"` + // Endpoint Relative path on the provider's API, beginning with `/`. It is + // joined with the provider's fixed base URL; it must not contain a + // scheme, host, userinfo, or a leading `//`. + Endpoint string `json:"endpoint"` // Method HTTP method to use Method *CallOAuthProviderAPIJSONBodyMethod `json:"method,omitempty"` - - // Url The API endpoint URL to call - Url string `json:"url"` } // CallOAuthProviderAPIParamsProvider defines parameters for CallOAuthProviderAPI. @@ -1435,12 +1407,14 @@ type QueryDatabaseSelectJSONBody_Filters_Value struct { // QueryDatabaseUpdateJSONBody defines parameters for QueryDatabaseUpdate. type QueryDatabaseUpdateJSONBody struct { - // Filters WHERE conditions for which rows to update - Filters *[]struct { + // Filters WHERE conditions for which rows to update. At least one filter + // is required; a filterless update is rejected to avoid rewriting + // every row. + Filters []struct { Column string `json:"column"` Operator QueryDatabaseUpdateJSONBodyFiltersOperator `json:"operator"` Value QueryDatabaseUpdateJSONBody_Filters_Value `json:"value"` - } `json:"filters,omitempty"` + } `json:"filters"` // Table Table name Table string `json:"table"` @@ -1569,6 +1543,21 @@ type ListUserSessionsParams struct { Limit *int `form:"limit,omitempty" json:"limit,omitempty"` } +// GetProjectConfigParams defines parameters for GetProjectConfig. +type GetProjectConfigParams struct { + // Format Response format override. Takes precedence over the Accept header. + Format *GetProjectConfigParamsFormat `form:"format,omitempty" json:"format,omitempty"` +} + +// GetProjectConfigParamsFormat defines parameters for GetProjectConfig. +type GetProjectConfigParamsFormat string + +// ApplyProjectConfigParams defines parameters for ApplyProjectConfig. +type ApplyProjectConfigParams struct { + // DryRun Validate and report projected actions without applying changes. + DryRun *bool `form:"dry_run,omitempty" json:"dry_run,omitempty"` +} + // ListDatabasesParams defines parameters for ListDatabases. type ListDatabasesParams struct { // Page Page number (1-indexed) @@ -1578,6 +1567,12 @@ type ListDatabasesParams struct { Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` } +// GetProjectDatabaseQueriesParams defines parameters for GetProjectDatabaseQueries. +type GetProjectDatabaseQueriesParams struct { + // Limit Maximum number of queries to return. + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` +} + // GetDatabaseStatsParams defines parameters for GetDatabaseStats. type GetDatabaseStatsParams struct { // From Start time in RFC3339 format (e.g., "2024-01-01T00:00:00Z"). Defaults to 24 hours ago. @@ -1780,10 +1775,13 @@ type CreateServiceKeyJSONBody struct { // 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 Optional least-privilege scope for the key. When omitted, empty, or + // containing only blank strings, the key is granted full access (["*"]) + // for backward compatibility. Provide an explicit list (e.g. + // ["functions.invoke", "storage.download"]) to restrict the key; "*" + // grants everything. Scope enforcement currently applies to function + // invocation and storage object operations; other service-key routes + // are not yet gated by scope. Permissions *[]string `json:"permissions,omitempty"` } @@ -1965,6 +1963,9 @@ type ConfigureAuthMethodsJSONRequestBody ConfigureAuthMethodsJSONBody // BanAuthUserJSONRequestBody defines body for BanAuthUser for application/json ContentType. type BanAuthUserJSONRequestBody BanAuthUserJSONBody +// ApplyProjectConfigJSONRequestBody defines body for ApplyProjectConfig for application/json ContentType. +type ApplyProjectConfigJSONRequestBody = ProjectConfig + // CreateDatabaseJSONRequestBody defines body for CreateDatabase for application/json ContentType. type CreateDatabaseJSONRequestBody = CreateDatabaseRequest @@ -2848,6 +2849,14 @@ type ClientInterface interface { // UnbanAuthUser request UnbanAuthUser(ctx context.Context, id ProjectId, userId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetProjectConfig request + GetProjectConfig(ctx context.Context, id ProjectId, params *GetProjectConfigParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ApplyProjectConfigWithBody request with any body + ApplyProjectConfigWithBody(ctx context.Context, id ProjectId, params *ApplyProjectConfigParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + ApplyProjectConfig(ctx context.Context, id ProjectId, params *ApplyProjectConfigParams, body ApplyProjectConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListDatabases request ListDatabases(ctx context.Context, id ProjectId, params *ListDatabasesParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2862,6 +2871,9 @@ type ClientInterface interface { // GetDatabase request GetDatabase(ctx context.Context, id ProjectId, databaseName DatabaseName, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetProjectDatabaseQueries request + GetProjectDatabaseQueries(ctx context.Context, id ProjectId, databaseName DatabaseName, params *GetProjectDatabaseQueriesParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // ResetDatabasePassword request ResetDatabasePassword(ctx context.Context, id ProjectId, databaseName DatabaseName, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2972,6 +2984,9 @@ type ClientInterface interface { UpdateFunctionScheduler(ctx context.Context, id ProjectId, functionId FunctionId, schedulerId SchedulerId, body UpdateFunctionSchedulerJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetProjectHealth request + GetProjectHealth(ctx context.Context, id ProjectId, reqEditors ...RequestEditorFn) (*http.Response, error) + // DeleteProjectLogo request DeleteProjectLogo(ctx context.Context, id ProjectId, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -4403,6 +4418,42 @@ func (c *Client) UnbanAuthUser(ctx context.Context, id ProjectId, userId openapi return c.Client.Do(req) } +func (c *Client) GetProjectConfig(ctx context.Context, id ProjectId, params *GetProjectConfigParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetProjectConfigRequest(c.Server, id, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ApplyProjectConfigWithBody(ctx context.Context, id ProjectId, params *ApplyProjectConfigParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewApplyProjectConfigRequestWithBody(c.Server, id, params, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ApplyProjectConfig(ctx context.Context, id ProjectId, params *ApplyProjectConfigParams, body ApplyProjectConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewApplyProjectConfigRequest(c.Server, id, params, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) ListDatabases(ctx context.Context, id ProjectId, params *ListDatabasesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewListDatabasesRequest(c.Server, id, params) if err != nil { @@ -4463,6 +4514,18 @@ func (c *Client) GetDatabase(ctx context.Context, id ProjectId, databaseName Dat return c.Client.Do(req) } +func (c *Client) GetProjectDatabaseQueries(ctx context.Context, id ProjectId, databaseName DatabaseName, params *GetProjectDatabaseQueriesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetProjectDatabaseQueriesRequest(c.Server, id, databaseName, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) ResetDatabasePassword(ctx context.Context, id ProjectId, databaseName DatabaseName, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewResetDatabasePasswordRequest(c.Server, id, databaseName) if err != nil { @@ -4931,6 +4994,18 @@ func (c *Client) UpdateFunctionScheduler(ctx context.Context, id ProjectId, func return c.Client.Do(req) } +func (c *Client) GetProjectHealth(ctx context.Context, id ProjectId, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetProjectHealthRequest(c.Server, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) DeleteProjectLogo(ctx context.Context, id ProjectId, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewDeleteProjectLogoRequest(c.Server, id) if err != nil { @@ -8817,6 +8892,141 @@ func NewUnbanAuthUserRequest(server string, id ProjectId, userId openapi_types.U return req, nil } +// NewGetProjectConfigRequest generates requests for GetProjectConfig +func NewGetProjectConfigRequest(server string, id ProjectId, params *GetProjectConfigParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/projects/%s/config", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Format != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "format", *params.Format, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewApplyProjectConfigRequest calls the generic ApplyProjectConfig builder with application/json body +func NewApplyProjectConfigRequest(server string, id ProjectId, params *ApplyProjectConfigParams, body ApplyProjectConfigJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewApplyProjectConfigRequestWithBody(server, id, params, "application/json", bodyReader) +} + +// NewApplyProjectConfigRequestWithBody generates requests for ApplyProjectConfig with any type of body +func NewApplyProjectConfigRequestWithBody(server string, id ProjectId, params *ApplyProjectConfigParams, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/projects/%s/config", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.DryRun != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "dry_run", *params.DryRun, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewListDatabasesRequest generates requests for ListDatabases func NewListDatabasesRequest(server string, id ProjectId, params *ListDatabasesParams) (*http.Request, error) { var err error @@ -9019,8 +9229,8 @@ func NewGetDatabaseRequest(server string, id ProjectId, databaseName DatabaseNam return req, nil } -// NewResetDatabasePasswordRequest generates requests for ResetDatabasePassword -func NewResetDatabasePasswordRequest(server string, id ProjectId, databaseName DatabaseName) (*http.Request, error) { +// NewGetProjectDatabaseQueriesRequest generates requests for GetProjectDatabaseQueries +func NewGetProjectDatabaseQueriesRequest(server string, id ProjectId, databaseName DatabaseName, params *GetProjectDatabaseQueriesParams) (*http.Request, error) { var err error var pathParam0 string @@ -9042,7 +9252,7 @@ func NewResetDatabasePasswordRequest(server string, id ProjectId, databaseName D return nil, err } - operationPath := fmt.Sprintf("/projects/%s/databases/%s/reset-password", pathParam0, pathParam1) + operationPath := fmt.Sprintf("/projects/%s/databases/%s/queries", pathParam0, pathParam1) if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -9052,17 +9262,85 @@ func NewResetDatabasePasswordRequest(server string, id ProjectId, databaseName D return nil, err } - req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) - if err != nil { - return nil, err - } + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string - return req, nil -} + if params.Limit != nil { -// NewGetDatabaseStatsRequest generates requests for GetDatabaseStats -func NewGetDatabaseStatsRequest(server string, id ProjectId, databaseName DatabaseName, params *GetDatabaseStatsParams) (*http.Request, error) { - var err error + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "limit", *params.Limit, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewResetDatabasePasswordRequest generates requests for ResetDatabasePassword +func NewResetDatabasePasswordRequest(server string, id ProjectId, databaseName DatabaseName) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "databaseName", databaseName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/projects/%s/databases/%s/reset-password", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetDatabaseStatsRequest generates requests for GetDatabaseStats +func NewGetDatabaseStatsRequest(server string, id ProjectId, databaseName DatabaseName, params *GetDatabaseStatsParams) (*http.Request, error) { + var err error var pathParam0 string @@ -10698,6 +10976,40 @@ func NewUpdateFunctionSchedulerRequestWithBody(server string, id ProjectId, func return req, nil } +// NewGetProjectHealthRequest generates requests for GetProjectHealth +func NewGetProjectHealthRequest(server string, id ProjectId) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/projects/%s/health", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewDeleteProjectLogoRequest generates requests for DeleteProjectLogo func NewDeleteProjectLogoRequest(server string, id ProjectId) (*http.Request, error) { var err error @@ -13392,6 +13704,14 @@ type ClientWithResponsesInterface interface { // UnbanAuthUserWithResponse request UnbanAuthUserWithResponse(ctx context.Context, id ProjectId, userId openapi_types.UUID, reqEditors ...RequestEditorFn) (*UnbanAuthUserClientResponse, error) + // GetProjectConfigWithResponse request + GetProjectConfigWithResponse(ctx context.Context, id ProjectId, params *GetProjectConfigParams, reqEditors ...RequestEditorFn) (*GetProjectConfigClientResponse, error) + + // ApplyProjectConfigWithBodyWithResponse request with any body + ApplyProjectConfigWithBodyWithResponse(ctx context.Context, id ProjectId, params *ApplyProjectConfigParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ApplyProjectConfigClientResponse, error) + + ApplyProjectConfigWithResponse(ctx context.Context, id ProjectId, params *ApplyProjectConfigParams, body ApplyProjectConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*ApplyProjectConfigClientResponse, error) + // ListDatabasesWithResponse request ListDatabasesWithResponse(ctx context.Context, id ProjectId, params *ListDatabasesParams, reqEditors ...RequestEditorFn) (*ListDatabasesClientResponse, error) @@ -13406,6 +13726,9 @@ type ClientWithResponsesInterface interface { // GetDatabaseWithResponse request GetDatabaseWithResponse(ctx context.Context, id ProjectId, databaseName DatabaseName, reqEditors ...RequestEditorFn) (*GetDatabaseClientResponse, error) + // GetProjectDatabaseQueriesWithResponse request + GetProjectDatabaseQueriesWithResponse(ctx context.Context, id ProjectId, databaseName DatabaseName, params *GetProjectDatabaseQueriesParams, reqEditors ...RequestEditorFn) (*GetProjectDatabaseQueriesClientResponse, error) + // ResetDatabasePasswordWithResponse request ResetDatabasePasswordWithResponse(ctx context.Context, id ProjectId, databaseName DatabaseName, reqEditors ...RequestEditorFn) (*ResetDatabasePasswordClientResponse, error) @@ -13516,6 +13839,9 @@ type ClientWithResponsesInterface interface { UpdateFunctionSchedulerWithResponse(ctx context.Context, id ProjectId, functionId FunctionId, schedulerId SchedulerId, body UpdateFunctionSchedulerJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateFunctionSchedulerClientResponse, error) + // GetProjectHealthWithResponse request + GetProjectHealthWithResponse(ctx context.Context, id ProjectId, reqEditors ...RequestEditorFn) (*GetProjectHealthClientResponse, error) + // DeleteProjectLogoWithResponse request DeleteProjectLogoWithResponse(ctx context.Context, id ProjectId, reqEditors ...RequestEditorFn) (*DeleteProjectLogoClientResponse, error) @@ -16077,6 +16403,73 @@ func (r UnbanAuthUserClientResponse) ContentType() string { return "" } +type GetProjectConfigClientResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ProjectConfig + JSON401 *Error + JSON404 *Error +} + +// Status returns HTTPResponse.Status +func (r GetProjectConfigClientResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetProjectConfigClientResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetProjectConfigClientResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ApplyProjectConfigClientResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ProjectConfigApplyResult + JSON400 *Error + JSON401 *Error + JSON404 *Error + JSON409 *Error + JSON422 *ProjectConfigValidationErrorResponse +} + +// Status returns HTTPResponse.Status +func (r ApplyProjectConfigClientResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ApplyProjectConfigClientResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ApplyProjectConfigClientResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type ListDatabasesClientResponse struct { Body []byte HTTPResponse *http.Response @@ -16141,6 +16534,10 @@ func (r CreateDatabaseClientResponse) ContentType() string { type DeleteDatabaseClientResponse struct { Body []byte HTTPResponse *http.Response + JSON202 *struct { + Message *string `json:"message,omitempty"` + Status *string `json:"status,omitempty"` + } } // Status returns HTTPResponse.Status @@ -16197,6 +16594,41 @@ func (r GetDatabaseClientResponse) ContentType() string { return "" } +type GetProjectDatabaseQueriesClientResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DatabaseQueryPerformanceResponse + JSON400 *Error + JSON401 *Error + JSON403 *Error + JSON404 *Error + JSON500 *Error +} + +// Status returns HTTPResponse.Status +func (r GetProjectDatabaseQueriesClientResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetProjectDatabaseQueriesClientResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetProjectDatabaseQueriesClientResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type ResetDatabasePasswordClientResponse struct { Body []byte HTTPResponse *http.Response @@ -16208,7 +16640,7 @@ type ResetDatabasePasswordClientResponse struct { // NewPassword New Volcano-managed client password. Always starts with `vpg_`. NewPassword *string `json:"new_password,omitempty"` - // RoleName Volcano-managed client database role name + // RoleName Volcano-managed per-database client login (also the pgproxy routing username) RoleName *string `json:"role_name,omitempty"` } } @@ -17251,6 +17683,40 @@ func (r UpdateFunctionSchedulerClientResponse) ContentType() string { return "" } +type GetProjectHealthClientResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ProjectHealthResponse + JSON401 *Error + JSON403 *Error + JSON404 *Error + JSON500 *Error +} + +// Status returns HTTPResponse.Status +func (r GetProjectHealthClientResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetProjectHealthClientResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetProjectHealthClientResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type DeleteProjectLogoClientResponse struct { Body []byte HTTPResponse *http.Response @@ -19592,6 +20058,32 @@ func (c *ClientWithResponses) UnbanAuthUserWithResponse(ctx context.Context, id return ParseUnbanAuthUserClientResponse(rsp) } +// GetProjectConfigWithResponse request returning *GetProjectConfigClientResponse +func (c *ClientWithResponses) GetProjectConfigWithResponse(ctx context.Context, id ProjectId, params *GetProjectConfigParams, reqEditors ...RequestEditorFn) (*GetProjectConfigClientResponse, error) { + rsp, err := c.GetProjectConfig(ctx, id, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetProjectConfigClientResponse(rsp) +} + +// ApplyProjectConfigWithBodyWithResponse request with arbitrary body returning *ApplyProjectConfigClientResponse +func (c *ClientWithResponses) ApplyProjectConfigWithBodyWithResponse(ctx context.Context, id ProjectId, params *ApplyProjectConfigParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ApplyProjectConfigClientResponse, error) { + rsp, err := c.ApplyProjectConfigWithBody(ctx, id, params, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseApplyProjectConfigClientResponse(rsp) +} + +func (c *ClientWithResponses) ApplyProjectConfigWithResponse(ctx context.Context, id ProjectId, params *ApplyProjectConfigParams, body ApplyProjectConfigJSONRequestBody, reqEditors ...RequestEditorFn) (*ApplyProjectConfigClientResponse, error) { + rsp, err := c.ApplyProjectConfig(ctx, id, params, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseApplyProjectConfigClientResponse(rsp) +} + // ListDatabasesWithResponse request returning *ListDatabasesClientResponse func (c *ClientWithResponses) ListDatabasesWithResponse(ctx context.Context, id ProjectId, params *ListDatabasesParams, reqEditors ...RequestEditorFn) (*ListDatabasesClientResponse, error) { rsp, err := c.ListDatabases(ctx, id, params, reqEditors...) @@ -19636,6 +20128,15 @@ func (c *ClientWithResponses) GetDatabaseWithResponse(ctx context.Context, id Pr return ParseGetDatabaseClientResponse(rsp) } +// GetProjectDatabaseQueriesWithResponse request returning *GetProjectDatabaseQueriesClientResponse +func (c *ClientWithResponses) GetProjectDatabaseQueriesWithResponse(ctx context.Context, id ProjectId, databaseName DatabaseName, params *GetProjectDatabaseQueriesParams, reqEditors ...RequestEditorFn) (*GetProjectDatabaseQueriesClientResponse, error) { + rsp, err := c.GetProjectDatabaseQueries(ctx, id, databaseName, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetProjectDatabaseQueriesClientResponse(rsp) +} + // ResetDatabasePasswordWithResponse request returning *ResetDatabasePasswordClientResponse func (c *ClientWithResponses) ResetDatabasePasswordWithResponse(ctx context.Context, id ProjectId, databaseName DatabaseName, reqEditors ...RequestEditorFn) (*ResetDatabasePasswordClientResponse, error) { rsp, err := c.ResetDatabasePassword(ctx, id, databaseName, reqEditors...) @@ -19980,6 +20481,15 @@ func (c *ClientWithResponses) UpdateFunctionSchedulerWithResponse(ctx context.Co return ParseUpdateFunctionSchedulerClientResponse(rsp) } +// GetProjectHealthWithResponse request returning *GetProjectHealthClientResponse +func (c *ClientWithResponses) GetProjectHealthWithResponse(ctx context.Context, id ProjectId, reqEditors ...RequestEditorFn) (*GetProjectHealthClientResponse, error) { + rsp, err := c.GetProjectHealth(ctx, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetProjectHealthClientResponse(rsp) +} + // DeleteProjectLogoWithResponse request returning *DeleteProjectLogoClientResponse func (c *ClientWithResponses) DeleteProjectLogoWithResponse(ctx context.Context, id ProjectId, reqEditors ...RequestEditorFn) (*DeleteProjectLogoClientResponse, error) { rsp, err := c.DeleteProjectLogo(ctx, id, reqEditors...) @@ -23013,6 +23523,107 @@ func ParseUnbanAuthUserClientResponse(rsp *http.Response) (*UnbanAuthUserClientR return response, nil } +// ParseGetProjectConfigClientResponse parses an HTTP response from a GetProjectConfigWithResponse call +func ParseGetProjectConfigClientResponse(rsp *http.Response) (*GetProjectConfigClientResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetProjectConfigClientResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ProjectConfig + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + } + + return response, nil +} + +// ParseApplyProjectConfigClientResponse parses an HTTP response from a ApplyProjectConfigWithResponse call +func ParseApplyProjectConfigClientResponse(rsp *http.Response) (*ApplyProjectConfigClientResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ApplyProjectConfigClientResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ProjectConfigApplyResult + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ProjectConfigValidationErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON422 = &dest + + } + + return response, nil +} + // ParseListDatabasesClientResponse parses an HTTP response from a ListDatabasesWithResponse call func ParseListDatabasesClientResponse(rsp *http.Response) (*ListDatabasesClientResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -23085,6 +23696,19 @@ func ParseDeleteDatabaseClientResponse(rsp *http.Response) (*DeleteDatabaseClien HTTPResponse: rsp, } + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 202: + var dest struct { + Message *string `json:"message,omitempty"` + Status *string `json:"status,omitempty"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON202 = &dest + + } + return response, nil } @@ -23114,6 +23738,67 @@ func ParseGetDatabaseClientResponse(rsp *http.Response) (*GetDatabaseClientRespo return response, nil } +// ParseGetProjectDatabaseQueriesClientResponse parses an HTTP response from a GetProjectDatabaseQueriesWithResponse call +func ParseGetProjectDatabaseQueriesClientResponse(rsp *http.Response) (*GetProjectDatabaseQueriesClientResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetProjectDatabaseQueriesClientResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DatabaseQueryPerformanceResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &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 + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseResetDatabasePasswordClientResponse parses an HTTP response from a ResetDatabasePasswordWithResponse call func ParseResetDatabasePasswordClientResponse(rsp *http.Response) (*ResetDatabasePasswordClientResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -23137,7 +23822,7 @@ func ParseResetDatabasePasswordClientResponse(rsp *http.Response) (*ResetDatabas // NewPassword New Volcano-managed client password. Always starts with `vpg_`. NewPassword *string `json:"new_password,omitempty"` - // RoleName Volcano-managed client database role name + // RoleName Volcano-managed per-database client login (also the pgproxy routing username) RoleName *string `json:"role_name,omitempty"` } if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -24529,6 +25214,60 @@ func ParseUpdateFunctionSchedulerClientResponse(rsp *http.Response) (*UpdateFunc return response, nil } +// ParseGetProjectHealthClientResponse parses an HTTP response from a GetProjectHealthWithResponse call +func ParseGetProjectHealthClientResponse(rsp *http.Response) (*GetProjectHealthClientResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetProjectHealthClientResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ProjectHealthResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &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 + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseDeleteProjectLogoClientResponse parses an HTTP response from a DeleteProjectLogoWithResponse call func ParseDeleteProjectLogoClientResponse(rsp *http.Response) (*DeleteProjectLogoClientResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/internal/apiclient/common/common.gen.go b/internal/apiclient/common/common.gen.go index d11a603..e0c8f14 100644 --- a/internal/apiclient/common/common.gen.go +++ b/internal/apiclient/common/common.gen.go @@ -128,16 +128,16 @@ func (e CreateDatabaseRequestDatabaseType) Valid() bool { // Defines values for CreateDatabaseRequestPgVersion. const ( - N15 CreateDatabaseRequestPgVersion = "15" - N16 CreateDatabaseRequestPgVersion = "16" + CreateDatabaseRequestPgVersionN15 CreateDatabaseRequestPgVersion = "15" + CreateDatabaseRequestPgVersionN16 CreateDatabaseRequestPgVersion = "16" ) // Valid indicates whether the value is a known member of the CreateDatabaseRequestPgVersion enum. func (e CreateDatabaseRequestPgVersion) Valid() bool { switch e { - case N15: + case CreateDatabaseRequestPgVersionN15: return true - case N16: + case CreateDatabaseRequestPgVersionN16: return true default: return false @@ -288,6 +288,7 @@ func (e DatabaseDatabaseType) Valid() bool { // Defines values for DatabaseStatus. const ( DatabaseStatusActive DatabaseStatus = "active" + DatabaseStatusDeleting DatabaseStatus = "deleting" DatabaseStatusFailed DatabaseStatus = "failed" DatabaseStatusProvisioning DatabaseStatus = "provisioning" ) @@ -297,6 +298,8 @@ func (e DatabaseStatus) Valid() bool { switch e { case DatabaseStatusActive: return true + case DatabaseStatusDeleting: + return true case DatabaseStatusFailed: return true case DatabaseStatusProvisioning: @@ -800,25 +803,25 @@ func (e LogResourceType) Valid() bool { // Defines values for OAuthConfigProvider. const ( - Apple OAuthConfigProvider = "apple" - Device OAuthConfigProvider = "device" - Github OAuthConfigProvider = "github" - Google OAuthConfigProvider = "google" - Microsoft OAuthConfigProvider = "microsoft" + OAuthConfigProviderApple OAuthConfigProvider = "apple" + OAuthConfigProviderDevice OAuthConfigProvider = "device" + OAuthConfigProviderGithub OAuthConfigProvider = "github" + OAuthConfigProviderGoogle OAuthConfigProvider = "google" + OAuthConfigProviderMicrosoft OAuthConfigProvider = "microsoft" ) // Valid indicates whether the value is a known member of the OAuthConfigProvider enum. func (e OAuthConfigProvider) Valid() bool { switch e { - case Apple: + case OAuthConfigProviderApple: return true - case Device: + case OAuthConfigProviderDevice: return true - case Github: + case OAuthConfigProviderGithub: return true - case Google: + case OAuthConfigProviderGoogle: return true - case Microsoft: + case OAuthConfigProviderMicrosoft: return true default: return false @@ -885,6 +888,195 @@ func (e ProjectStatus) Valid() bool { } } +// Defines values for ProjectConfigVersion. +const ( + N1 ProjectConfigVersion = 1 +) + +// Valid indicates whether the value is a known member of the ProjectConfigVersion enum. +func (e ProjectConfigVersion) Valid() bool { + switch e { + case N1: + return true + default: + return false + } +} + +// Defines values for ProjectConfigApplyResultEntryAction. +const ( + ProjectConfigApplyResultEntryActionCreated ProjectConfigApplyResultEntryAction = "created" + ProjectConfigApplyResultEntryActionDeleted ProjectConfigApplyResultEntryAction = "deleted" + ProjectConfigApplyResultEntryActionError ProjectConfigApplyResultEntryAction = "error" + ProjectConfigApplyResultEntryActionUnchanged ProjectConfigApplyResultEntryAction = "unchanged" + ProjectConfigApplyResultEntryActionUpdated ProjectConfigApplyResultEntryAction = "updated" +) + +// Valid indicates whether the value is a known member of the ProjectConfigApplyResultEntryAction enum. +func (e ProjectConfigApplyResultEntryAction) Valid() bool { + switch e { + case ProjectConfigApplyResultEntryActionCreated: + return true + case ProjectConfigApplyResultEntryActionDeleted: + return true + case ProjectConfigApplyResultEntryActionError: + return true + case ProjectConfigApplyResultEntryActionUnchanged: + return true + case ProjectConfigApplyResultEntryActionUpdated: + return true + default: + return false + } +} + +// Defines values for ProjectConfigBucketPolicyOperation. +const ( + ProjectConfigBucketPolicyOperationDELETE ProjectConfigBucketPolicyOperation = "DELETE" + ProjectConfigBucketPolicyOperationINSERT ProjectConfigBucketPolicyOperation = "INSERT" + ProjectConfigBucketPolicyOperationSELECT ProjectConfigBucketPolicyOperation = "SELECT" + ProjectConfigBucketPolicyOperationUPDATE ProjectConfigBucketPolicyOperation = "UPDATE" +) + +// Valid indicates whether the value is a known member of the ProjectConfigBucketPolicyOperation enum. +func (e ProjectConfigBucketPolicyOperation) Valid() bool { + switch e { + case ProjectConfigBucketPolicyOperationDELETE: + return true + case ProjectConfigBucketPolicyOperationINSERT: + return true + case ProjectConfigBucketPolicyOperationSELECT: + return true + case ProjectConfigBucketPolicyOperationUPDATE: + return true + default: + return false + } +} + +// Defines values for ProjectConfigDatabaseDatabaseType. +const ( + ProjectConfigDatabaseDatabaseTypeVolcanoDb2xl ProjectConfigDatabaseDatabaseType = "volcano-db-2xl" + ProjectConfigDatabaseDatabaseTypeVolcanoDbL ProjectConfigDatabaseDatabaseType = "volcano-db-l" + ProjectConfigDatabaseDatabaseTypeVolcanoDbM ProjectConfigDatabaseDatabaseType = "volcano-db-m" + ProjectConfigDatabaseDatabaseTypeVolcanoDbS ProjectConfigDatabaseDatabaseType = "volcano-db-s" + ProjectConfigDatabaseDatabaseTypeVolcanoDbXl ProjectConfigDatabaseDatabaseType = "volcano-db-xl" + ProjectConfigDatabaseDatabaseTypeVolcanoDbXs ProjectConfigDatabaseDatabaseType = "volcano-db-xs" +) + +// Valid indicates whether the value is a known member of the ProjectConfigDatabaseDatabaseType enum. +func (e ProjectConfigDatabaseDatabaseType) Valid() bool { + switch e { + case ProjectConfigDatabaseDatabaseTypeVolcanoDb2xl: + return true + case ProjectConfigDatabaseDatabaseTypeVolcanoDbL: + return true + case ProjectConfigDatabaseDatabaseTypeVolcanoDbM: + return true + case ProjectConfigDatabaseDatabaseTypeVolcanoDbS: + return true + case ProjectConfigDatabaseDatabaseTypeVolcanoDbXl: + return true + case ProjectConfigDatabaseDatabaseTypeVolcanoDbXs: + return true + default: + return false + } +} + +// Defines values for ProjectConfigDatabasePgVersion. +const ( + ProjectConfigDatabasePgVersionN15 ProjectConfigDatabasePgVersion = "15" + ProjectConfigDatabasePgVersionN16 ProjectConfigDatabasePgVersion = "16" +) + +// Valid indicates whether the value is a known member of the ProjectConfigDatabasePgVersion enum. +func (e ProjectConfigDatabasePgVersion) Valid() bool { + switch e { + case ProjectConfigDatabasePgVersionN15: + return true + case ProjectConfigDatabasePgVersionN16: + return true + default: + return false + } +} + +// Defines values for ProjectConfigMissingResourceType. +const ( + ProjectConfigMissingResourceTypeBucket ProjectConfigMissingResourceType = "bucket" + ProjectConfigMissingResourceTypeDatabase ProjectConfigMissingResourceType = "database" + ProjectConfigMissingResourceTypeFrontend ProjectConfigMissingResourceType = "frontend" + ProjectConfigMissingResourceTypeFunction ProjectConfigMissingResourceType = "function" +) + +// Valid indicates whether the value is a known member of the ProjectConfigMissingResourceType enum. +func (e ProjectConfigMissingResourceType) Valid() bool { + switch e { + case ProjectConfigMissingResourceTypeBucket: + return true + case ProjectConfigMissingResourceTypeDatabase: + return true + case ProjectConfigMissingResourceTypeFrontend: + return true + case ProjectConfigMissingResourceTypeFunction: + return true + default: + return false + } +} + +// Defines values for ProjectConfigOAuthProviderProvider. +const ( + ProjectConfigOAuthProviderProviderApple ProjectConfigOAuthProviderProvider = "apple" + ProjectConfigOAuthProviderProviderDevice ProjectConfigOAuthProviderProvider = "device" + ProjectConfigOAuthProviderProviderGithub ProjectConfigOAuthProviderProvider = "github" + ProjectConfigOAuthProviderProviderGoogle ProjectConfigOAuthProviderProvider = "google" + ProjectConfigOAuthProviderProviderMicrosoft ProjectConfigOAuthProviderProvider = "microsoft" +) + +// Valid indicates whether the value is a known member of the ProjectConfigOAuthProviderProvider enum. +func (e ProjectConfigOAuthProviderProvider) Valid() bool { + switch e { + case ProjectConfigOAuthProviderProviderApple: + return true + case ProjectConfigOAuthProviderProviderDevice: + return true + case ProjectConfigOAuthProviderProviderGithub: + return true + case ProjectConfigOAuthProviderProviderGoogle: + return true + case ProjectConfigOAuthProviderProviderMicrosoft: + return true + default: + return false + } +} + +// Defines values for ProjectConfigSkippedResourceType. +const ( + ProjectConfigSkippedResourceTypeBucket ProjectConfigSkippedResourceType = "bucket" + ProjectConfigSkippedResourceTypeDatabase ProjectConfigSkippedResourceType = "database" + ProjectConfigSkippedResourceTypeFrontend ProjectConfigSkippedResourceType = "frontend" + ProjectConfigSkippedResourceTypeFunction ProjectConfigSkippedResourceType = "function" +) + +// Valid indicates whether the value is a known member of the ProjectConfigSkippedResourceType enum. +func (e ProjectConfigSkippedResourceType) Valid() bool { + switch e { + case ProjectConfigSkippedResourceTypeBucket: + return true + case ProjectConfigSkippedResourceTypeDatabase: + return true + case ProjectConfigSkippedResourceTypeFrontend: + return true + case ProjectConfigSkippedResourceTypeFunction: + return true + default: + return false + } +} + // Defines values for ProjectFrontendCustomDomainDomainStatus. const ( ProjectFrontendCustomDomainDomainStatusActive ProjectFrontendCustomDomainDomainStatus = "active" @@ -999,6 +1191,93 @@ func (e ProjectFrontendDeploymentStatus) Valid() bool { } } +// Defines values for ProjectHealthComponentName. +const ( + ProjectHealthComponentNameDatabases ProjectHealthComponentName = "databases" + ProjectHealthComponentNameFrontends ProjectHealthComponentName = "frontends" + ProjectHealthComponentNameFunctions ProjectHealthComponentName = "functions" + ProjectHealthComponentNameProject ProjectHealthComponentName = "project" +) + +// Valid indicates whether the value is a known member of the ProjectHealthComponentName enum. +func (e ProjectHealthComponentName) Valid() bool { + switch e { + case ProjectHealthComponentNameDatabases: + return true + case ProjectHealthComponentNameFrontends: + return true + case ProjectHealthComponentNameFunctions: + return true + case ProjectHealthComponentNameProject: + return true + default: + return false + } +} + +// Defines values for ProjectHealthIssueSeverity. +const ( + Critical ProjectHealthIssueSeverity = "critical" + Warning ProjectHealthIssueSeverity = "warning" +) + +// Valid indicates whether the value is a known member of the ProjectHealthIssueSeverity enum. +func (e ProjectHealthIssueSeverity) Valid() bool { + switch e { + case Critical: + return true + case Warning: + return true + default: + return false + } +} + +// Defines values for ProjectHealthResourceType. +const ( + ProjectHealthResourceTypeDatabase ProjectHealthResourceType = "database" + ProjectHealthResourceTypeFrontend ProjectHealthResourceType = "frontend" + ProjectHealthResourceTypeFunction ProjectHealthResourceType = "function" + ProjectHealthResourceTypeProject ProjectHealthResourceType = "project" +) + +// Valid indicates whether the value is a known member of the ProjectHealthResourceType enum. +func (e ProjectHealthResourceType) Valid() bool { + switch e { + case ProjectHealthResourceTypeDatabase: + return true + case ProjectHealthResourceTypeFrontend: + return true + case ProjectHealthResourceTypeFunction: + return true + case ProjectHealthResourceTypeProject: + return true + default: + return false + } +} + +// Defines values for ProjectHealthStatus. +const ( + Degraded ProjectHealthStatus = "degraded" + Healthy ProjectHealthStatus = "healthy" + Unhealthy ProjectHealthStatus = "unhealthy" +) + +// Valid indicates whether the value is a known member of the ProjectHealthStatus enum. +func (e ProjectHealthStatus) Valid() bool { + switch e { + case Degraded: + return true + case Healthy: + return true + case Unhealthy: + return true + default: + return false + } +} + // Defines values for ScheduleRequestKind. const ( ScheduleRequestKindCron ScheduleRequestKind = "cron" @@ -1016,22 +1295,22 @@ func (e ScheduleRequestKind) Valid() bool { // Defines values for StoragePolicyOperation. const ( - StoragePolicyOperationDELETE StoragePolicyOperation = "DELETE" - StoragePolicyOperationINSERT StoragePolicyOperation = "INSERT" - StoragePolicyOperationSELECT StoragePolicyOperation = "SELECT" - StoragePolicyOperationUPDATE StoragePolicyOperation = "UPDATE" + DELETE StoragePolicyOperation = "DELETE" + INSERT StoragePolicyOperation = "INSERT" + SELECT StoragePolicyOperation = "SELECT" + UPDATE StoragePolicyOperation = "UPDATE" ) // Valid indicates whether the value is a known member of the StoragePolicyOperation enum. func (e StoragePolicyOperation) Valid() bool { switch e { - case StoragePolicyOperationDELETE: + case DELETE: return true - case StoragePolicyOperationINSERT: + case INSERT: return true - case StoragePolicyOperationSELECT: + case SELECT: return true - case StoragePolicyOperationUPDATE: + case UPDATE: return true default: return false @@ -1055,28 +1334,28 @@ func (e UnbanUserResponseStatus) Valid() bool { // Defines values for UpdateDatabaseTypeRequestDatabaseType. const ( - VolcanoDb2xl UpdateDatabaseTypeRequestDatabaseType = "volcano-db-2xl" - VolcanoDbL UpdateDatabaseTypeRequestDatabaseType = "volcano-db-l" - VolcanoDbM UpdateDatabaseTypeRequestDatabaseType = "volcano-db-m" - VolcanoDbS UpdateDatabaseTypeRequestDatabaseType = "volcano-db-s" - VolcanoDbXl UpdateDatabaseTypeRequestDatabaseType = "volcano-db-xl" - VolcanoDbXs UpdateDatabaseTypeRequestDatabaseType = "volcano-db-xs" + UpdateDatabaseTypeRequestDatabaseTypeVolcanoDb2xl UpdateDatabaseTypeRequestDatabaseType = "volcano-db-2xl" + UpdateDatabaseTypeRequestDatabaseTypeVolcanoDbL UpdateDatabaseTypeRequestDatabaseType = "volcano-db-l" + UpdateDatabaseTypeRequestDatabaseTypeVolcanoDbM UpdateDatabaseTypeRequestDatabaseType = "volcano-db-m" + UpdateDatabaseTypeRequestDatabaseTypeVolcanoDbS UpdateDatabaseTypeRequestDatabaseType = "volcano-db-s" + UpdateDatabaseTypeRequestDatabaseTypeVolcanoDbXl UpdateDatabaseTypeRequestDatabaseType = "volcano-db-xl" + UpdateDatabaseTypeRequestDatabaseTypeVolcanoDbXs UpdateDatabaseTypeRequestDatabaseType = "volcano-db-xs" ) // Valid indicates whether the value is a known member of the UpdateDatabaseTypeRequestDatabaseType enum. func (e UpdateDatabaseTypeRequestDatabaseType) Valid() bool { switch e { - case VolcanoDb2xl: + case UpdateDatabaseTypeRequestDatabaseTypeVolcanoDb2xl: return true - case VolcanoDbL: + case UpdateDatabaseTypeRequestDatabaseTypeVolcanoDbL: return true - case VolcanoDbM: + case UpdateDatabaseTypeRequestDatabaseTypeVolcanoDbM: return true - case VolcanoDbS: + case UpdateDatabaseTypeRequestDatabaseTypeVolcanoDbS: return true - case VolcanoDbXl: + case UpdateDatabaseTypeRequestDatabaseTypeVolcanoDbXl: return true - case VolcanoDbXs: + case UpdateDatabaseTypeRequestDatabaseTypeVolcanoDbXs: return true default: return false @@ -1558,10 +1837,12 @@ type CreateVariableRequest struct { type Database struct { // ConnectionString Secure PostgreSQL connection URI for your database. // - // **Connection Modes via application_name parameter:** - // - `volcano_full_access:{database_name}` — Full admin access (DDL, migrations) - // - `volcano_user_access:{database_name}:{user_id}` — User impersonation (RLS enforced) - // - `volcano_user_access:{database_name}` — Anonymous access (anon role, RLS enforced) + // The database is identified by the globally-unique username + // (`volcano_client_{database_id}`) already in this URI; the + // `application_name` parameter only selects the access mode: + // - `volcano_full_access` — Full admin access (DDL, migrations) + // - `volcano_user_access:{user_id}` — User impersonation (RLS enforced) + // - `volcano_user_access` — Anonymous access (anon role, RLS enforced) ConnectionString *string `json:"connection_string,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -1596,6 +1877,39 @@ type DatabaseDatabaseType string // DatabaseStatus Database provisioning status type DatabaseStatus string +// DatabaseQueryPerformanceDatabase defines model for DatabaseQueryPerformanceDatabase. +type DatabaseQueryPerformanceDatabase struct { + Id openapi_types.UUID `json:"id"` + Name string `json:"name"` +} + +// DatabaseQueryPerformanceItem defines model for DatabaseQueryPerformanceItem. +type DatabaseQueryPerformanceItem struct { + Calls int64 `json:"calls"` + Database DatabaseQueryPerformanceDatabase `json:"database"` + MaxExecTimeSeconds float64 `json:"max_exec_time_seconds"` + MeanExecTimeSeconds float64 `json:"mean_exec_time_seconds"` + MinExecTimeSeconds float64 `json:"min_exec_time_seconds"` + + // Query Normalized and obfuscated representative query text. + Query string `json:"query"` + + // QueryId pg_stat_statements query identifier. + QueryId string `json:"query_id"` + + // Role Database role used for the query. + Role string `json:"role"` + RowsProcessed int64 `json:"rows_processed"` + + // TotalExecTimeSeconds Cumulative total execution time from pg_stat_statements in seconds. + TotalExecTimeSeconds float64 `json:"total_exec_time_seconds"` +} + +// DatabaseQueryPerformanceResponse defines model for DatabaseQueryPerformanceResponse. +type DatabaseQueryPerformanceResponse struct { + Data []DatabaseQueryPerformanceItem `json:"data"` +} + // DatabaseStats defines model for DatabaseStats. type DatabaseStats struct { // ActiveTimeSeconds Total active compute time in seconds @@ -2026,7 +2340,7 @@ type HostedLoginOptionsResponse struct { // HostedRenderablePageType defines model for HostedRenderablePageType. type HostedRenderablePageType string -// LiveLogLevel Normalized function runtime log level. +// LiveLogLevel Canonical lowercase function runtime log level. type LiveLogLevel string // LogActivityBucket Log-event counts for one activity time bucket. @@ -2061,7 +2375,7 @@ type LogActivityRequest struct { // EndTime End time. EndTime *time.Time `json:"end_time,omitempty"` - // Levels Normalized log levels to filter by. If omitted, empty, or all levels are selected, no level filter is applied. + // Levels Canonical lowercase log levels to filter by. If omitted, empty, or all levels are selected, no level filter is applied. Levels *[]LiveLogLevel `json:"levels,omitempty"` // Q Optional free-text search query for log messages. @@ -2126,7 +2440,7 @@ type LogEvent struct { // InvocationId Function invocation ID associated with this log event, when available. InvocationId *string `json:"invocation_id,omitempty"` - // Level Normalized function runtime log level. + // Level Canonical lowercase function runtime log level. Level *LiveLogLevel `json:"level,omitempty"` // Message Display log message. @@ -2209,7 +2523,7 @@ type LogSearchEvent struct { // InvocationId Function invocation ID associated with this log event, when available. InvocationId *string `json:"invocation_id,omitempty"` - // Level Normalized function runtime log level. + // Level Canonical lowercase function runtime log level. Level *LiveLogLevel `json:"level,omitempty"` // Message Display log message. @@ -2239,7 +2553,7 @@ type LogSearchRequest struct { // EndTime End time. EndTime *time.Time `json:"end_time,omitempty"` - // Levels Normalized log levels to filter by. If omitted, empty, or all levels are selected, no level filter is applied. + // Levels Canonical lowercase log levels to filter by. If omitted, empty, or all levels are selected, no level filter is applied. Levels *[]LiveLogLevel `json:"levels,omitempty"` // Limit Maximum number of records to return. @@ -2275,7 +2589,7 @@ type LogSearchResponse struct { // LogStreamRequest Stream request for live project logs. Text search, pagination cursors, and fixed end times are not supported. type LogStreamRequest struct { - // Levels Normalized log levels to filter by. If omitted, empty, or all levels are selected, no level filter is applied. + // Levels Canonical lowercase log levels to filter by. If omitted, empty, or all levels are selected, no level filter is applied. Levels *[]LiveLogLevel `json:"levels,omitempty"` // Limit Maximum number of records to deliver on connect or reconnect before following new events. @@ -2612,6 +2926,455 @@ type ProjectPlan string // ProjectStatus defines model for Project.Status. type ProjectStatus string +// ProjectConfig Declarative project configuration manifest (the JSON form of +// volcano-config.yaml). Omitted sections are left untouched. Within +// declared entries, omitted optional fields keep their current server +// values (patch semantics). Declared collection keys are fully synced to +// the manifest: `variables`, `buckets[].policies`, `auth.providers.oauth`, +// `auth.email.templates`, and `functions[].schedulers` are reconciled to +// exactly match, deleting resources absent from the manifest. Functions, +// frontends, databases, and buckets are never created or deleted through +// this manifest; entries referencing resources that do not exist are +// skipped and reported. +type ProjectConfig struct { + // Auth Authentication settings, grouped like the dashboard auth-settings tabs. + Auth *ProjectConfigAuth `json:"auth,omitempty"` + Buckets *[]ProjectConfigBucket `json:"buckets,omitempty"` + Databases *[]ProjectConfigDatabase `json:"databases,omitempty"` + Frontends *[]ProjectConfigFrontend `json:"frontends,omitempty"` + Functions *[]ProjectConfigFunction `json:"functions,omitempty"` + + // Project Project-level settings. `name` renames the project. + Project *ProjectConfigProject `json:"project,omitempty"` + Realtime *ProjectConfigRealtime `json:"realtime,omitempty"` + + // Variables Fully synced when declared - variables absent from this list are deleted. + Variables *[]ProjectConfigVariable `json:"variables,omitempty"` + + // Version Manifest schema version. Must be 1. + Version ProjectConfigVersion `json:"version"` +} + +// ProjectConfigVersion Manifest schema version. Must be 1. +type ProjectConfigVersion int + +// ProjectConfigApplyResult Per-resource report for a project config apply (or dry run). +type ProjectConfigApplyResult struct { + // DryRun True when the request was a dry run and no changes were made. + DryRun *bool `json:"dry_run,omitempty"` + + // Missing Existing functions, frontends, databases, or buckets that have no + // entry in the corresponding declared manifest section. + Missing []ProjectConfigMissingResource `json:"missing"` + Results []ProjectConfigApplyResultEntry `json:"results"` + + // Skipped Manifest entries referencing functions, frontends, databases, or + // buckets that do not exist. Their configuration was not applied; + // deploy/create the resource first, then re-apply. + Skipped []ProjectConfigSkippedResource `json:"skipped"` + Summary ProjectConfigApplySummary `json:"summary"` +} + +// ProjectConfigApplyResultEntry defines model for ProjectConfigApplyResultEntry. +type ProjectConfigApplyResultEntry struct { + Action ProjectConfigApplyResultEntryAction `json:"action"` + + // Error Error detail when action is `error`. + Error *string `json:"error,omitempty"` + + // Name Resource name or key within the section. Empty for singleton sections. + Name *string `json:"name,omitempty"` + + // Notice Optional operational note (e.g. disabling realtime drops active connections). + Notice *string `json:"notice,omitempty"` + + // Section Manifest section the entry belongs to (e.g. variables, buckets, auth.providers.oauth). + Section string `json:"section"` +} + +// ProjectConfigApplyResultEntryAction defines model for ProjectConfigApplyResultEntry.Action. +type ProjectConfigApplyResultEntryAction string + +// ProjectConfigApplySummary defines model for ProjectConfigApplySummary. +type ProjectConfigApplySummary struct { + Created int `json:"created"` + Deleted int `json:"deleted"` + Errors int `json:"errors"` + Missing int `json:"missing"` + Skipped int `json:"skipped"` + Unchanged int `json:"unchanged"` + Updated int `json:"updated"` +} + +// ProjectConfigAuth Authentication settings, grouped like the dashboard auth-settings tabs. +type ProjectConfigAuth struct { + Cors *ProjectConfigAuthCORS `json:"cors,omitempty"` + Email *ProjectConfigAuthEmail `json:"email,omitempty"` + EmailVerification *ProjectConfigAuthEmailVerification `json:"email_verification,omitempty"` + ManagedPages *ProjectConfigAuthManagedPages `json:"managed_pages,omitempty"` + Password *ProjectConfigAuthPassword `json:"password,omitempty"` + PasswordReset *ProjectConfigAuthPasswordReset `json:"password_reset,omitempty"` + Providers *ProjectConfigAuthProviders `json:"providers,omitempty"` + + // RateLimits Rate limits per hour. + RateLimits *ProjectConfigAuthRateLimits `json:"rate_limits,omitempty"` + Sessions *ProjectConfigAuthSessions `json:"sessions,omitempty"` + Signup *ProjectConfigAuthSignup `json:"signup,omitempty"` + + // Tokens Token lifetimes in seconds. + Tokens *ProjectConfigAuthTokens `json:"tokens,omitempty"` +} + +// ProjectConfigAuthCORS defines model for ProjectConfigAuthCORS. +type ProjectConfigAuthCORS struct { + AllowCredentials *bool `json:"allow_credentials,omitempty"` + AllowedOrigins *[]string `json:"allowed_origins,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + MaxAge *int `json:"max_age,omitempty"` +} + +// ProjectConfigAuthEmail defines model for ProjectConfigAuthEmail. +type ProjectConfigAuthEmail struct { + // Enabled Enable transactional email sending + Enabled *bool `json:"enabled,omitempty"` + From *ProjectConfigAuthEmailFrom `json:"from,omitempty"` + Smtp *ProjectConfigAuthEmailSMTP `json:"smtp,omitempty"` + + // Templates Email templates keyed by type. Fully synced when declared - template + // types absent from a declared map revert to server defaults (custom + // bodies deleted, subject overrides cleared). Custom template bodies + // require the PRO plan; subject-only changes are available on FREE. + Templates *ProjectConfigEmailTemplates `json:"templates,omitempty"` +} + +// ProjectConfigAuthEmailFrom defines model for ProjectConfigAuthEmailFrom. +type ProjectConfigAuthEmailFrom struct { + Address *string `json:"address,omitempty"` + Name *string `json:"name,omitempty"` +} + +// ProjectConfigAuthEmailPasswordProvider defines model for ProjectConfigAuthEmailPasswordProvider. +type ProjectConfigAuthEmailPasswordProvider struct { + Enabled *bool `json:"enabled,omitempty"` +} + +// ProjectConfigAuthEmailSMTP defines model for ProjectConfigAuthEmailSMTP. +type ProjectConfigAuthEmailSMTP struct { + Host *string `json:"host,omitempty"` + + // Password Write-only; omitted from config export. + Password *string `json:"password,omitempty"` + Port *int `json:"port,omitempty"` + UseTls *bool `json:"use_tls,omitempty"` + Username *string `json:"username,omitempty"` +} + +// ProjectConfigAuthEmailVerification defines model for ProjectConfigAuthEmailVerification. +type ProjectConfigAuthEmailVerification struct { + // ConfirmationTimeout Email confirmation token expiry in seconds + ConfirmationTimeout *int `json:"confirmation_timeout,omitempty"` + + // RequireConfirmation Require users to confirm email before sign-in. Requires email sending to be enabled. + RequireConfirmation *bool `json:"require_confirmation,omitempty"` +} + +// ProjectConfigAuthManagedPages defines model for ProjectConfigAuthManagedPages. +type ProjectConfigAuthManagedPages struct { + // Enabled Enable or disable managed auth hosted pages + Enabled *bool `json:"enabled,omitempty"` + + // Pages Hosted auth pages keyed by page type (PRO plan). Upsert-only: omitted + // pages are left untouched (there is no delete for hosted pages). + Pages *ProjectConfigHostedPages `json:"pages,omitempty"` + Redirects *ProjectConfigAuthRedirects `json:"redirects,omitempty"` +} + +// ProjectConfigAuthPassword defines model for ProjectConfigAuthPassword. +type ProjectConfigAuthPassword struct { + MinLength *int `json:"min_length,omitempty"` + RequireLowercase *bool `json:"require_lowercase,omitempty"` + RequireNumbers *bool `json:"require_numbers,omitempty"` + RequireSpecialChars *bool `json:"require_special_chars,omitempty"` + RequireUppercase *bool `json:"require_uppercase,omitempty"` +} + +// ProjectConfigAuthPasswordReset defines model for ProjectConfigAuthPasswordReset. +type ProjectConfigAuthPasswordReset struct { + Allow *bool `json:"allow,omitempty"` + + // MaxHistory Number of previous passwords to disallow (0=disabled) + MaxHistory *int `json:"max_history,omitempty"` + + // Timeout Password reset token expiry in seconds + Timeout *int `json:"timeout,omitempty"` +} + +// ProjectConfigAuthProviders defines model for ProjectConfigAuthProviders. +type ProjectConfigAuthProviders struct { + EmailPassword *ProjectConfigAuthEmailPasswordProvider `json:"email_password,omitempty"` + + // Oauth Fully synced when declared - providers absent from this list are deleted. + Oauth *[]ProjectConfigOAuthProvider `json:"oauth,omitempty"` +} + +// ProjectConfigAuthRateLimits Rate limits per hour. +type ProjectConfigAuthRateLimits struct { + PasswordReset *int `json:"password_reset,omitempty"` + Signin *int `json:"signin,omitempty"` + Signup *int `json:"signup,omitempty"` + TokenRefresh *int `json:"token_refresh,omitempty"` +} + +// ProjectConfigAuthRedirects defines model for ProjectConfigAuthRedirects. +type ProjectConfigAuthRedirects struct { + // Allowed Redirect allowlist. Every entry must be a valid http/https URL. + Allowed *[]string `json:"allowed,omitempty"` + + // DeviceVerification Optional custom device-authorization verification page URL. + DeviceVerification *string `json:"device_verification,omitempty"` + + // PostAuth Must be included in `allowed` when set. + PostAuth *string `json:"post_auth,omitempty"` + + // PostLogout Must be included in `allowed` when set. + PostLogout *string `json:"post_logout,omitempty"` +} + +// ProjectConfigAuthSessions defines model for ProjectConfigAuthSessions. +type ProjectConfigAuthSessions struct { + // InactivityTimeout Force re-login after inactivity (seconds, 0=never) + InactivityTimeout *int `json:"inactivity_timeout,omitempty"` + + // MaxSessionDuration Force re-login after duration (seconds, 0=never) + MaxSessionDuration *int `json:"max_session_duration,omitempty"` +} + +// ProjectConfigAuthSignup defines model for ProjectConfigAuthSignup. +type ProjectConfigAuthSignup struct { + EnableAnonymousSignins *bool `json:"enable_anonymous_signins,omitempty"` + + // EnableSignup Master switch for signups across ALL providers + EnableSignup *bool `json:"enable_signup,omitempty"` +} + +// ProjectConfigAuthTokens Token lifetimes in seconds. +type ProjectConfigAuthTokens struct { + AccessTokenLifetime *int `json:"access_token_lifetime,omitempty"` + PlatformTokenTtl *int `json:"platform_token_ttl,omitempty"` + RefreshTokenLifetime *int `json:"refresh_token_lifetime,omitempty"` + RefreshTokenReuseInterval *int `json:"refresh_token_reuse_interval,omitempty"` +} + +// ProjectConfigBucket Settings for an existing storage bucket. Buckets are never created or +// deleted through the manifest. When `policies` is declared it is fully +// synced (policies absent from the list are deleted; an empty list +// deletes all); omitting `policies` leaves the bucket's policies +// untouched. +type ProjectConfigBucket struct { + AllowedMimeTypes *[]string `json:"allowed_mime_types,omitempty"` + + // FileSizeLimit Maximum file size in bytes + FileSizeLimit *int64 `json:"file_size_limit,omitempty"` + Name string `json:"name"` + Policies *[]ProjectConfigBucketPolicy `json:"policies,omitempty"` +} + +// ProjectConfigBucketPolicy defines model for ProjectConfigBucketPolicy. +type ProjectConfigBucketPolicy struct { + // Definition Policy expression + Definition string `json:"definition"` + Name string `json:"name"` + Operation ProjectConfigBucketPolicyOperation `json:"operation"` +} + +// ProjectConfigBucketPolicyOperation defines model for ProjectConfigBucketPolicy.Operation. +type ProjectConfigBucketPolicyOperation string + +// ProjectConfigCustomDomain Custom domain with BYOC TLS (PRO plan). `tls` is required when the +// domain is first created and optional afterwards: providing new TLS +// material for the same domain rotates the certificate in place (zero +// downtime); omitting `tls` keeps the stored certificate. TLS material is +// write-only and omitted from config export. +type ProjectConfigCustomDomain struct { + // Domain Fully-qualified domain name (hostname only, no scheme/path) + Domain string `json:"domain"` + Tls *FrontendCustomDomainTLSConfig `json:"tls,omitempty"` +} + +// ProjectConfigDatabase Assertion-only entry for an existing database. No database property is +// mutable through the manifest; declared values are compared against the +// deployed database and any mismatch fails validation. Databases are +// never created or deleted here. +type ProjectConfigDatabase struct { + // DatabaseType Compute tier. Asserted, never written - tier changes are not + // supported via the manifest; use the databases API/CLI/GUI instead. + DatabaseType *ProjectConfigDatabaseDatabaseType `json:"database_type,omitempty"` + Name string `json:"name"` + + // PgVersion PostgreSQL major version. Asserted, never written. + PgVersion ProjectConfigDatabasePgVersion `json:"pg_version"` + + // Region Deployed region (aws- prefixed, e.g. aws-us-east-1). Asserted, never written. + Region string `json:"region"` +} + +// ProjectConfigDatabaseDatabaseType Compute tier. Asserted, never written - tier changes are not +// supported via the manifest; use the databases API/CLI/GUI instead. +type ProjectConfigDatabaseDatabaseType string + +// ProjectConfigDatabasePgVersion PostgreSQL major version. Asserted, never written. +type ProjectConfigDatabasePgVersion string + +// ProjectConfigEmailTemplate defines model for ProjectConfigEmailTemplate. +type ProjectConfigEmailTemplate struct { + // HtmlBody HTML body. Max 256 KiB. PRO plan required for custom bodies. + HtmlBody *string `json:"html_body,omitempty"` + Subject *string `json:"subject,omitempty"` + + // TextBody Plain-text body. Max 256 KiB. PRO plan required for custom bodies. + TextBody *string `json:"text_body,omitempty"` +} + +// ProjectConfigEmailTemplates Email templates keyed by type. Fully synced when declared - template +// types absent from a declared map revert to server defaults (custom +// bodies deleted, subject overrides cleared). Custom template bodies +// require the PRO plan; subject-only changes are available on FREE. +type ProjectConfigEmailTemplates struct { + Confirmation *ProjectConfigEmailTemplate `json:"confirmation,omitempty"` + PasswordChanged *ProjectConfigEmailTemplate `json:"password_changed,omitempty"` + PasswordReset *ProjectConfigEmailTemplate `json:"password_reset,omitempty"` + Welcome *ProjectConfigEmailTemplate `json:"welcome,omitempty"` +} + +// ProjectConfigFrontend Configuration for an existing (deployed) frontend. Frontends are never +// created or deleted through the manifest. A declared frontend entry +// without `custom_domain` deletes an existing custom domain. +type ProjectConfigFrontend struct { + // CustomDomain Custom domain with BYOC TLS (PRO plan). `tls` is required when the + // domain is first created and optional afterwards: providing new TLS + // material for the same domain rotates the certificate in place (zero + // downtime); omitting `tls` keeps the stored certificate. TLS material is + // write-only and omitted from config export. + CustomDomain *ProjectConfigCustomDomain `json:"custom_domain,omitempty"` + Name string `json:"name"` +} + +// ProjectConfigFunction Configuration for an existing (deployed) function. Functions are never +// created or deleted through the manifest. When `schedulers` is declared +// it is fully synced (schedulers absent from the list are deleted); +// omitting `schedulers` leaves the function's schedulers untouched. +type ProjectConfigFunction struct { + Name string `json:"name"` + + // Public Function visibility for anon-key invocation + Public *bool `json:"public,omitempty"` + Schedulers *[]ProjectConfigScheduler `json:"schedulers,omitempty"` +} + +// ProjectConfigHostedPage defines model for ProjectConfigHostedPage. +type ProjectConfigHostedPage struct { + // Css Optional CSS injected at render time. Max 256 KiB. + Css *string `json:"css,omitempty"` + + // Html Raw HTML markup for the page. Max 256 KiB. + Html string `json:"html"` +} + +// ProjectConfigHostedPages Hosted auth pages keyed by page type (PRO plan). Upsert-only: omitted +// pages are left untouched (there is no delete for hosted pages). +type ProjectConfigHostedPages struct { + Login *ProjectConfigHostedPage `json:"login,omitempty"` + ResetPassword *ProjectConfigHostedPage `json:"reset_password,omitempty"` +} + +// ProjectConfigMissingResource defines model for ProjectConfigMissingResource. +type ProjectConfigMissingResource struct { + Name string `json:"name"` + Type ProjectConfigMissingResourceType `json:"type"` +} + +// ProjectConfigMissingResourceType defines model for ProjectConfigMissingResource.Type. +type ProjectConfigMissingResourceType string + +// ProjectConfigOAuthProvider defines model for ProjectConfigOAuthProvider. +type ProjectConfigOAuthProvider struct { + // ClientId Required for non-device providers. Server-generated for + // `provider=device` (exported read-only, ignored on apply). + ClientId *string `json:"client_id,omitempty"` + + // ClientSecret Write-only; omitted from config export. Not used for `provider=device`. + ClientSecret *string `json:"client_secret,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Provider ProjectConfigOAuthProviderProvider `json:"provider"` + + // RedirectUrl Not used for `provider=device`. + RedirectUrl *string `json:"redirect_url,omitempty"` + Scopes *[]string `json:"scopes,omitempty"` +} + +// ProjectConfigOAuthProviderProvider defines model for ProjectConfigOAuthProvider.Provider. +type ProjectConfigOAuthProviderProvider string + +// ProjectConfigProject Project-level settings. `name` renames the project. +type ProjectConfigProject struct { + // AllRegions Region policy. `false` requires `selected_regions` (PRO plan). + AllRegions *bool `json:"all_regions,omitempty"` + Name *string `json:"name,omitempty"` + + // SelectedRegions Region subset (bare AWS names). Requires `all_regions=false`. + SelectedRegions *[]string `json:"selected_regions,omitempty"` +} + +// ProjectConfigRealtime defines model for ProjectConfigRealtime. +type ProjectConfigRealtime struct { + BroadcastEnabled *bool `json:"broadcast_enabled,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + PostgresChangesEnabled *bool `json:"postgres_changes_enabled,omitempty"` + PresenceEnabled *bool `json:"presence_enabled,omitempty"` +} + +// ProjectConfigScheduler defines model for ProjectConfigScheduler. +type ProjectConfigScheduler struct { + // Cron 5-field UTC cron expression + Cron string `json:"cron"` + Enabled *bool `json:"enabled,omitempty"` + Name string `json:"name"` + Payload *map[string]interface{} `json:"payload,omitempty"` +} + +// ProjectConfigSkippedResource defines model for ProjectConfigSkippedResource. +type ProjectConfigSkippedResource struct { + Name string `json:"name"` + Reason string `json:"reason"` + Type ProjectConfigSkippedResourceType `json:"type"` +} + +// ProjectConfigSkippedResourceType defines model for ProjectConfigSkippedResource.Type. +type ProjectConfigSkippedResourceType string + +// ProjectConfigValidationError defines model for ProjectConfigValidationError. +type ProjectConfigValidationError struct { + Message string `json:"message"` + + // Name Resource name or key within the section, when applicable. + Name *string `json:"name,omitempty"` + + // Section Manifest section the error refers to (e.g. databases, functions). + Section string `json:"section"` +} + +// ProjectConfigValidationErrorResponse Returned when manifest validation fails. Nothing was applied. +type ProjectConfigValidationErrorResponse struct { + Error string `json:"error"` + Errors []ProjectConfigValidationError `json:"errors"` +} + +// ProjectConfigVariable defines model for ProjectConfigVariable. +type ProjectConfigVariable struct { + Name string `json:"name"` + Value string `json:"value"` +} + // ProjectFrontendCustomDomain defines model for ProjectFrontendCustomDomain. type ProjectFrontendCustomDomain struct { CreatedAt time.Time `json:"created_at"` @@ -2680,6 +3443,64 @@ type ProjectFrontendDeploymentOperation string // ProjectFrontendDeploymentStatus defines model for ProjectFrontendDeployment.Status. type ProjectFrontendDeploymentStatus string +// ProjectHealthComponent defines model for ProjectHealthComponent. +type ProjectHealthComponent struct { + Counts ProjectHealthCounts `json:"counts"` + Name ProjectHealthComponentName `json:"name"` + Status ProjectHealthStatus `json:"status"` +} + +// ProjectHealthComponentName defines model for ProjectHealthComponent.Name. +type ProjectHealthComponentName string + +// ProjectHealthCounts defines model for ProjectHealthCounts. +type ProjectHealthCounts struct { + Active int `json:"active"` + Deleting int `json:"deleting"` + Failed int `json:"failed"` + Provisioning int `json:"provisioning"` + Total int `json:"total"` + Unknown int `json:"unknown"` +} + +// ProjectHealthIssue defines model for ProjectHealthIssue. +type ProjectHealthIssue struct { + CreatedAt time.Time `json:"created_at"` + LastError *string `json:"last_error,omitempty"` + Message string `json:"message"` + ProvisioningStartedAt *time.Time `json:"provisioning_started_at,omitempty"` + Resource ProjectHealthResource `json:"resource"` + Severity ProjectHealthIssueSeverity `json:"severity"` + Status string `json:"status"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ProjectHealthIssueSeverity defines model for ProjectHealthIssueSeverity. +type ProjectHealthIssueSeverity string + +// ProjectHealthResource defines model for ProjectHealthResource. +type ProjectHealthResource struct { + Id openapi_types.UUID `json:"id"` + Name string `json:"name"` + Type ProjectHealthResourceType `json:"type"` +} + +// ProjectHealthResourceType defines model for ProjectHealthResource.Type. +type ProjectHealthResourceType string + +// ProjectHealthResponse defines model for ProjectHealthResponse. +type ProjectHealthResponse struct { + CheckedAt time.Time `json:"checked_at"` + Components []ProjectHealthComponent `json:"components"` + Counts ProjectHealthCounts `json:"counts"` + Issues []ProjectHealthIssue `json:"issues"` + ProjectId openapi_types.UUID `json:"project_id"` + Status ProjectHealthStatus `json:"status"` +} + +// ProjectHealthStatus defines model for ProjectHealthStatus. +type ProjectHealthStatus string + // ProjectUsageResponse Aggregated usage metrics for a project. type ProjectUsageResponse struct { // Frontends Per-frontend request totals for the current usage month diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index 7cd19df..2eec9f2 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -1,23 +1,40 @@ -// Package config wires the config command tree for project configuration. +// Package config wires the config command tree for declarative project +// configuration. package config import ( "context" + "errors" "fmt" "io" + "os" "path/filepath" + "strings" "github.com/spf13/cobra" + "github.com/Kong/volcano-cli/internal/api" "github.com/Kong/volcano-cli/internal/output" "github.com/Kong/volcano-cli/internal/projectconfig" cliruntime "github.com/Kong/volcano-cli/internal/runtime" ) +// pulledManifestMode keeps pulled manifests owner-only: variable values are +// included in exports. +const pulledManifestMode = 0o600 + type deployOptions struct { - deps cliruntime.Deps - file string - out io.Writer + deps cliruntime.Deps + file string + dryRun bool + out io.Writer +} + +type pullOptions struct { + deps cliruntime.Deps + file string + force bool + out io.Writer } // New returns the config command. @@ -25,55 +42,100 @@ func New(deps cliruntime.Deps) *cobra.Command { cmd := &cobra.Command{ Use: "config", Short: "Manage declarative project configuration", - Long: `Deploy declarative project configuration from YAML manifests. + Long: `Manage the full project configuration through a declarative YAML manifest +(volcano-config.yaml): project settings, database assertions, variables, +buckets and policies, realtime, the complete auth configuration (providers, +email, templates, managed pages), function visibility and schedulers, and +frontend custom domains. -This command group is designed for expanding configuration management over time. -Currently it supports: - - Storage buckets - - Storage policies - - Function visibility (public/private)`, +The server owns validation and reconciliation; the CLI uploads and downloads +the manifest.`, } cmd.AddCommand(newDeploy(deps)) + cmd.AddCommand(newPull(deps)) return cmd } func newDeploy(deps cliruntime.Deps) *cobra.Command { var file string + var dryRun bool cmd := &cobra.Command{ Use: "deploy", Short: "Deploy project configuration from YAML", - Long: `Deploy project configuration from a declarative YAML file. + Long: `Deploy project configuration from a declarative YAML manifest. + +The manifest is uploaded in a single request; the server validates everything +first (nothing is applied on validation failure) and then reconciles each +declared section. Declared config sections are the source of truth: variables, +bucket policies, OAuth providers, email templates, and function schedulers are +fully synced, so entries absent from the manifest are deleted. Omitted +sections and fields are left untouched. Functions, frontends, databases, and +buckets are never created or deleted through the manifest. -Currently supported resources: - - Storage buckets - - Storage policies - - Function visibility (public/private) +${ENV_VAR} references are interpolated from the CLI environment before upload; +a reference to an unset variable is an error. Use $$ for a literal $. -Reconciliation semantics: - - Bucket attributes (file_size_limit, allowed_mime_types) merge: omitted - fields leave the existing server value untouched. - - Storage policies are reconciled exhaustively: any policy on the server - that is not present in the manifest's bucket "policies:" list is DELETED. - Omitting "policies:" entirely (or setting it to []) deletes every policy - on that bucket. +Use --dry-run to print the projected actions (including skipped/missing +warnings and operational notices) without changing anything — for example to +validate a manifest in CI. If --file is omitted, the CLI looks for (in order): 1. volcano/volcano-config.yaml (recommended) 2. ./volcano-config.yaml (project root)`, Example: fmt.Sprintf(` %s + %s %s`, cliruntime.CommandPath(deps, "config deploy"), - cliruntime.CommandPath(deps, "config deploy -f volcano-config.yaml")), + cliruntime.CommandPath(deps, "config deploy -f volcano-config.yaml"), + cliruntime.CommandPath(deps, "config deploy --dry-run")), Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return runDeploy(cmd.Context(), deployOptions{ - deps: deps, - file: file, - out: cmd.OutOrStdout(), + deps: deps, + file: file, + dryRun: dryRun, + out: cmd.OutOrStdout(), }) }, } cmd.Flags().StringVarP(&file, "file", "f", "", "Path to configuration YAML file (default: volcano/volcano-config.yaml or ./volcano-config.yaml)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Validate and print projected actions without applying changes") + return cmd +} + +func newPull(deps cliruntime.Deps) *cobra.Command { + var file string + var force bool + cmd := &cobra.Command{ + Use: "pull", + Short: "Download the current project configuration as YAML", + Long: `Download the project's current configuration as a canonical +volcano-config.yaml rendered by the server. + +Write-only secrets (SMTP password, OAuth client secrets, custom domain TLS +material) are omitted from the export; set them via ${ENV_VAR} interpolation +before deploying. Variable values are included. + +Without --file the manifest is written to an existing manifest location, or +volcano/volcano-config.yaml when the volcano directory exists, else +./volcano-config.yaml. An existing file is not overwritten unless --force is +given.`, + Example: fmt.Sprintf(` %s + %s`, + cliruntime.CommandPath(deps, "config pull"), + cliruntime.CommandPath(deps, "config pull -f volcano-config.yaml --force")), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runPull(cmd.Context(), pullOptions{ + deps: deps, + file: file, + force: force, + out: cmd.OutOrStdout(), + }) + }, + } + cmd.Flags().StringVarP(&file, "file", "f", "", "Path to write the configuration YAML file to (default: volcano/volcano-config.yaml or ./volcano-config.yaml)") + cmd.Flags().BoolVar(&force, "force", false, "Overwrite the target file if it already exists") return cmd } @@ -88,15 +150,102 @@ func runDeploy(ctx context.Context, opts deployOptions) error { return err } - summary, err := projectconfig.NewService(opts.deps).Deploy(ctx, manifest) + result, err := projectconfig.NewService(opts.deps).Deploy(ctx, manifest, opts.dryRun) if err != nil { - if summary != nil { - output.ProjectConfigDeploySummary(opts.out, summary) + var validationErr *api.ProjectConfigValidationError + if errors.As(err, &validationErr) { + output.ProjectConfigValidationErrors(opts.out, validationErr.Errors) + return validationErr + } + if isConfigEndpointMissing(err) { + return errors.New("this server does not support declarative config apply; upgrade your local-mode server image and try again") } return fmt.Errorf("failed to deploy configuration from %s: %w", manifestPath, err) } - output.Success(opts.out, "Configuration deployed from %s", filepath.Base(resolvedPath)) - output.ProjectConfigDeploySummary(opts.out, summary) + if opts.dryRun { + fmt.Fprintln(opts.out, "Dry run: projected actions, nothing was applied.") + } else { + output.Success(opts.out, "Configuration deployed from %s", filepath.Base(resolvedPath)) + } + output.ProjectConfigApplyReport(opts.out, result) + + if result != nil && result.Summary.Errors > 0 { + return fmt.Errorf("%d configuration change(s) failed to apply; see the report above", result.Summary.Errors) + } return nil } + +func runPull(ctx context.Context, opts pullOptions) error { + targetPath := strings.TrimSpace(opts.file) + if targetPath == "" { + targetPath = projectconfig.DefaultPullPath() + } + + if !opts.force { + if _, err := os.Stat(targetPath); err == nil { + return fmt.Errorf("refusing to overwrite existing file %s (use --force to replace it)", targetPath) + } + } + + manifest, err := projectconfig.NewService(opts.deps).Pull(ctx) + if err != nil { + if isConfigEndpointMissing(err) { + return errors.New("this server does not support declarative config export; upgrade your local-mode server image and try again") + } + return fmt.Errorf("failed to download configuration: %w", err) + } + + if dir := filepath.Dir(targetPath); dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + } + if err := writePulledManifest(targetPath, manifest); err != nil { + return err + } + + output.Success(opts.out, "Configuration written to %s", targetPath) + output.Note(opts.out, "write-only secrets (SMTP password, OAuth client secrets, TLS material) are omitted; set them via ${ENV_VAR} interpolation before deploying") + return nil +} + +// writePulledManifest writes the pulled manifest owner-only (pulledManifestMode) +// even when overwriting an existing file. os.WriteFile only applies its mode +// when it creates the file, so a --force overwrite of a pre-existing 0644 +// manifest would keep the looser mode and leave the exported variable values +// readable by other local users. Writing a fresh 0600 temp file in the target +// directory and renaming it into place makes the write atomic and guarantees +// the owner-only mode regardless of any pre-existing file. +func writePulledManifest(targetPath string, manifest []byte) error { + tmp, err := os.CreateTemp(filepath.Dir(targetPath), ".volcano-config-*.tmp") + if err != nil { + return fmt.Errorf("failed to create temporary configuration file: %w", err) + } + tmpPath := tmp.Name() + // Best-effort cleanup of the temp file if we fail before the rename lands. + defer func() { _ = os.Remove(tmpPath) }() + + if err := tmp.Chmod(pulledManifestMode); err != nil { + _ = tmp.Close() + return fmt.Errorf("failed to secure temporary configuration file %s: %w", tmpPath, err) + } + if _, err := tmp.Write(manifest); err != nil { + _ = tmp.Close() + return fmt.Errorf("failed to write configuration to %s: %w", tmpPath, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("failed to finalize configuration file %s: %w", tmpPath, err) + } + if err := os.Rename(tmpPath, targetPath); err != nil { + return fmt.Errorf("failed to write configuration to %s: %w", targetPath, err) + } + return nil +} + +// isConfigEndpointMissing detects a 404 from a server without the config +// routes (an older local-mode image): the router's plain "404 page not found" +// body rather than a JSON project-not-found error. +func isConfigEndpointMissing(err error) bool { + return api.Status(err) == 404 && strings.Contains(err.Error(), "page not found") +} diff --git a/internal/cmd/config/config_test.go b/internal/cmd/config/config_test.go index ab9d072..400cfc9 100644 --- a/internal/cmd/config/config_test.go +++ b/internal/cmd/config/config_test.go @@ -18,11 +18,8 @@ import ( ) const ( - configProjectID = "55555555-5555-4555-8555-555555555555" - configBucketID = "66666666-6666-4666-8666-666666666666" - configFunctionID = "77777777-7777-4777-8777-777777777777" - configPolicyID = "88888888-8888-4888-8888-888888888888" - storageBucketsURL = "/projects/" + configProjectID + "/storage/buckets" + configProjectID = "55555555-5555-4555-8555-555555555555" + projectConfigURL = "/projects/" + configProjectID + "/config" ) func executeConfigCommand(t *testing.T, cmd *cobra.Command, args ...string) (string, error) { @@ -74,108 +71,214 @@ func chdirToTemp(t *testing.T) string { return resolved } -func TestDeployCreatesBucketPolicyAndFlipsFunction(t *testing.T) { +func applyResultResponse() map[string]any { + return map[string]any{ + "results": []any{ + map[string]any{"section": "variables", "name": "API_KEY", "action": "created"}, + map[string]any{"section": "variables", "name": "STALE", "action": "deleted"}, + map[string]any{"section": "realtime", "action": "updated", "notice": "disabling realtime drops active connections"}, + map[string]any{"section": "databases", "name": "appdb", "action": "unchanged"}, + }, + "skipped": []any{ + map[string]any{"type": "function", "name": "hello", "reason": "not deployed"}, + }, + "missing": []any{ + map[string]any{"type": "frontend", "name": "web"}, + }, + "summary": map[string]any{ + "created": 1, "updated": 1, "deleted": 1, "unchanged": 1, + "errors": 0, "skipped": 1, "missing": 1, + }, + } +} + +func TestDeployUploadsManifestAndRendersReport(t *testing.T) { dir := chdirToTemp(t) setConfigCommandTestHome(t) saveConfigCommandTestConfig(t) + t.Setenv("DEPLOY_TEST_SECRET", "interpolated-value") - manifestPath := filepath.Join(dir, "volcano-config.yaml") - require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 -buckets: - - name: uploads - file_size_limit: 2048 - allowed_mime_types: - - image/png - policies: - - name: owner - operation: select - definition: "auth.uid() = owner_id" -functions: - - name: hello - public: true + require.NoError(t, os.WriteFile(filepath.Join(dir, "volcano-config.yaml"), []byte(`version: 1 +variables: + - name: API_KEY + value: ${DEPLOY_TEST_SECRET} +realtime: + enabled: false +databases: + - name: appdb + region: aws-us-east-1 + pg_version: "16" `), 0o644)) - var createBucketBody map[string]any - var createPolicyBody map[string]any - var visibilityBody map[string]bool - functionPublic := false - + var method, query string + var uploaded map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == storageBucketsURL+"/uploads": - http.NotFound(w, r) - case r.Method == http.MethodPost && r.URL.Path == storageBucketsURL: - require.NoError(t, json.NewDecoder(r.Body).Decode(&createBucketBody)) - writeConfigCommandJSON(t, w, http.StatusCreated, map[string]any{ - "id": configBucketID, - "name": "uploads", - "project_id": configProjectID, - "file_size_limit": 2048, - "created_at": "2026-05-20T00:00:00Z", - "updated_at": "2026-05-20T00:00:00Z", - }) - case r.Method == http.MethodGet && r.URL.Path == storageBucketsURL+"/uploads/policies": - writeConfigCommandJSON(t, w, http.StatusOK, []any{}) - case r.Method == http.MethodPost && r.URL.Path == storageBucketsURL+"/uploads/policies": - require.NoError(t, json.NewDecoder(r.Body).Decode(&createPolicyBody)) - writeConfigCommandJSON(t, w, http.StatusCreated, map[string]any{ - "id": configPolicyID, - "bucket_id": configBucketID, - "name": "owner", - "operation": "SELECT", - "definition": "auth.uid() = owner_id", - }) - case r.Method == http.MethodGet && r.URL.Path == "/projects/"+configProjectID+"/functions": - writeConfigCommandJSON(t, w, http.StatusOK, map[string]any{ - "data": []any{map[string]any{ - "id": configFunctionID, - "name": "hello", - "project_id": configProjectID, - "deployed_regions": []string{"aws-us-east-1"}, - "is_public": functionPublic, - "runtime": "nodejs24.x", - "status": "active", - "created_at": "2026-05-20T00:00:00Z", - "updated_at": "2026-05-20T00:00:00Z", - }}, - "has_more": false, - "page": 1, - "limit": 100, - "total": 1, - }) - case r.Method == http.MethodPatch && r.URL.Path == "/projects/"+configProjectID+"/functions/"+configFunctionID: - require.NoError(t, json.NewDecoder(r.Body).Decode(&visibilityBody)) - functionPublic = visibilityBody["is_public"] - writeConfigCommandJSON(t, w, http.StatusOK, map[string]any{ - "id": configFunctionID, - "name": "hello", - "project_id": configProjectID, - "deployed_regions": []string{"aws-us-east-1"}, - "is_public": functionPublic, - "runtime": "nodejs24.x", - "status": "active", - "created_at": "2026-05-20T00:00:00Z", - "updated_at": "2026-05-20T00:00:00Z", - }) - default: + if r.URL.Path != projectConfigURL { http.NotFound(w, r) + return } + method = r.Method + query = r.URL.RawQuery + require.NoError(t, json.NewDecoder(r.Body).Decode(&uploaded)) + writeConfigCommandJSON(t, w, http.StatusOK, applyResultResponse()) })) defer server.Close() out, err := executeConfigCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "deploy") require.NoError(t, err) + assert.Equal(t, http.MethodPut, method) + assert.Empty(t, query) + assert.EqualValues(t, 1, uploaded["version"]) + variables, ok := uploaded["variables"].([]any) + require.True(t, ok) + variable, ok := variables[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "interpolated-value", variable["value"]) + realtime, ok := uploaded["realtime"].(map[string]any) + require.True(t, ok) + assert.Equal(t, false, realtime["enabled"]) + assert.Contains(t, out, "Configuration deployed from volcano-config.yaml") - assert.Contains(t, out, "Buckets: 1 created, 0 updated, 0 unchanged") - assert.Contains(t, out, "Policies: 1 created, 0 updated, 0 deleted, 0 unchanged") - assert.Contains(t, out, "Functions: 1 updated, 0 unchanged") + assert.Contains(t, out, "variables: 1 created, 1 deleted") + assert.Contains(t, out, "realtime: 1 updated") + assert.Contains(t, out, "databases: 1 unchanged") + assert.Contains(t, out, "Summary: 1 created, 1 updated, 1 deleted, 1 unchanged") + assert.Contains(t, out, "Note: realtime: disabling realtime drops active connections") + assert.Contains(t, out, `Warning: function "hello" is declared in the manifest but not deployed`) + assert.Contains(t, out, `Warning: frontend "web" exists but is not covered by your manifest`) +} + +func TestDeployDryRunSendsQueryParamAndPrefixesOutput(t *testing.T) { + dir := chdirToTemp(t) + setConfigCommandTestHome(t) + saveConfigCommandTestConfig(t) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "volcano-config.yaml"), []byte("version: 1\nrealtime:\n enabled: true\n"), 0o644)) + + var query string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query = r.URL.RawQuery + response := applyResultResponse() + response["dry_run"] = true + writeConfigCommandJSON(t, w, http.StatusOK, response) + })) + defer server.Close() + + out, err := executeConfigCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "deploy", "--dry-run") + require.NoError(t, err) + assert.Equal(t, "dry_run=true", query) + assert.Contains(t, out, "Dry run: projected actions, nothing was applied.") + assert.NotContains(t, out, "Configuration deployed from") +} + +func TestDeployValidationFailureRendersErrorListAndExitsNonZero(t *testing.T) { + dir := chdirToTemp(t) + setConfigCommandTestHome(t) + saveConfigCommandTestConfig(t) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "volcano-config.yaml"), []byte("version: 1\nrealtime:\n enabled: true\n"), 0o644)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeConfigCommandJSON(t, w, http.StatusUnprocessableEntity, map[string]any{ + "error": "configuration validation failed", + "errors": []any{ + map[string]any{"section": "databases", "name": "appdb", "message": "tier changes are not supported via config"}, + map[string]any{"section": "functions.schedulers", "name": "nightly", "message": "invalid cron expression"}, + }, + }) + })) + defer server.Close() + + out, err := executeConfigCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "deploy") + require.Error(t, err) + assert.Contains(t, err.Error(), "validation failed with 2 error(s)") + assert.Contains(t, out, `databases "appdb": tier changes are not supported via config`) + assert.Contains(t, out, `functions.schedulers "nightly": invalid cron expression`) + assert.NotContains(t, out, "Configuration deployed from") +} + +func TestDeployApplyErrorsExitNonZero(t *testing.T) { + dir := chdirToTemp(t) + setConfigCommandTestHome(t) + saveConfigCommandTestConfig(t) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "volcano-config.yaml"), []byte("version: 1\nrealtime:\n enabled: true\n"), 0o644)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeConfigCommandJSON(t, w, http.StatusOK, map[string]any{ + "results": []any{ + map[string]any{"section": "realtime", "action": "error", "error": "provider unavailable"}, + }, + "skipped": []any{}, + "missing": []any{}, + "summary": map[string]any{ + "created": 0, "updated": 0, "deleted": 0, "unchanged": 0, + "errors": 1, "skipped": 0, "missing": 0, + }, + }) + })) + defer server.Close() + + out, err := executeConfigCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "deploy") + require.Error(t, err) + assert.Contains(t, err.Error(), "1 configuration change(s) failed to apply") + assert.Contains(t, out, "Error: realtime: provider unavailable") +} + +func TestDeployMissingEnvVarFailsBeforeUpload(t *testing.T) { + dir := chdirToTemp(t) + setConfigCommandTestHome(t) + saveConfigCommandTestConfig(t) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "volcano-config.yaml"), []byte("version: 1\nvariables:\n - name: A\n value: ${DEFINITELY_NOT_SET_VAR}\n"), 0o644)) + + requestSeen := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestSeen = true + http.NotFound(w, r) + })) + defer server.Close() + + _, err := executeConfigCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "deploy") + require.Error(t, err) + assert.Contains(t, err.Error(), `environment variable "DEFINITELY_NOT_SET_VAR" is not set`) + assert.False(t, requestSeen) +} + +func TestDeployConcurrentApplyConflict(t *testing.T) { + dir := chdirToTemp(t) + setConfigCommandTestHome(t) + saveConfigCommandTestConfig(t) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "volcano-config.yaml"), []byte("version: 1\nrealtime:\n enabled: true\n"), 0o644)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeConfigCommandJSON(t, w, http.StatusConflict, map[string]any{ + "error": "another config apply is already in progress for this project", + }) + })) + defer server.Close() + + _, err := executeConfigCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "deploy") + require.Error(t, err) + assert.Contains(t, err.Error(), "already in progress") +} + +func TestDeployOldServerWithoutConfigEndpoint(t *testing.T) { + dir := chdirToTemp(t) + setConfigCommandTestHome(t) + saveConfigCommandTestConfig(t) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "volcano-config.yaml"), []byte("version: 1\nrealtime:\n enabled: true\n"), 0o644)) - assert.Equal(t, "uploads", createBucketBody["name"]) - assert.EqualValues(t, 2048, createBucketBody["file_size_limit"]) - assert.Equal(t, "owner", createPolicyBody["name"]) - assert.Equal(t, "SELECT", createPolicyBody["operation"]) - assert.True(t, visibilityBody["is_public"]) + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + _, err := executeConfigCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "deploy") + require.Error(t, err) + assert.Contains(t, err.Error(), "does not support declarative config apply") + assert.Contains(t, err.Error(), "upgrade") } func TestDeployMissingManifest(t *testing.T) { @@ -196,43 +299,167 @@ func TestDeployExplicitFile(t *testing.T) { customPath := filepath.Join(dir, "config", "custom.yaml") require.NoError(t, os.MkdirAll(filepath.Dir(customPath), 0o755)) - require.NoError(t, os.WriteFile(customPath, []byte(`version: 1 -buckets: - - name: uploads + require.NoError(t, os.WriteFile(customPath, []byte("version: 1\nrealtime:\n enabled: true\n"), 0o644)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeConfigCommandJSON(t, w, http.StatusOK, map[string]any{ + "results": []any{map[string]any{"section": "realtime", "action": "unchanged"}}, + "skipped": []any{}, + "missing": []any{}, + "summary": map[string]any{ + "created": 0, "updated": 0, "deleted": 0, "unchanged": 1, + "errors": 0, "skipped": 0, "missing": 0, + }, + }) + })) + defer server.Close() + + out, err := executeConfigCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "deploy", "-f", filepath.Join("config", "custom.yaml")) + require.NoError(t, err) + assert.Contains(t, out, "Configuration deployed from custom.yaml") + assert.Contains(t, out, "realtime: 1 unchanged") +} + +func TestDeployRejectedSchedulerRegions(t *testing.T) { + dir := chdirToTemp(t) + setConfigCommandTestHome(t) + saveConfigCommandTestConfig(t) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "volcano-config.yaml"), []byte(`version: 1 +functions: + - name: hello + schedulers: + - name: nightly + cron: "0 3 * * *" + regions: [aws-us-east-1] `), 0o644)) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == storageBucketsURL+"/uploads": - writeConfigCommandJSON(t, w, http.StatusOK, map[string]any{ - "id": configBucketID, - "name": "uploads", - "project_id": configProjectID, - "created_at": "2026-05-20T00:00:00Z", - "updated_at": "2026-05-20T00:00:00Z", - }) - case r.Method == http.MethodGet && r.URL.Path == storageBucketsURL+"/uploads/policies": - writeConfigCommandJSON(t, w, http.StatusOK, []any{}) - default: + _, err := executeConfigCommand(t, New(cliruntime.Deps{}), "deploy") + require.Error(t, err) + assert.Contains(t, err.Error(), "scheduler placement is managed by the server") +} + +const pulledYAML = `# volcano-config.yaml (manifest version 1) +version: 1 +variables: + - name: API_KEY + value: secret-value +` + +// requirePulledYAMLVerbatim asserts byte-for-byte fidelity: pull must save +// the server-rendered manifest exactly, including comments and ordering. +func requirePulledYAMLVerbatim(t *testing.T, path string) { + t.Helper() + written, err := os.ReadFile(path) + require.NoError(t, err) + require.True(t, bytes.Equal([]byte(pulledYAML), written), "pulled manifest was not saved verbatim:\n%s", string(written)) +} + +func newPullTestServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != projectConfigURL { http.NotFound(w, r) + return } + assert.Equal(t, "yaml", r.URL.Query().Get("format")) + w.Header().Set("Content-Type", "application/yaml") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(pulledYAML)) })) +} + +func TestPullWritesServerYAMLVerbatim(t *testing.T) { + dir := chdirToTemp(t) + setConfigCommandTestHome(t) + saveConfigCommandTestConfig(t) + + server := newPullTestServer(t) defer server.Close() - out, err := executeConfigCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "deploy", "-f", filepath.Join("config", "custom.yaml")) + out, err := executeConfigCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "pull") require.NoError(t, err) - assert.Contains(t, out, "Configuration deployed from custom.yaml") - assert.Contains(t, out, "Buckets: 0 created, 0 updated, 1 unchanged") + assert.Contains(t, out, "Configuration written to volcano-config.yaml") + assert.Contains(t, out, "write-only secrets") + + requirePulledYAMLVerbatim(t, filepath.Join(dir, "volcano-config.yaml")) + + info, err := os.Stat(filepath.Join(dir, "volcano-config.yaml")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) } -func TestDeployInvalidManifest(t *testing.T) { +func TestPullPrefersVolcanoDirectory(t *testing.T) { + dir := chdirToTemp(t) + setConfigCommandTestHome(t) + saveConfigCommandTestConfig(t) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "volcano"), 0o755)) + + server := newPullTestServer(t) + defer server.Close() + + out, err := executeConfigCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "pull") + require.NoError(t, err) + assert.Contains(t, out, filepath.Join("volcano", "volcano-config.yaml")) + + _, err = os.Stat(filepath.Join(dir, "volcano", "volcano-config.yaml")) + require.NoError(t, err) +} + +func TestPullRefusesToOverwriteWithoutForce(t *testing.T) { dir := chdirToTemp(t) setConfigCommandTestHome(t) saveConfigCommandTestConfig(t) require.NoError(t, os.WriteFile(filepath.Join(dir, "volcano-config.yaml"), []byte("version: 1\n"), 0o644)) - out, err := executeConfigCommand(t, New(cliruntime.Deps{}), "deploy") + server := newPullTestServer(t) + defer server.Close() + + deps := cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL} + + _, err := executeConfigCommand(t, New(deps), "pull") require.Error(t, err) - assert.Contains(t, err.Error(), "must include at least one bucket or function") - assert.NotContains(t, out, "Configuration deployed from") + assert.Contains(t, err.Error(), "refusing to overwrite existing file") + assert.Contains(t, err.Error(), "--force") + + out, err := executeConfigCommand(t, New(deps), "pull", "--force") + require.NoError(t, err) + assert.Contains(t, out, "Configuration written to volcano-config.yaml") + + requirePulledYAMLVerbatim(t, filepath.Join(dir, "volcano-config.yaml")) + + // Forcing over a pre-existing 0644 manifest must still restrict it to + // owner-only: pulled manifests include variable values. + info, err := os.Stat(filepath.Join(dir, "volcano-config.yaml")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestPullExplicitFileCreatesDirectory(t *testing.T) { + dir := chdirToTemp(t) + setConfigCommandTestHome(t) + saveConfigCommandTestConfig(t) + + server := newPullTestServer(t) + defer server.Close() + + target := filepath.Join("nested", "dir", "volcano-config.yaml") + out, err := executeConfigCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "pull", "-f", target) + require.NoError(t, err) + assert.Contains(t, out, "Configuration written to "+target) + + requirePulledYAMLVerbatim(t, filepath.Join(dir, target)) +} + +func TestPullOldServerWithoutConfigEndpoint(t *testing.T) { + chdirToTemp(t) + setConfigCommandTestHome(t) + saveConfigCommandTestConfig(t) + + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + _, err := executeConfigCommand(t, New(cliruntime.Deps{HTTPClient: server.Client(), APIBaseURL: server.URL}), "pull") + require.Error(t, err) + assert.Contains(t, err.Error(), "does not support declarative config export") } diff --git a/internal/cmd/frontends/logs.go b/internal/cmd/frontends/logs.go index 33fb284..ca45595 100644 --- a/internal/cmd/frontends/logs.go +++ b/internal/cmd/frontends/logs.go @@ -152,7 +152,7 @@ func followDeploymentLogs(ctx context.Context, opts frontendLogsOptions, service }) } -func frontendDeploymentTerminal(deployment *apiclient.FrontendDeployment) bool { +func frontendDeploymentTerminal(deployment *apicommon.FrontendDeployment) bool { if deployment == nil { return false } diff --git a/internal/cmd/functions/deploy.go b/internal/cmd/functions/deploy.go index 09d0423..b06bb2e 100644 --- a/internal/cmd/functions/deploy.go +++ b/internal/cmd/functions/deploy.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/Kong/volcano-cli/internal/apiclient" + apicommon "github.com/Kong/volcano-cli/internal/apiclient/common" "github.com/Kong/volcano-cli/internal/archive" clifunction "github.com/Kong/volcano-cli/internal/function" "github.com/Kong/volcano-cli/internal/output" @@ -258,7 +259,7 @@ func formatSourceNames(sources []clifunction.SourceInfo) string { return strings.Join(names, ", ") } -func batchFailures(resp *apiclient.BatchFunctionDeployResponse) []apiclient.BatchFunctionDeployFailure { +func batchFailures(resp *apiclient.BatchFunctionDeployResponse) []apicommon.BatchFunctionDeployFailure { if resp == nil || resp.Failed == nil { return nil } diff --git a/internal/cmd/functions/logs.go b/internal/cmd/functions/logs.go index 2a94318..0a52653 100644 --- a/internal/cmd/functions/logs.go +++ b/internal/cmd/functions/logs.go @@ -143,7 +143,7 @@ func followDeploymentLogs(ctx context.Context, opts logsOptions, service clifunc }) } -func functionDeploymentTerminal(deployment *apiclient.FunctionDeployment) bool { +func functionDeploymentTerminal(deployment *apicommon.FunctionDeployment) bool { if deployment == nil { return false } diff --git a/internal/frontend/frontend.go b/internal/frontend/frontend.go index f47cab8..b2cb8b5 100644 --- a/internal/frontend/frontend.go +++ b/internal/frontend/frontend.go @@ -12,6 +12,7 @@ import ( "github.com/Kong/volcano-cli/internal/api" "github.com/Kong/volcano-cli/internal/apiclient" + apicommon "github.com/Kong/volcano-cli/internal/apiclient/common" cliruntime "github.com/Kong/volcano-cli/internal/runtime" clisession "github.com/Kong/volcano-cli/internal/session" ) @@ -125,7 +126,7 @@ func (s Service) Redeploy(ctx context.Context, identifier string) (*apiclient.Fr } // ResolveDeployment returns a frontend deployment by ID, paging internally. -func (s Service) ResolveDeployment(ctx context.Context, frontendID uuid.UUID, deploymentID string) (*apiclient.FrontendDeployment, error) { +func (s Service) ResolveDeployment(ctx context.Context, frontendID uuid.UUID, deploymentID string) (*apicommon.FrontendDeployment, error) { authenticated, err := s.sessions.CurrentProject() if err != nil { return nil, err @@ -167,7 +168,7 @@ func (s Service) ResolveDeployment(ctx context.Context, frontendID uuid.UUID, de } // LatestDeployment returns the first deployment on the default deployment page. -func (s Service) LatestDeployment(ctx context.Context, frontendID uuid.UUID) (*apiclient.FrontendDeployment, error) { +func (s Service) LatestDeployment(ctx context.Context, frontendID uuid.UUID) (*apicommon.FrontendDeployment, error) { authenticated, err := s.sessions.CurrentProject() if err != nil { return nil, err diff --git a/internal/function/function.go b/internal/function/function.go index 394d520..b8d9a08 100644 --- a/internal/function/function.go +++ b/internal/function/function.go @@ -13,6 +13,7 @@ import ( "github.com/Kong/volcano-cli/internal/api" "github.com/Kong/volcano-cli/internal/apiclient" + apicommon "github.com/Kong/volcano-cli/internal/apiclient/common" cliconfig "github.com/Kong/volcano-cli/internal/config" cliruntime "github.com/Kong/volcano-cli/internal/runtime" clisession "github.com/Kong/volcano-cli/internal/session" @@ -67,7 +68,7 @@ func (s Service) ListPage(ctx context.Context, page, limit int) (*apiclient.Pagi } // ListRuntimes returns the function runtime catalog. -func (s Service) ListRuntimes(ctx context.Context) ([]apiclient.FunctionRuntimeOption, error) { +func (s Service) ListRuntimes(ctx context.Context) ([]apicommon.FunctionRuntimeOption, error) { cfg, err := s.sessions.Config() if err != nil { return nil, err @@ -464,7 +465,7 @@ func (s Service) DeleteScheduler(ctx context.Context, identifier string, schedul } // ResolveDeployment returns a deployment by ID, paging internally. -func (s Service) ResolveDeployment(ctx context.Context, functionID uuid.UUID, deploymentID string) (*apiclient.FunctionDeployment, error) { +func (s Service) ResolveDeployment(ctx context.Context, functionID uuid.UUID, deploymentID string) (*apicommon.FunctionDeployment, error) { authenticated, err := s.sessions.CurrentProject() if err != nil { return nil, err diff --git a/internal/function/packager_test.go b/internal/function/packager_test.go index 89f842c..11dcbb7 100644 --- a/internal/function/packager_test.go +++ b/internal/function/packager_test.go @@ -12,13 +12,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/Kong/volcano-cli/internal/apiclient" + "github.com/Kong/volcano-cli/internal/apiclient/common" ) func TestPackageSourceRenamesSingleFilesToStandardEntrypoints(t *testing.T) { for _, tc := range []struct { name string - runtime apiclient.FunctionRuntimeOption + runtime common.FunctionRuntimeOption filename string wantEntry string sourceCode string diff --git a/internal/function/scanner.go b/internal/function/scanner.go index a693366..a99e70e 100644 --- a/internal/function/scanner.go +++ b/internal/function/scanner.go @@ -7,20 +7,20 @@ import ( "sort" "strings" - "github.com/Kong/volcano-cli/internal/apiclient" + apicommon "github.com/Kong/volcano-cli/internal/apiclient/common" ) // RuntimeCatalog indexes API-provided default runtime metadata for local source detection. type RuntimeCatalog struct { - byExtension map[string]apiclient.FunctionRuntimeOption - byEntrypoint map[string]apiclient.FunctionRuntimeOption + byExtension map[string]apicommon.FunctionRuntimeOption + byEntrypoint map[string]apicommon.FunctionRuntimeOption } // RuntimeCatalogFromOptions converts the API runtime catalog to scanner/package metadata. -func RuntimeCatalogFromOptions(options []apiclient.FunctionRuntimeOption) RuntimeCatalog { +func RuntimeCatalogFromOptions(options []apicommon.FunctionRuntimeOption) RuntimeCatalog { catalog := RuntimeCatalog{ - byExtension: map[string]apiclient.FunctionRuntimeOption{}, - byEntrypoint: map[string]apiclient.FunctionRuntimeOption{}, + byExtension: map[string]apicommon.FunctionRuntimeOption{}, + byEntrypoint: map[string]apicommon.FunctionRuntimeOption{}, } for _, option := range options { @@ -36,12 +36,12 @@ func RuntimeCatalogFromOptions(options []apiclient.FunctionRuntimeOption) Runtim return catalog } -func (c RuntimeCatalog) runtimeForFile(filename string) (apiclient.FunctionRuntimeOption, bool) { +func (c RuntimeCatalog) runtimeForFile(filename string) (apicommon.FunctionRuntimeOption, bool) { runtime, ok := c.byExtension[filepath.Ext(filename)] return runtime, ok } -func (c RuntimeCatalog) runtimeForEntrypoint(filename string) (apiclient.FunctionRuntimeOption, bool) { +func (c RuntimeCatalog) runtimeForEntrypoint(filename string) (apicommon.FunctionRuntimeOption, bool) { runtime, ok := c.byEntrypoint[filename] return runtime, ok } @@ -50,7 +50,7 @@ func (c RuntimeCatalog) runtimeForEntrypoint(filename string) (apiclient.Functio type SourceInfo struct { Path string Name string - Runtime apiclient.FunctionRuntimeOption + Runtime apicommon.FunctionRuntimeOption IsDir bool } @@ -101,10 +101,10 @@ func ScanSources(baseDir string, catalog RuntimeCatalog) ([]SourceInfo, error) { return functions, nil } -func detectDirectoryRuntime(dirPath string, catalog RuntimeCatalog) (apiclient.FunctionRuntimeOption, string, bool) { +func detectDirectoryRuntime(dirPath string, catalog RuntimeCatalog) (apicommon.FunctionRuntimeOption, string, bool) { entries, err := os.ReadDir(dirPath) if err != nil { - return apiclient.FunctionRuntimeOption{}, "", false + return apicommon.FunctionRuntimeOption{}, "", false } for _, entry := range entries { @@ -119,7 +119,7 @@ func detectDirectoryRuntime(dirPath string, catalog RuntimeCatalog) (apiclient.F } } } - return apiclient.FunctionRuntimeOption{}, "", false + return apicommon.FunctionRuntimeOption{}, "", false } // SharedLibrary represents a shared function library file or directory. diff --git a/internal/function/scanner_test.go b/internal/function/scanner_test.go index 72250e4..72f2780 100644 --- a/internal/function/scanner_test.go +++ b/internal/function/scanner_test.go @@ -8,12 +8,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/Kong/volcano-cli/internal/apiclient" "github.com/Kong/volcano-cli/internal/apiclient/common" ) func TestRuntimeCatalogFromOptions(t *testing.T) { - catalog := RuntimeCatalogFromOptions([]apiclient.FunctionRuntimeOption{ + catalog := RuntimeCatalogFromOptions([]common.FunctionRuntimeOption{ testRuntimeOption("nodejs24.x", "nodejs", true, []string{".js", ".mjs"}, "index.js", "handler", []string{"package.json"}), testRuntimeOption("python3.12", "python", true, []string{".py"}, "main.py", "handler", []string{"requirements.txt"}), testRuntimeOption("ruby3.4", "ruby", true, []string{".rb"}, "main.rb", "handler", []string{"Gemfile", "Gemfile.lock"}), @@ -46,7 +45,7 @@ func TestScanSourcesUsesRuntimeCatalogMetadata(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(functionsDir, "api-fn", "server.jsx"), []byte(""), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(functionsDir, "_utils", "index.js"), []byte(""), 0o644)) - catalog := RuntimeCatalogFromOptions([]apiclient.FunctionRuntimeOption{ + catalog := RuntimeCatalogFromOptions([]common.FunctionRuntimeOption{ testRuntimeOption("nodejs24.x", "nodejs", true, []string{".js", ".mjs"}, "index.js", "handler", []string{"package.json"}), testRuntimeOption("python3.12", "python", true, []string{".py"}, "main.py", "handler", []string{"requirements.txt"}), testRuntimeOption("ruby3.4", "ruby", true, []string{".rb"}, "main.rb", "handler", []string{"Gemfile", "Gemfile.lock"}), @@ -57,7 +56,7 @@ func TestScanSourcesUsesRuntimeCatalogMetadata(t *testing.T) { require.NoError(t, err) var names []string - runtimes := map[string]apiclient.FunctionRuntimeOption{} + runtimes := map[string]common.FunctionRuntimeOption{} paths := map[string]string{} isDir := map[string]bool{} for _, source := range sources { @@ -134,15 +133,15 @@ func TestFindSharedLibrariesSkipsFunctionContents(t *testing.T) { func testRuntimeCatalog(t *testing.T) RuntimeCatalog { t.Helper() - return RuntimeCatalogFromOptions([]apiclient.FunctionRuntimeOption{ + return RuntimeCatalogFromOptions([]common.FunctionRuntimeOption{ testRuntimeOption("nodejs24.x", "nodejs", true, []string{".js", ".mjs"}, "index.js", "handler", []string{"package.json", "package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", ".yarnrc.yml"}), testRuntimeOption("python3.12", "python", true, []string{".py"}, "main.py", "handler", []string{"requirements.txt"}), testRuntimeOption("ruby3.4", "ruby", true, []string{".rb"}, "main.rb", "handler", []string{"Gemfile", "Gemfile.lock"}), }) } -func testRuntimeOption(name, language string, isDefault bool, fileExtensions []string, entrypoint, handler string, dependencyManifests []string) apiclient.FunctionRuntimeOption { - return apiclient.FunctionRuntimeOption{ +func testRuntimeOption(name, language string, isDefault bool, fileExtensions []string, entrypoint, handler string, dependencyManifests []string) common.FunctionRuntimeOption { + return common.FunctionRuntimeOption{ Name: name, Language: language, Default: isDefault, diff --git a/internal/output/functions.go b/internal/output/functions.go index 10d6f60..e9afd9d 100644 --- a/internal/output/functions.go +++ b/internal/output/functions.go @@ -7,6 +7,7 @@ import ( "time" "github.com/Kong/volcano-cli/internal/apiclient" + apicommon "github.com/Kong/volcano-cli/internal/apiclient/common" ) // Functions renders one function list page. @@ -77,7 +78,7 @@ func Function(w io.Writer, fn *apiclient.Function) { } // FunctionRuntimes renders function runtime options. -func FunctionRuntimes(w io.Writer, runtimes []apiclient.FunctionRuntimeOption) { +func FunctionRuntimes(w io.Writer, runtimes []apicommon.FunctionRuntimeOption) { if len(runtimes) == 0 { fmt.Fprintln(w, "No function runtimes found") return @@ -95,14 +96,14 @@ func FunctionRuntimes(w io.Writer, runtimes []apiclient.FunctionRuntimeOption) { } // LogEvents renders function log events. -func LogEvents(w io.Writer, events []apiclient.LogEvent) { +func LogEvents(w io.Writer, events []apicommon.LogEvent) { for _, event := range events { printLogEvent(w, event.Timestamp, event.Region, event.Message) } } // LogSearchEvents renders function log search events. -func LogSearchEvents(w io.Writer, events []apiclient.LogSearchEvent) { +func LogSearchEvents(w io.Writer, events []apicommon.LogSearchEvent) { for _, event := range events { printLogEvent(w, event.Timestamp, event.Region, event.Message) } diff --git a/internal/output/logs.go b/internal/output/logs.go index e86599a..1ac9f83 100644 --- a/internal/output/logs.go +++ b/internal/output/logs.go @@ -7,6 +7,7 @@ import ( "github.com/Kong/volcano-cli/internal/api" "github.com/Kong/volcano-cli/internal/apiclient" + apicommon "github.com/Kong/volcano-cli/internal/apiclient/common" ) // SearchLogsFetcher fetches one page of searched log events. An empty cursor requests the first page. @@ -41,11 +42,11 @@ func PrintSearchLogsSkipping(w io.Writer, fetch SearchLogsFetcher, skip map[stri } } -func filterSkippedLogSearchEvents(events []apiclient.LogSearchEvent, skip map[string]struct{}) []apiclient.LogSearchEvent { +func filterSkippedLogSearchEvents(events []apicommon.LogSearchEvent, skip map[string]struct{}) []apicommon.LogSearchEvent { if len(events) == 0 || len(skip) == 0 { return events } - filtered := make([]apiclient.LogSearchEvent, 0, len(events)) + filtered := make([]apicommon.LogSearchEvent, 0, len(events)) for _, event := range events { if _, ok := skip[event.Id]; ok && event.Id != "" { continue @@ -62,7 +63,7 @@ func PrintLogStreamEvent(w io.Writer, event *api.ProjectLogStreamEvent) { } switch { case event.Log != nil: - LogSearchEvents(w, []apiclient.LogSearchEvent{*event.Log}) + LogSearchEvents(w, []apicommon.LogSearchEvent{*event.Log}) case event.Warning != "": fmt.Fprintf(w, "Warning: %s\n", event.Warning) } diff --git a/internal/output/projectconfig.go b/internal/output/projectconfig.go index 4358e9f..f3afdb7 100644 --- a/internal/output/projectconfig.go +++ b/internal/output/projectconfig.go @@ -3,33 +3,128 @@ package output import ( "fmt" "io" + "strings" - "github.com/Kong/volcano-cli/internal/projectconfig" + apicommon "github.com/Kong/volcano-cli/internal/apiclient/common" ) -// ProjectConfigDeploySummary renders the counts from a `config deploy` run. -func ProjectConfigDeploySummary(w io.Writer, summary *projectconfig.Summary) { - if summary == nil { +const ( + configActionCreated = "created" + configActionUpdated = "updated" + configActionDeleted = "deleted" + configActionUnchanged = "unchanged" + configActionError = "error" +) + +// ProjectConfigApplyReport renders the server's config apply (or dry-run) +// report: per-section action counts, per-entry errors and notices, and +// prominent warnings for skipped and missing resources. +func ProjectConfigApplyReport(w io.Writer, result *apicommon.ProjectConfigApplyResult) { + if result == nil { + return + } + + printProjectConfigSectionCounts(w, result.Results) + + summary := result.Summary + fmt.Fprintf(w, "Summary: %d created, %d updated, %d deleted, %d unchanged\n", + summary.Created, summary.Updated, summary.Deleted, summary.Unchanged) + + for _, entry := range result.Results { + if entry.Action == configActionError { + detail := "" + if entry.Error != nil { + detail = *entry.Error + } + fmt.Fprintf(w, "Error: %s: %s\n", projectConfigEntryRef(entry), detail) + } + } + for _, entry := range result.Results { + if entry.Notice != nil && *entry.Notice != "" { + Note(w, "%s: %s", projectConfigEntryRef(entry), *entry.Notice) + } + } + + for _, skipped := range result.Skipped { + Warning(w, "%s %q is declared in the manifest but %s; its configuration was skipped — deploy or create it first, then re-run config deploy", + skipped.Type, skipped.Name, skipped.Reason) + } + for _, missing := range result.Missing { + Warning(w, "%s %q exists but is not covered by your manifest", missing.Type, missing.Name) + } +} + +func printProjectConfigSectionCounts(w io.Writer, results []apicommon.ProjectConfigApplyResultEntry) { + type sectionCounts struct { + created, updated, deleted, unchanged, errors int + } + order := make([]string, 0, len(results)) + bySection := make(map[string]*sectionCounts, len(results)) + for _, entry := range results { + counts, seen := bySection[entry.Section] + if !seen { + counts = §ionCounts{} + bySection[entry.Section] = counts + order = append(order, entry.Section) + } + switch string(entry.Action) { + case configActionCreated: + counts.created++ + case configActionUpdated: + counts.updated++ + case configActionDeleted: + counts.deleted++ + case configActionUnchanged: + counts.unchanged++ + case configActionError: + counts.errors++ + } + } + + for _, section := range order { + counts := bySection[section] + parts := make([]string, 0, 5) + if counts.created > 0 { + parts = append(parts, fmt.Sprintf("%d created", counts.created)) + } + if counts.updated > 0 { + parts = append(parts, fmt.Sprintf("%d updated", counts.updated)) + } + if counts.deleted > 0 { + parts = append(parts, fmt.Sprintf("%d deleted", counts.deleted)) + } + if counts.unchanged > 0 { + parts = append(parts, fmt.Sprintf("%d unchanged", counts.unchanged)) + } + if counts.errors > 0 { + parts = append(parts, fmt.Sprintf("%d failed", counts.errors)) + } + if len(parts) == 0 { + continue + } + fmt.Fprintf(w, "%s: %s\n", section, strings.Join(parts, ", ")) + } +} + +func projectConfigEntryRef(entry apicommon.ProjectConfigApplyResultEntry) string { + if entry.Name != nil && *entry.Name != "" { + return fmt.Sprintf("%s %q", entry.Section, *entry.Name) + } + return entry.Section +} + +// ProjectConfigValidationErrors renders the server's 422 validation error +// list, one line per entry. +func ProjectConfigValidationErrors(w io.Writer, errs []apicommon.ProjectConfigValidationError) { + if len(errs) == 0 { return } - fmt.Fprintf(w, "Buckets: %d created, %d updated, %d unchanged\n", - summary.BucketsCreated, - summary.BucketsUpdated, - summary.BucketsUnchanged, - ) - fmt.Fprintf(w, "Policies: %d created, %d updated, %d deleted, %d unchanged\n", - summary.PoliciesCreated, - summary.PoliciesUpdated, - summary.PoliciesDeleted, - summary.PoliciesUnchanged, - ) - fmt.Fprintf(w, "Functions: %d updated, %d unchanged\n", - summary.FunctionsUpdated, - summary.FunctionsUnchanged, - ) - fmt.Fprintf(w, "Schedulers: %d created, %d updated, %d unchanged\n", - summary.SchedulersCreated, - summary.SchedulersUpdated, - summary.SchedulersUnchanged, - ) + fmt.Fprintf(w, "The server rejected the configuration with %d validation error(s); nothing was applied:\n", len(errs)) + for _, entry := range errs { + ref := entry.Section + if entry.Name != nil && *entry.Name != "" { + ref = fmt.Sprintf("%s %q", entry.Section, *entry.Name) + } + fmt.Fprintf(w, " - %s: %s\n", ref, entry.Message) + } } diff --git a/internal/projectconfig/deploy.go b/internal/projectconfig/deploy.go deleted file mode 100644 index 3300fe6..0000000 --- a/internal/projectconfig/deploy.go +++ /dev/null @@ -1,496 +0,0 @@ -package projectconfig - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "reflect" - "sort" - "strings" - - "github.com/google/uuid" - - "github.com/Kong/volcano-cli/internal/api" - "github.com/Kong/volcano-cli/internal/apiclient" - apicommon "github.com/Kong/volcano-cli/internal/apiclient/common" - clifunction "github.com/Kong/volcano-cli/internal/function" - cliruntime "github.com/Kong/volcano-cli/internal/runtime" - clistorage "github.com/Kong/volcano-cli/internal/storage" -) - -// Summary describes what changed in one Deploy run. All counters are -// non-decreasing across reconciliation steps so callers can render a single -// "buckets: X created, Y updated, Z unchanged" line per resource type. -type Summary struct { - BucketsCreated int - BucketsUpdated int - BucketsUnchanged int - PoliciesCreated int - PoliciesUpdated int - PoliciesDeleted int - PoliciesUnchanged int - FunctionsUpdated int - FunctionsUnchanged int - SchedulersCreated int - SchedulersUpdated int - SchedulersUnchanged int -} - -// StorageReconciler is the subset of internal/storage.Service that Deploy -// drives. It is defined here so tests can substitute a fake implementation -// without standing up an authenticated session. -type StorageReconciler interface { - GetBucket(ctx context.Context, name string) (*apiclient.StorageBucket, error) - CreateBucket(ctx context.Context, input api.StorageBucketCreateInput) (*apiclient.StorageBucket, error) - UpdateBucket(ctx context.Context, name string, input api.StorageBucketUpdateInput) (*apiclient.StorageBucket, error) - ListPolicies(ctx context.Context, bucketName string) ([]apiclient.StoragePolicy, error) - CreatePolicy(ctx context.Context, bucketName string, input api.StoragePolicyCreateInput) (*apiclient.StoragePolicy, error) - DeletePolicy(ctx context.Context, bucketName, identifier string) (*apiclient.StoragePolicy, error) -} - -// FunctionReconciler is the subset of internal/function.Service used to update -// function visibility from a manifest. -type FunctionReconciler interface { - ListPage(ctx context.Context, page, limit int) (*apiclient.PaginatedFunctions, error) - UpdateVisibility(ctx context.Context, identifier string, isPublic bool) (*apiclient.Function, error) -} - -// SchedulerReconciler is the subset of internal/function.Service used to -// reconcile schedulers from a manifest. -type SchedulerReconciler interface { - ListSchedulers(ctx context.Context, identifier string) (*apiclient.Function, *apiclient.FunctionSchedulerListResponse, error) - CreateSchedulerByID(ctx context.Context, functionID uuid.UUID, input api.FunctionSchedulerInput) (*apiclient.FunctionScheduler, error) - UpdateSchedulerByID(ctx context.Context, functionID, schedulerID uuid.UUID, input api.FunctionSchedulerInput) (*apiclient.FunctionScheduler, error) -} - -// Service deploys declarative project configuration to the Volcano API. -type Service struct { - storage StorageReconciler - functions FunctionReconciler - schedulers SchedulerReconciler -} - -// NewService wires the projectconfig Service against the storage and function -// services. -func NewService(deps cliruntime.Deps) Service { - fnService := clifunction.NewService(deps) - return Service{ - storage: clistorage.NewService(deps), - functions: fnService, - schedulers: fnService, - } -} - -// NewServiceWithReconcilers returns a Service that uses the supplied -// reconcilers. Intended for tests. -func NewServiceWithReconcilers(storage StorageReconciler, functions FunctionReconciler, schedulers SchedulerReconciler) Service { - return Service{storage: storage, functions: functions, schedulers: schedulers} -} - -// Deploy reconciles the project state to match the manifest. Buckets and their -// policies are processed in manifest order; function visibility updates run -// after storage so an operator can see partial progress in the summary even -// when the storage phase fails. -func (s Service) Deploy(ctx context.Context, manifest *Manifest) (*Summary, error) { - if manifest == nil { - return nil, errors.New("manifest is required") - } - - summary := &Summary{} - for _, bucket := range manifest.Buckets { - if err := s.reconcileBucket(ctx, bucket, summary); err != nil { - return summary, err - } - if err := s.reconcilePolicies(ctx, bucket, summary); err != nil { - return summary, err - } - } - - if err := s.reconcileFunctions(ctx, manifest.Functions, summary); err != nil { - return summary, err - } - - if err := s.reconcileSchedulers(ctx, manifest.Functions, summary); err != nil { - return summary, err - } - return summary, nil -} - -func (s Service) reconcileBucket(ctx context.Context, bucket BucketManifest, summary *Summary) error { - existing, err := s.storage.GetBucket(ctx, bucket.Name) - if err != nil { - if api.Status(err) == http.StatusNotFound { - input := api.StorageBucketCreateInput{ - Name: bucket.Name, - FileSizeLimit: bucket.FileSizeLimit, - } - if bucket.AllowedMimeTypes != nil { - input.AllowedMimeTypes = append([]string(nil), (*bucket.AllowedMimeTypes)...) - } - if _, createErr := s.storage.CreateBucket(ctx, input); createErr != nil { - return fmt.Errorf("bucket %q: failed to create: %w", bucket.Name, createErr) - } - summary.BucketsCreated++ - return nil - } - return fmt.Errorf("bucket %q: failed to fetch: %w", bucket.Name, err) - } - - if !bucketNeedsUpdate(existing, bucket) { - summary.BucketsUnchanged++ - return nil - } - - update := api.StorageBucketUpdateInput{} - if bucket.FileSizeLimit != nil { - size := *bucket.FileSizeLimit - update.FileSizeLimit = &size - } - if bucket.AllowedMimeTypes != nil { - mimes := append([]string(nil), (*bucket.AllowedMimeTypes)...) - update.AllowedMimeTypes = &mimes - } - if _, err := s.storage.UpdateBucket(ctx, bucket.Name, update); err != nil { - return fmt.Errorf("bucket %q: failed to update: %w", bucket.Name, err) - } - summary.BucketsUpdated++ - return nil -} - -func bucketNeedsUpdate(existing *apiclient.StorageBucket, desired BucketManifest) bool { - if existing == nil { - return true - } - if desired.FileSizeLimit != nil { - if existing.FileSizeLimit == nil || *existing.FileSizeLimit != *desired.FileSizeLimit { - return true - } - } - if desired.AllowedMimeTypes != nil { - var current []string - if existing.AllowedMimeTypes != nil { - current = *existing.AllowedMimeTypes - } - if !stringSlicesEqual(current, *desired.AllowedMimeTypes) { - return true - } - } - return false -} - -func (s Service) reconcilePolicies(ctx context.Context, bucket BucketManifest, summary *Summary) error { - existing, err := s.storage.ListPolicies(ctx, bucket.Name) - if err != nil { - return fmt.Errorf("bucket %q: failed to list policies: %w", bucket.Name, err) - } - - desired := make(map[string]PolicyManifest, len(bucket.Policies)) - for _, policy := range bucket.Policies { - desired[policy.Name] = policy - } - - handled := make(map[string]bool, len(existing)) - for i := range existing { - current := existing[i] - target, want := desired[current.Name] - if !want { - if _, err := s.storage.DeletePolicy(ctx, bucket.Name, current.Id.String()); err != nil { - return fmt.Errorf("bucket %q: failed to delete policy %q: %w", bucket.Name, current.Name, err) - } - summary.PoliciesDeleted++ - continue - } - handled[current.Name] = true - - if string(current.Operation) == target.Operation && strings.TrimSpace(current.Definition) == strings.TrimSpace(target.Definition) { - summary.PoliciesUnchanged++ - continue - } - - if _, err := s.storage.DeletePolicy(ctx, bucket.Name, current.Id.String()); err != nil { - return fmt.Errorf("bucket %q: failed to replace policy %q: %w", bucket.Name, current.Name, err) - } - if _, err := s.storage.CreatePolicy(ctx, bucket.Name, policyCreateInput(target)); err != nil { - // Best-effort rollback: the API has no atomic policy update, so on - // create failure we recreate the previous definition to avoid - // leaving the bucket without a policy. - rollback := api.StoragePolicyCreateInput{ - Name: current.Name, - Definition: current.Definition, - Operation: apicommon.CreateStoragePolicyRequestOperation(current.Operation), - } - if _, rollbackErr := s.storage.CreatePolicy(ctx, bucket.Name, rollback); rollbackErr != nil { - return fmt.Errorf("bucket %q: failed to recreate policy %q: %w; rollback to previous definition also failed: %w", bucket.Name, target.Name, err, rollbackErr) - } - return fmt.Errorf("bucket %q: failed to recreate policy %q (rolled back to previous definition): %w", bucket.Name, target.Name, err) - } - summary.PoliciesUpdated++ - } - - for _, target := range bucket.Policies { - if handled[target.Name] { - continue - } - if _, err := s.storage.CreatePolicy(ctx, bucket.Name, policyCreateInput(target)); err != nil { - return fmt.Errorf("bucket %q: failed to create policy %q: %w", bucket.Name, target.Name, err) - } - summary.PoliciesCreated++ - } - return nil -} - -func policyCreateInput(policy PolicyManifest) api.StoragePolicyCreateInput { - return api.StoragePolicyCreateInput{ - Name: policy.Name, - Definition: policy.Definition, - Operation: apicommon.CreateStoragePolicyRequestOperation(policy.Operation), - } -} - -func (s Service) reconcileFunctions(ctx context.Context, functions []FunctionManifest, summary *Summary) error { - if len(functions) == 0 { - return nil - } - - deployed, err := listAllFunctions(ctx, s.functions) - if err != nil { - return fmt.Errorf("failed to list functions: %w", err) - } - - byName := make(map[string]apiclient.Function, len(deployed)) - for _, fn := range deployed { - byName[fn.Name] = fn - } - - for _, target := range functions { - // Skip visibility reconciliation if Public is not set (schedulers-only entry) - if target.Public == nil { - continue - } - - fn, ok := byName[target.Name] - if !ok { - available := make([]string, 0, len(deployed)) - for _, existing := range deployed { - available = append(available, existing.Name) - } - sort.Strings(available) - if len(available) == 0 { - return fmt.Errorf("function %q not found: project has no deployed functions", target.Name) - } - return fmt.Errorf("function %q not found. Available functions: %s", target.Name, strings.Join(available, ", ")) - } - - desiredPublic := *target.Public - if fn.IsPublic == desiredPublic { - summary.FunctionsUnchanged++ - continue - } - - if _, err := s.functions.UpdateVisibility(ctx, fn.Id.String(), desiredPublic); err != nil { - return fmt.Errorf("function %q: failed to update visibility: %w", target.Name, err) - } - summary.FunctionsUpdated++ - } - return nil -} - -// listAllFunctions walks every paginated page of project functions. The -// function visibility step needs the full set so it can report a useful -// "available functions" hint when a manifest entry doesn't match anything. -func listAllFunctions(ctx context.Context, functions FunctionReconciler) ([]apiclient.Function, error) { - var all []apiclient.Function - for page := api.DefaultPage; ; page++ { - resp, err := functions.ListPage(ctx, page, api.DefaultLimit) - if err != nil { - return nil, err - } - if resp == nil { - return all, nil - } - all = append(all, resp.Data...) - if !resp.HasMore || len(resp.Data) == 0 { - return all, nil - } - } -} - -// stringSlicesEqual reports whether a and b contain the same elements -// regardless of order. The API and the manifest may serialize values like -// allowed MIME types in different orders, so positional comparison would flag -// equivalent content as changed and trigger spurious updates. -func stringSlicesEqual(a, b []string) bool { - if len(a) != len(b) { - return false - } - aSorted := append([]string(nil), a...) - bSorted := append([]string(nil), b...) - sort.Strings(aSorted) - sort.Strings(bSorted) - for i := range aSorted { - if aSorted[i] != bSorted[i] { - return false - } - } - return true -} - -func (s Service) reconcileSchedulers(ctx context.Context, functions []FunctionManifest, summary *Summary) error { - for _, fnManifest := range functions { - if len(fnManifest.Schedulers) == 0 { - continue - } - - // Resolve the function and list existing schedulers - fn, listResp, err := s.schedulers.ListSchedulers(ctx, fnManifest.Name) - if err != nil { - return fmt.Errorf("function %q: failed to list schedulers: %w", fnManifest.Name, err) - } - if fn == nil { - return fmt.Errorf("function %q: not found", fnManifest.Name) - } - // The function-scoped scheduler list returns the full set in one response - // (has_more is always false today and the client exposes no page parameter). - // Guard against a future server change that paginates: schedulers beyond page - // 1 would be missed and wrongly recreated, so fail loudly instead. - if listResp != nil && listResp.HasMore { - return fmt.Errorf("function %q: scheduler list is paginated (has_more=true) but the CLI cannot page it; aborting to avoid duplicate creation", fnManifest.Name) - } - - // Build map of existing schedulers by Name - existingByName := make(map[string]apiclient.FunctionScheduler) - if listResp != nil { - for _, existing := range listResp.Data { - if existing.Name != nil { - name := *existing.Name - if _, duplicate := existingByName[name]; duplicate { - return fmt.Errorf("function %q: duplicate scheduler name %q exists on server (cannot reconcile unambiguously)", fnManifest.Name, name) - } - existingByName[name] = existing - } - } - } - - // Reconcile each desired scheduler - for _, desired := range fnManifest.Schedulers { - input := buildSchedulerInput(desired) - - existing, found := existingByName[desired.Name] - if !found { - // Create new scheduler - if _, err := s.schedulers.CreateSchedulerByID(ctx, fn.Id, input); err != nil { - return fmt.Errorf("function %q: failed to create scheduler %q: %w", fnManifest.Name, desired.Name, err) - } - summary.SchedulersCreated++ - continue - } - - // Check if update needed - if schedulerNeedsUpdate(existing, desired) { - if existing.Id == nil { - return fmt.Errorf("function %q scheduler %q: existing scheduler has no ID", fnManifest.Name, desired.Name) - } - schedulerID, err := uuid.Parse(existing.Id.String()) - if err != nil { - return fmt.Errorf("function %q scheduler %q: invalid scheduler ID: %w", fnManifest.Name, desired.Name, err) - } - if _, err := s.schedulers.UpdateSchedulerByID(ctx, fn.Id, schedulerID, input); err != nil { - return fmt.Errorf("function %q: failed to update scheduler %q: %w", fnManifest.Name, desired.Name, err) - } - summary.SchedulersUpdated++ - } else { - summary.SchedulersUnchanged++ - } - } - } - return nil -} - -func buildSchedulerInput(manifest SchedulerManifest) api.FunctionSchedulerInput { - // Forward the manifest's enabled as-is. A nil (omitted) value is dropped from - // the update body so the server keeps the scheduler's current state, and on - // create the server defaults it to true. This follows the server's own - // "omitted = keep existing" contract and avoids re-enabling a deliberately - // disabled scheduler; schedulerNeedsUpdate only flags enabled when declared. - return api.FunctionSchedulerInput{ - Name: manifest.Name, - CronExpression: manifest.Cron, - Payload: manifest.Payload, - Regions: manifest.Regions, - Enabled: manifest.Enabled, - } -} - -func schedulerNeedsUpdate(existing apiclient.FunctionScheduler, desired SchedulerManifest) bool { - // Compare cron expression - if existing.CronExpression == nil || *existing.CronExpression != desired.Cron { - return true - } - - // Only reconcile enabled when the manifest declares it. This mirrors the - // server, whose update contract treats an omitted enabled as "keep existing" - // (payload/regions below are handled the same way). Forcing the default would - // re-enable a deliberately-disabled scheduler on every deploy. A nil existing - // Enabled from the API means enabled. - if desired.Enabled != nil { - existingEnabled := true - if existing.Enabled != nil { - existingEnabled = *existing.Enabled - } - if existingEnabled != *desired.Enabled { - return true - } - } - - // Only reconcile payload when the manifest declares one. An omitted payload is - // treated as server-managed: the API does not serialize a nil payload on update - // (so we could never clear it), and the server defaults it to {}. Diffing an - // omitted payload against an existing non-empty one would report an update that - // can never converge, so we leave it alone — mirroring how omitted regions are - // handled below. When a payload IS declared, compare on canonical JSON so YAML - // ints vs server float64s (and key order) don't produce false differences. - if len(desired.Payload) > 0 { - var existingPayload map[string]any - if existing.Payload != nil { - existingPayload = *existing.Payload - } - if !payloadEqual(existingPayload, desired.Payload) { - return true - } - } - - // Compare regions only when the manifest explicitly declares them. When - // regions are omitted the server auto-assigns a deployed region, so a - // comparison against the empty manifest value would report a spurious - // update on every deploy. Treat omitted regions as server-managed. - if len(desired.Regions) > 0 { - var existingRegions []string - if existing.Regions != nil { - existingRegions = *existing.Regions - } - if !stringSlicesEqual(existingRegions, desired.Regions) { - return true - } - } - - return false -} - -// payloadEqual reports whether two scheduler payloads are equivalent. An empty -// or nil payload is treated as equal to the server's default empty object. -// Comparison is done on canonical JSON so YAML ints and server float64s (and -// map key ordering) do not produce false differences. -func payloadEqual(a, b map[string]any) bool { - if len(a) == 0 && len(b) == 0 { - return true - } - aJSON, errA := json.Marshal(a) - bJSON, errB := json.Marshal(b) - if errA != nil || errB != nil { - return reflect.DeepEqual(a, b) - } - return bytes.Equal(aJSON, bJSON) -} diff --git a/internal/projectconfig/deploy_test.go b/internal/projectconfig/deploy_test.go deleted file mode 100644 index 755333e..0000000 --- a/internal/projectconfig/deploy_test.go +++ /dev/null @@ -1,1086 +0,0 @@ -package projectconfig - -import ( - "context" - "errors" - "fmt" - "maps" - "net/http" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/Kong/volcano-cli/internal/api" - "github.com/Kong/volcano-cli/internal/apiclient" - apicommon "github.com/Kong/volcano-cli/internal/apiclient/common" -) - -// fakeStorage records calls and lets tests script its responses. -type fakeStorage struct { - buckets map[string]*apiclient.StorageBucket - policies map[string][]apiclient.StoragePolicy - createBuckets []api.StorageBucketCreateInput - updateBuckets []bucketUpdateCall - createPolicies []policyCreateCall - deletePolicies []policyDeleteCall - - getBucketErr error - createErr error - updateErr error - listPoliciesErr error - createPolicyErr error - createPolicyErrs []error - deletePolicyErr error -} - -type bucketUpdateCall struct { - Name string - Input api.StorageBucketUpdateInput -} - -type policyCreateCall struct { - Bucket string - Input api.StoragePolicyCreateInput -} - -type policyDeleteCall struct { - Bucket string - Identifier string -} - -func newFakeStorage() *fakeStorage { - return &fakeStorage{ - buckets: make(map[string]*apiclient.StorageBucket), - policies: make(map[string][]apiclient.StoragePolicy), - } -} - -func (f *fakeStorage) GetBucket(_ context.Context, name string) (*apiclient.StorageBucket, error) { - if f.getBucketErr != nil { - return nil, f.getBucketErr - } - bucket, ok := f.buckets[name] - if !ok { - return nil, &api.Error{StatusCode: http.StatusNotFound, Message: "not found"} - } - return bucket, nil -} - -func (f *fakeStorage) CreateBucket(_ context.Context, input api.StorageBucketCreateInput) (*apiclient.StorageBucket, error) { - f.createBuckets = append(f.createBuckets, input) - if f.createErr != nil { - return nil, f.createErr - } - bucket := &apiclient.StorageBucket{ - Id: uuid.New(), - Name: input.Name, - FileSizeLimit: input.FileSizeLimit, - } - if len(input.AllowedMimeTypes) > 0 { - mimes := append([]string(nil), input.AllowedMimeTypes...) - bucket.AllowedMimeTypes = &mimes - } - f.buckets[input.Name] = bucket - return bucket, nil -} - -func (f *fakeStorage) UpdateBucket(_ context.Context, name string, input api.StorageBucketUpdateInput) (*apiclient.StorageBucket, error) { - f.updateBuckets = append(f.updateBuckets, bucketUpdateCall{Name: name, Input: input}) - if f.updateErr != nil { - return nil, f.updateErr - } - bucket, ok := f.buckets[name] - if !ok { - return nil, fmt.Errorf("bucket %q not found", name) - } - if input.FileSizeLimit != nil { - size := *input.FileSizeLimit - bucket.FileSizeLimit = &size - } - if input.AllowedMimeTypes != nil { - mimes := append([]string(nil), (*input.AllowedMimeTypes)...) - bucket.AllowedMimeTypes = &mimes - } - return bucket, nil -} - -func (f *fakeStorage) ListPolicies(_ context.Context, bucketName string) ([]apiclient.StoragePolicy, error) { - if f.listPoliciesErr != nil { - return nil, f.listPoliciesErr - } - return append([]apiclient.StoragePolicy(nil), f.policies[bucketName]...), nil -} - -func (f *fakeStorage) CreatePolicy(_ context.Context, bucketName string, input api.StoragePolicyCreateInput) (*apiclient.StoragePolicy, error) { - f.createPolicies = append(f.createPolicies, policyCreateCall{Bucket: bucketName, Input: input}) - if len(f.createPolicyErrs) > 0 { - err := f.createPolicyErrs[0] - f.createPolicyErrs = f.createPolicyErrs[1:] - if err != nil { - return nil, err - } - } else if f.createPolicyErr != nil { - return nil, f.createPolicyErr - } - policy := apiclient.StoragePolicy{ - Id: uuid.New(), - BucketId: uuid.New(), - Name: input.Name, - Operation: apicommon.StoragePolicyOperation(input.Operation), - Definition: input.Definition, - } - f.policies[bucketName] = append(f.policies[bucketName], policy) - return &policy, nil -} - -func (f *fakeStorage) DeletePolicy(_ context.Context, bucketName, identifier string) (*apiclient.StoragePolicy, error) { - f.deletePolicies = append(f.deletePolicies, policyDeleteCall{Bucket: bucketName, Identifier: identifier}) - if f.deletePolicyErr != nil { - return nil, f.deletePolicyErr - } - existing := f.policies[bucketName] - for i := range existing { - if existing[i].Id.String() == identifier || existing[i].Name == identifier { - deleted := existing[i] - f.policies[bucketName] = append(existing[:i], existing[i+1:]...) - return &deleted, nil - } - } - return nil, fmt.Errorf("policy %q not found", identifier) -} - -type fakeFunctions struct { - functions []apiclient.Function - visibilitySets []visibilityCall - listErr error - updateErr error -} - -type visibilityCall struct { - Identifier string - IsPublic bool -} - -func (f *fakeFunctions) ListPage(_ context.Context, page, _ int) (*apiclient.PaginatedFunctions, error) { - if f.listErr != nil { - return nil, f.listErr - } - if page != api.DefaultPage { - return &apiclient.PaginatedFunctions{Page: page, HasMore: false}, nil - } - return &apiclient.PaginatedFunctions{ - Page: page, - Limit: api.DefaultLimit, - Total: len(f.functions), - Data: append([]apiclient.Function(nil), f.functions...), - HasMore: false, - }, nil -} - -func (f *fakeFunctions) UpdateVisibility(_ context.Context, identifier string, isPublic bool) (*apiclient.Function, error) { - f.visibilitySets = append(f.visibilitySets, visibilityCall{Identifier: identifier, IsPublic: isPublic}) - if f.updateErr != nil { - return nil, f.updateErr - } - for i := range f.functions { - if f.functions[i].Name == identifier || f.functions[i].Id.String() == identifier { - f.functions[i].IsPublic = isPublic - fn := f.functions[i] - return &fn, nil - } - } - return nil, fmt.Errorf("function %q not found", identifier) -} - -func TestDeployCreatesMissingBucketAndPolicies(t *testing.T) { - storage := newFakeStorage() - functions := &fakeFunctions{} - svc := NewServiceWithReconcilers(storage, functions, newFakeSchedulers()) - - limit := int64(2048) - manifest := &Manifest{ - Version: 1, - Buckets: []BucketManifest{{ - Name: "uploads", - FileSizeLimit: &limit, - AllowedMimeTypes: &[]string{"image/png", "image/jpeg"}, - Policies: []PolicyManifest{ - {Name: "owner", Operation: "SELECT", Definition: "auth.uid() = owner_id"}, - }, - }}, - } - - summary, err := svc.Deploy(context.Background(), manifest) - require.NoError(t, err) - assert.Equal(t, 1, summary.BucketsCreated) - assert.Equal(t, 0, summary.BucketsUpdated) - assert.Equal(t, 0, summary.BucketsUnchanged) - assert.Equal(t, 1, summary.PoliciesCreated) - assert.Equal(t, 0, summary.PoliciesUpdated) - assert.Equal(t, 0, summary.PoliciesUnchanged) - assert.Equal(t, 0, summary.PoliciesDeleted) - - require.Len(t, storage.createBuckets, 1) - assert.Equal(t, "uploads", storage.createBuckets[0].Name) - require.NotNil(t, storage.createBuckets[0].FileSizeLimit) - assert.EqualValues(t, 2048, *storage.createBuckets[0].FileSizeLimit) - assert.Equal(t, []string{"image/png", "image/jpeg"}, storage.createBuckets[0].AllowedMimeTypes) - - require.Len(t, storage.createPolicies, 1) - assert.Equal(t, "uploads", storage.createPolicies[0].Bucket) - assert.Equal(t, "owner", storage.createPolicies[0].Input.Name) - assert.Equal(t, apicommon.CreateStoragePolicyRequestOperationSELECT, storage.createPolicies[0].Input.Operation) -} - -func TestDeployUpdatesBucketWhenMimeTypesDiffer(t *testing.T) { - storage := newFakeStorage() - existingLimit := int64(1024) - storage.buckets["uploads"] = &apiclient.StorageBucket{ - Id: uuid.New(), - Name: "uploads", - FileSizeLimit: &existingLimit, - AllowedMimeTypes: &[]string{"image/png"}, - } - - svc := NewServiceWithReconcilers(storage, &fakeFunctions{}, newFakeSchedulers()) - newLimit := int64(1024) - manifest := &Manifest{ - Version: 1, - Buckets: []BucketManifest{{ - Name: "uploads", - FileSizeLimit: &newLimit, - AllowedMimeTypes: &[]string{"image/png", "image/jpeg"}, - }}, - } - - summary, err := svc.Deploy(context.Background(), manifest) - require.NoError(t, err) - assert.Equal(t, 0, summary.BucketsCreated) - assert.Equal(t, 1, summary.BucketsUpdated) - require.Len(t, storage.updateBuckets, 1) - require.NotNil(t, storage.updateBuckets[0].Input.AllowedMimeTypes) - assert.Equal(t, []string{"image/png", "image/jpeg"}, *storage.updateBuckets[0].Input.AllowedMimeTypes) - require.NotNil(t, storage.updateBuckets[0].Input.FileSizeLimit, - "update payload must carry FileSizeLimit so a partial update can't clear the server value") - assert.Equal(t, newLimit, *storage.updateBuckets[0].Input.FileSizeLimit) -} - -func TestDeployLeavesBucketUnchangedWhenFieldsMatch(t *testing.T) { - storage := newFakeStorage() - limit := int64(1024) - storage.buckets["uploads"] = &apiclient.StorageBucket{ - Id: uuid.New(), - Name: "uploads", - FileSizeLimit: &limit, - AllowedMimeTypes: &[]string{"image/png"}, - } - - svc := NewServiceWithReconcilers(storage, &fakeFunctions{}, newFakeSchedulers()) - manifest := &Manifest{ - Version: 1, - Buckets: []BucketManifest{{ - Name: "uploads", - FileSizeLimit: &limit, - AllowedMimeTypes: &[]string{"image/png"}, - }}, - } - - summary, err := svc.Deploy(context.Background(), manifest) - require.NoError(t, err) - assert.Equal(t, 1, summary.BucketsUnchanged) - assert.Empty(t, storage.updateBuckets) -} - -func TestDeployRecreatesPolicyWhenDefinitionChanges(t *testing.T) { - storage := newFakeStorage() - bucketID := uuid.New() - policyID := uuid.New() - storage.buckets["uploads"] = &apiclient.StorageBucket{Id: bucketID, Name: "uploads"} - storage.policies["uploads"] = []apiclient.StoragePolicy{{ - Id: policyID, - BucketId: bucketID, - Name: "owner", - Operation: apicommon.StoragePolicyOperationSELECT, - Definition: "true", - }} - - svc := NewServiceWithReconcilers(storage, &fakeFunctions{}, newFakeSchedulers()) - manifest := &Manifest{ - Version: 1, - Buckets: []BucketManifest{{ - Name: "uploads", - Policies: []PolicyManifest{ - {Name: "owner", Operation: "SELECT", Definition: "auth.uid() = owner_id"}, - }, - }}, - } - - summary, err := svc.Deploy(context.Background(), manifest) - require.NoError(t, err) - assert.Equal(t, 1, summary.PoliciesUpdated) - assert.Equal(t, 0, summary.PoliciesUnchanged) - - require.Len(t, storage.deletePolicies, 1) - assert.Equal(t, policyID.String(), storage.deletePolicies[0].Identifier) - require.Len(t, storage.createPolicies, 1) - assert.Equal(t, "auth.uid() = owner_id", storage.createPolicies[0].Input.Definition) -} - -func TestDeployDeletesPolicyMissingFromManifest(t *testing.T) { - storage := newFakeStorage() - bucketID := uuid.New() - storage.buckets["uploads"] = &apiclient.StorageBucket{Id: bucketID, Name: "uploads"} - staleID := uuid.New() - storage.policies["uploads"] = []apiclient.StoragePolicy{{ - Id: staleID, - BucketId: bucketID, - Name: "old-policy", - Operation: apicommon.StoragePolicyOperationSELECT, - Definition: "false", - }} - - svc := NewServiceWithReconcilers(storage, &fakeFunctions{}, newFakeSchedulers()) - manifest := &Manifest{ - Version: 1, - Buckets: []BucketManifest{{Name: "uploads"}}, - } - - summary, err := svc.Deploy(context.Background(), manifest) - require.NoError(t, err) - assert.Equal(t, 1, summary.PoliciesDeleted) - require.Len(t, storage.deletePolicies, 1) - assert.Equal(t, staleID.String(), storage.deletePolicies[0].Identifier) -} - -func TestDeployUpdatesFunctionVisibility(t *testing.T) { - helloID := uuid.New() - functions := &fakeFunctions{ - functions: []apiclient.Function{ - {Id: helloID, Name: "hello", IsPublic: false}, - {Id: uuid.New(), Name: "world", IsPublic: true}, - }, - } - svc := NewServiceWithReconcilers(newFakeStorage(), functions, newFakeSchedulers()) - - pub := true - stay := true - manifest := &Manifest{ - Version: 1, - Functions: []FunctionManifest{ - {Name: "hello", Public: &pub}, - {Name: "world", Public: &stay}, - }, - } - - summary, err := svc.Deploy(context.Background(), manifest) - require.NoError(t, err) - assert.Equal(t, 1, summary.FunctionsUpdated) - assert.Equal(t, 1, summary.FunctionsUnchanged) - require.Len(t, functions.visibilitySets, 1) - assert.Equal(t, helloID.String(), functions.visibilitySets[0].Identifier, - "visibility update must target the UUID we already fetched, not re-resolve by name") - assert.True(t, functions.visibilitySets[0].IsPublic) -} - -func TestDeployFunctionMissingReturnsAvailableList(t *testing.T) { - functions := &fakeFunctions{ - functions: []apiclient.Function{ - {Id: uuid.New(), Name: "hello"}, - {Id: uuid.New(), Name: "world"}, - }, - } - svc := NewServiceWithReconcilers(newFakeStorage(), functions, newFakeSchedulers()) - pub := true - manifest := &Manifest{ - Version: 1, - Functions: []FunctionManifest{{Name: "missing", Public: &pub}}, - } - - _, err := svc.Deploy(context.Background(), manifest) - require.Error(t, err) - assert.Contains(t, err.Error(), `function "missing" not found`) - assert.Contains(t, err.Error(), "Available functions: hello, world") -} - -func TestDeployFunctionMissingNoFunctions(t *testing.T) { - svc := NewServiceWithReconcilers(newFakeStorage(), &fakeFunctions{}, newFakeSchedulers()) - pub := true - manifest := &Manifest{ - Version: 1, - Functions: []FunctionManifest{{Name: "missing", Public: &pub}}, - } - - _, err := svc.Deploy(context.Background(), manifest) - require.Error(t, err) - assert.Contains(t, err.Error(), "project has no deployed functions") -} - -func TestDeployBucketFetchErrorPropagates(t *testing.T) { - storage := newFakeStorage() - storage.getBucketErr = errors.New("boom") - svc := NewServiceWithReconcilers(storage, &fakeFunctions{}, newFakeSchedulers()) - - manifest := &Manifest{ - Version: 1, - Buckets: []BucketManifest{{Name: "uploads"}}, - } - _, err := svc.Deploy(context.Background(), manifest) - require.Error(t, err) - assert.Contains(t, err.Error(), `bucket "uploads": failed to fetch`) -} - -func TestDeployNilManifest(t *testing.T) { - svc := NewServiceWithReconcilers(newFakeStorage(), &fakeFunctions{}, newFakeSchedulers()) - _, err := svc.Deploy(context.Background(), nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "manifest is required") -} - -func TestDeployRollsBackPolicyWhenRecreateFails(t *testing.T) { - storage := newFakeStorage() - bucketID := uuid.New() - policyID := uuid.New() - storage.buckets["uploads"] = &apiclient.StorageBucket{Id: bucketID, Name: "uploads"} - storage.policies["uploads"] = []apiclient.StoragePolicy{{ - Id: policyID, - BucketId: bucketID, - Name: "owner", - Operation: apicommon.StoragePolicyOperationSELECT, - Definition: "true", - }} - // Fail the new-definition create; let the rollback create succeed. - storage.createPolicyErrs = []error{errors.New("boom"), nil} - - svc := NewServiceWithReconcilers(storage, &fakeFunctions{}, newFakeSchedulers()) - manifest := &Manifest{ - Version: 1, - Buckets: []BucketManifest{{ - Name: "uploads", - Policies: []PolicyManifest{ - {Name: "owner", Operation: "SELECT", Definition: "auth.uid() = owner_id"}, - }, - }}, - } - - _, err := svc.Deploy(context.Background(), manifest) - require.Error(t, err) - assert.Contains(t, err.Error(), `failed to recreate policy "owner"`) - assert.Contains(t, err.Error(), "rolled back to previous definition") - - require.Len(t, storage.createPolicies, 2) - assert.Equal(t, "auth.uid() = owner_id", storage.createPolicies[0].Input.Definition) - assert.Equal(t, "true", storage.createPolicies[1].Input.Definition, - "rollback must restore the previous policy definition") - assert.Equal(t, "owner", storage.createPolicies[1].Input.Name) - assert.Equal(t, apicommon.CreateStoragePolicyRequestOperationSELECT, storage.createPolicies[1].Input.Operation) -} - -func TestDeployPolicyRollbackFailureSurfacesBothErrors(t *testing.T) { - storage := newFakeStorage() - bucketID := uuid.New() - policyID := uuid.New() - storage.buckets["uploads"] = &apiclient.StorageBucket{Id: bucketID, Name: "uploads"} - storage.policies["uploads"] = []apiclient.StoragePolicy{{ - Id: policyID, - BucketId: bucketID, - Name: "owner", - Operation: apicommon.StoragePolicyOperationSELECT, - Definition: "true", - }} - storage.createPolicyErrs = []error{errors.New("create failed"), errors.New("rollback failed")} - - svc := NewServiceWithReconcilers(storage, &fakeFunctions{}, newFakeSchedulers()) - manifest := &Manifest{ - Version: 1, - Buckets: []BucketManifest{{ - Name: "uploads", - Policies: []PolicyManifest{ - {Name: "owner", Operation: "SELECT", Definition: "auth.uid() = owner_id"}, - }, - }}, - } - - _, err := svc.Deploy(context.Background(), manifest) - require.Error(t, err) - assert.Contains(t, err.Error(), "create failed") - assert.Contains(t, err.Error(), "rollback to previous definition also failed") - assert.Contains(t, err.Error(), "rollback failed") -} - -// pagedFunctions returns one apiclient.Function per page so we can exercise -// listAllFunctions's pagination loop beyond page 1. -type pagedFunctions struct { - pages [][]apiclient.Function - visibilitySets []visibilityCall -} - -func (p *pagedFunctions) ListPage(_ context.Context, page, _ int) (*apiclient.PaginatedFunctions, error) { - idx := page - api.DefaultPage - if idx < 0 || idx >= len(p.pages) { - return &apiclient.PaginatedFunctions{Page: page, HasMore: false}, nil - } - return &apiclient.PaginatedFunctions{ - Page: page, - Data: append([]apiclient.Function(nil), p.pages[idx]...), - HasMore: idx < len(p.pages)-1, - }, nil -} - -func (p *pagedFunctions) UpdateVisibility(_ context.Context, identifier string, isPublic bool) (*apiclient.Function, error) { - p.visibilitySets = append(p.visibilitySets, visibilityCall{Identifier: identifier, IsPublic: isPublic}) - for _, page := range p.pages { - for i := range page { - if page[i].Id.String() == identifier || page[i].Name == identifier { - page[i].IsPublic = isPublic - fn := page[i] - return &fn, nil - } - } - } - return nil, fmt.Errorf("function %q not found", identifier) -} - -func TestDeployListsFunctionsAcrossMultiplePages(t *testing.T) { - firstID := uuid.New() - secondID := uuid.New() - functions := &pagedFunctions{ - pages: [][]apiclient.Function{ - {{Id: firstID, Name: "first", IsPublic: false}}, - {{Id: secondID, Name: "second", IsPublic: false}}, - }, - } - svc := NewServiceWithReconcilers(newFakeStorage(), functions, newFakeSchedulers()) - - pub := true - manifest := &Manifest{ - Version: 1, - Functions: []FunctionManifest{ - {Name: "first", Public: &pub}, - {Name: "second", Public: &pub}, - }, - } - - summary, err := svc.Deploy(context.Background(), manifest) - require.NoError(t, err) - assert.Equal(t, 2, summary.FunctionsUpdated, - "both functions must be reachable, including the one on page 2") - require.Len(t, functions.visibilitySets, 2) - assert.Equal(t, firstID.String(), functions.visibilitySets[0].Identifier) - assert.Equal(t, secondID.String(), functions.visibilitySets[1].Identifier) -} - -func TestDeployLeavesBucketUnchangedWhenMimeTypesDifferInOrder(t *testing.T) { - storage := newFakeStorage() - limit := int64(1024) - storage.buckets["uploads"] = &apiclient.StorageBucket{ - Id: uuid.New(), - Name: "uploads", - FileSizeLimit: &limit, - AllowedMimeTypes: &[]string{"image/jpeg", "image/png"}, - } - - svc := NewServiceWithReconcilers(storage, &fakeFunctions{}, newFakeSchedulers()) - manifest := &Manifest{ - Version: 1, - Buckets: []BucketManifest{{ - Name: "uploads", - FileSizeLimit: &limit, - AllowedMimeTypes: &[]string{"image/png", "image/jpeg"}, - }}, - } - - summary, err := svc.Deploy(context.Background(), manifest) - require.NoError(t, err) - assert.Equal(t, 1, summary.BucketsUnchanged) - assert.Empty(t, storage.updateBuckets, "MIME types differing only in order must not trigger an update") -} - -type fakeSchedulers struct { - functions map[string]*apiclient.Function - schedulers map[string][]apiclient.FunctionScheduler - createdCalls []schedulerCreateCall - updatedCalls []schedulerUpdateCall - listErr error - createErr error - updateErr error - hasMore bool -} - -type schedulerCreateCall struct { - FunctionID uuid.UUID - Input api.FunctionSchedulerInput -} - -type schedulerUpdateCall struct { - FunctionID uuid.UUID - SchedulerID uuid.UUID - Input api.FunctionSchedulerInput -} - -func newFakeSchedulers() *fakeSchedulers { - return &fakeSchedulers{ - functions: make(map[string]*apiclient.Function), - schedulers: make(map[string][]apiclient.FunctionScheduler), - } -} - -func (f *fakeSchedulers) ListSchedulers(_ context.Context, identifier string) (*apiclient.Function, *apiclient.FunctionSchedulerListResponse, error) { - if f.listErr != nil { - return nil, nil, f.listErr - } - fn, ok := f.functions[identifier] - if !ok { - return nil, nil, api.ErrNotFound - } - schedulers := f.schedulers[identifier] - resp := &apiclient.FunctionSchedulerListResponse{ - Data: append([]apiclient.FunctionScheduler(nil), schedulers...), - HasMore: f.hasMore, - } - return fn, resp, nil -} - -func (f *fakeSchedulers) CreateSchedulerByID(_ context.Context, functionID uuid.UUID, input api.FunctionSchedulerInput) (*apiclient.FunctionScheduler, error) { - f.createdCalls = append(f.createdCalls, schedulerCreateCall{FunctionID: functionID, Input: input}) - if f.createErr != nil { - return nil, f.createErr - } - schedulerID := uuid.New() - scheduler := apiclient.FunctionScheduler{ - Id: &schedulerID, - FunctionId: &functionID, - Name: &input.Name, - CronExpression: &input.CronExpression, - Enabled: input.Enabled, - } - if input.Payload != nil { - payload := maps.Clone(input.Payload) - scheduler.Payload = &payload - } - if len(input.Regions) > 0 { - regions := append([]string(nil), input.Regions...) - scheduler.Regions = ®ions - } - // Store by function name (need to find it) - for name, fn := range f.functions { - if fn.Id == functionID { - f.schedulers[name] = append(f.schedulers[name], scheduler) - break - } - } - return &scheduler, nil -} - -func (f *fakeSchedulers) UpdateSchedulerByID(_ context.Context, functionID, schedulerID uuid.UUID, input api.FunctionSchedulerInput) (*apiclient.FunctionScheduler, error) { - f.updatedCalls = append(f.updatedCalls, schedulerUpdateCall{FunctionID: functionID, SchedulerID: schedulerID, Input: input}) - if f.updateErr != nil { - return nil, f.updateErr - } - // Find and update the scheduler - for name, fn := range f.functions { - if fn.Id == functionID { - for i := range f.schedulers[name] { - if f.schedulers[name][i].Id == nil || *f.schedulers[name][i].Id != schedulerID { - continue - } - f.schedulers[name][i].Name = &input.Name - f.schedulers[name][i].CronExpression = &input.CronExpression - f.schedulers[name][i].Enabled = input.Enabled - if input.Payload != nil { - payload := maps.Clone(input.Payload) - f.schedulers[name][i].Payload = &payload - } else { - f.schedulers[name][i].Payload = nil - } - if len(input.Regions) > 0 { - regions := append([]string(nil), input.Regions...) - f.schedulers[name][i].Regions = ®ions - } else { - f.schedulers[name][i].Regions = nil - } - return &f.schedulers[name][i], nil - } - break - } - } - return nil, errors.New("scheduler not found") -} - -func TestReconcileSchedulersCreatesNew(t *testing.T) { - functionID := uuid.New() - schedulers := newFakeSchedulers() - schedulers.functions["hello"] = &apiclient.Function{ - Id: functionID, - Name: "hello", - } - - svc := NewServiceWithReconcilers(newFakeStorage(), &fakeFunctions{}, schedulers) - manifest := &Manifest{ - Version: 1, - Functions: []FunctionManifest{{ - Name: "hello", - Schedulers: []SchedulerManifest{{ - Name: "daily", - Cron: "0 0 * * *", - }}, - }}, - } - - summary, err := svc.Deploy(context.Background(), manifest) - require.NoError(t, err) - assert.Equal(t, 1, summary.SchedulersCreated) - assert.Equal(t, 0, summary.SchedulersUpdated) - assert.Equal(t, 0, summary.SchedulersUnchanged) - - require.Len(t, schedulers.createdCalls, 1) - assert.Equal(t, functionID, schedulers.createdCalls[0].FunctionID) - assert.Equal(t, "daily", schedulers.createdCalls[0].Input.Name) - assert.Equal(t, "0 0 * * *", schedulers.createdCalls[0].Input.CronExpression) -} - -func TestReconcileSchedulersUpdatesChanged(t *testing.T) { - functionID := uuid.New() - schedulerID := uuid.New() - schedulers := newFakeSchedulers() - schedulers.functions["hello"] = &apiclient.Function{ - Id: functionID, - Name: "hello", - } - cron := "0 0 * * *" - enabled := true - schedulers.schedulers["hello"] = []apiclient.FunctionScheduler{{ - Id: &schedulerID, - FunctionId: &functionID, - Name: strPtr("daily"), - CronExpression: &cron, - Enabled: &enabled, - }} - - svc := NewServiceWithReconcilers(newFakeStorage(), &fakeFunctions{}, schedulers) - newCron := "0 12 * * *" // Changed time - manifest := &Manifest{ - Version: 1, - Functions: []FunctionManifest{{ - Name: "hello", - Schedulers: []SchedulerManifest{{ - Name: "daily", - Cron: newCron, - }}, - }}, - } - - summary, err := svc.Deploy(context.Background(), manifest) - require.NoError(t, err) - assert.Equal(t, 0, summary.SchedulersCreated) - assert.Equal(t, 1, summary.SchedulersUpdated) - assert.Equal(t, 0, summary.SchedulersUnchanged) - - require.Len(t, schedulers.updatedCalls, 1) - assert.Equal(t, functionID, schedulers.updatedCalls[0].FunctionID) - assert.Equal(t, schedulerID, schedulers.updatedCalls[0].SchedulerID) - assert.Equal(t, "daily", schedulers.updatedCalls[0].Input.Name) - assert.Equal(t, newCron, schedulers.updatedCalls[0].Input.CronExpression) -} - -func TestReconcileSchedulersUnchanged(t *testing.T) { - functionID := uuid.New() - schedulerID := uuid.New() - schedulers := newFakeSchedulers() - schedulers.functions["hello"] = &apiclient.Function{ - Id: functionID, - Name: "hello", - } - cron := "0 0 * * *" - enabled := true - schedulers.schedulers["hello"] = []apiclient.FunctionScheduler{{ - Id: &schedulerID, - FunctionId: &functionID, - Name: strPtr("daily"), - CronExpression: &cron, - Enabled: &enabled, - }} - - svc := NewServiceWithReconcilers(newFakeStorage(), &fakeFunctions{}, schedulers) - manifest := &Manifest{ - Version: 1, - Functions: []FunctionManifest{{ - Name: "hello", - Schedulers: []SchedulerManifest{{ - Name: "daily", - Cron: "0 0 * * *", - Enabled: &enabled, - }}, - }}, - } - - summary, err := svc.Deploy(context.Background(), manifest) - require.NoError(t, err) - assert.Equal(t, 0, summary.SchedulersCreated) - assert.Equal(t, 0, summary.SchedulersUpdated) - assert.Equal(t, 1, summary.SchedulersUnchanged) - - assert.Empty(t, schedulers.createdCalls) - assert.Empty(t, schedulers.updatedCalls) -} - -func TestReconcileSchedulersDoesNotDeleteUndeclared(t *testing.T) { - functionID := uuid.New() - schedulerID := uuid.New() - schedulers := newFakeSchedulers() - schedulers.functions["hello"] = &apiclient.Function{ - Id: functionID, - Name: "hello", - } - cron := "0 0 * * *" - enabled := true - // Server has an existing scheduler not in manifest - schedulers.schedulers["hello"] = []apiclient.FunctionScheduler{{ - Id: &schedulerID, - FunctionId: &functionID, - Name: strPtr("adhoc"), - CronExpression: &cron, - Enabled: &enabled, - }} - - svc := NewServiceWithReconcilers(newFakeStorage(), &fakeFunctions{}, schedulers) - // Manifest declares a different scheduler - manifest := &Manifest{ - Version: 1, - Functions: []FunctionManifest{{ - Name: "hello", - Schedulers: []SchedulerManifest{{ - Name: "daily", - Cron: "0 12 * * *", - }}, - }}, - } - - summary, err := svc.Deploy(context.Background(), manifest) - require.NoError(t, err) - assert.Equal(t, 1, summary.SchedulersCreated) // Creates "daily" - assert.Equal(t, 0, summary.SchedulersUpdated) - assert.Equal(t, 0, summary.SchedulersUnchanged) - - // Verify "adhoc" was not deleted (still in fake storage) - assert.Len(t, schedulers.schedulers["hello"], 2, "undeclared scheduler should not be deleted") -} - -func TestReconcileSchedulersDuplicateNameError(t *testing.T) { - functionID := uuid.New() - schedulerID1 := uuid.New() - schedulerID2 := uuid.New() - schedulers := newFakeSchedulers() - schedulers.functions["hello"] = &apiclient.Function{ - Id: functionID, - Name: "hello", - } - cron := "0 0 * * *" - enabled := true - // Server has duplicate scheduler names - name := "daily" - schedulers.schedulers["hello"] = []apiclient.FunctionScheduler{ - { - Id: &schedulerID1, - FunctionId: &functionID, - Name: &name, - CronExpression: &cron, - Enabled: &enabled, - }, - { - Id: &schedulerID2, - FunctionId: &functionID, - Name: &name, - CronExpression: &cron, - Enabled: &enabled, - }, - } - svc := NewServiceWithReconcilers(newFakeStorage(), &fakeFunctions{}, schedulers) - manifest := &Manifest{ - Version: 1, - Functions: []FunctionManifest{{ - Name: "hello", - Schedulers: []SchedulerManifest{{ - Name: "daily", - Cron: "0 0 * * *", - }}, - }}, - } - - _, err := svc.Deploy(context.Background(), manifest) - require.Error(t, err) - assert.Contains(t, err.Error(), "duplicate scheduler name") - assert.Contains(t, err.Error(), "daily") -} - -func strPtr(s string) *string { - return &s -} - -func TestSchedulerNeedsUpdateIdempotency(t *testing.T) { - cron := "0 9 * * *" - enabled := true - - base := apiclient.FunctionScheduler{ - CronExpression: &cron, - Enabled: &enabled, - } - - t.Run("omitted regions are server-managed", func(t *testing.T) { - existing := base - regions := []string{"us-east-1"} - existing.Regions = ®ions - desired := SchedulerManifest{Name: "daily", Cron: cron} // regions omitted - if schedulerNeedsUpdate(existing, desired) { - t.Fatalf("expected no update when manifest omits regions (server-managed)") - } - }) - - t.Run("payload int equals server float64", func(t *testing.T) { - existing := base - p := map[string]any{"count": float64(5)} - existing.Payload = &p - desired := SchedulerManifest{Name: "daily", Cron: cron, Payload: map[string]any{"count": 5}} - if schedulerNeedsUpdate(existing, desired) { - t.Fatalf("expected no update for numerically equivalent payload") - } - }) - - t.Run("omitted payload equals server empty object", func(t *testing.T) { - existing := base - empty := map[string]any{} - existing.Payload = &empty - desired := SchedulerManifest{Name: "daily", Cron: cron} // payload omitted - if schedulerNeedsUpdate(existing, desired) { - t.Fatalf("expected no update when payload omitted") - } - }) - - t.Run("cron change triggers update", func(t *testing.T) { - existing := base - desired := SchedulerManifest{Name: "daily", Cron: "0 10 * * *"} - if !schedulerNeedsUpdate(existing, desired) { - t.Fatalf("expected update when cron differs") - } - }) - - t.Run("explicit region mismatch triggers update", func(t *testing.T) { - existing := base - regions := []string{"us-east-1"} - existing.Regions = ®ions - desired := SchedulerManifest{Name: "daily", Cron: cron, Regions: []string{"eu-west-1"}} - if !schedulerNeedsUpdate(existing, desired) { - t.Fatalf("expected update when explicit regions differ") - } - }) - - t.Run("nil existing enabled treated as enabled", func(t *testing.T) { - existing := apiclient.FunctionScheduler{CronExpression: &cron} // Enabled nil - desired := SchedulerManifest{Name: "daily", Cron: cron} // enabled omitted -> true - if schedulerNeedsUpdate(existing, desired) { - t.Fatalf("expected no update: nil existing Enabled should be treated as enabled") - } - }) - - t.Run("omitted payload is server-managed (no churn)", func(t *testing.T) { - existing := base - serverPayload := map[string]any{"job": "refresh"} - existing.Payload = &serverPayload - desired := SchedulerManifest{Name: "daily", Cron: cron} // payload omitted - // Manifest omits payload; the API can't clear it and the server keeps its - // value, so reporting an update would never converge. Expect no update. - if schedulerNeedsUpdate(existing, desired) { - t.Fatalf("expected no update when manifest omits payload (server-managed)") - } - }) - - t.Run("existing disabled + omitted enabled is server-managed (no churn)", func(t *testing.T) { - existing := base - disabled := false - existing.Enabled = &disabled - desired := SchedulerManifest{Name: "daily", Cron: cron} // enabled omitted - // Following server behavior, omitted enabled is left alone; flagging an update - // would never converge and would re-enable a deliberately-disabled scheduler. - if schedulerNeedsUpdate(existing, desired) { - t.Fatalf("expected no update: omitted enabled is server-managed (must converge)") - } - }) - - t.Run("existing disabled + explicit enable triggers update", func(t *testing.T) { - existing := base - disabled := false - existing.Enabled = &disabled - want := true - desired := SchedulerManifest{Name: "daily", Cron: cron, Enabled: &want} - if !schedulerNeedsUpdate(existing, desired) { - t.Fatalf("expected update when manifest explicitly enables a disabled scheduler") - } - }) - - t.Run("existing enabled + explicit disable triggers update", func(t *testing.T) { - existing := base // enabled - want := false - desired := SchedulerManifest{Name: "daily", Cron: cron, Enabled: &want} - if !schedulerNeedsUpdate(existing, desired) { - t.Fatalf("expected update when manifest explicitly disables an enabled scheduler") - } - }) -} - -func TestReconcileSchedulersLeavesDisabledWhenEnabledOmitted(t *testing.T) { - functionID := uuid.New() - schedulerID := uuid.New() - schedulers := newFakeSchedulers() - schedulers.functions["hello"] = &apiclient.Function{Id: functionID, Name: "hello"} - cron := "0 0 * * *" - disabled := false - // Server scheduler is disabled (e.g. an operator ran `schedulers disable`); the - // manifest re-declares it but omits enabled. - schedulers.schedulers["hello"] = []apiclient.FunctionScheduler{{ - Id: &schedulerID, - FunctionId: &functionID, - Name: strPtr("daily"), - CronExpression: &cron, - Enabled: &disabled, - }} - - svc := NewServiceWithReconcilers(newFakeStorage(), &fakeFunctions{}, schedulers) - manifest := &Manifest{ - Version: 1, - Functions: []FunctionManifest{{ - Name: "hello", - Schedulers: []SchedulerManifest{{Name: "daily", Cron: cron}}, // enabled omitted - }}, - } - - // Following server behavior, an omitted enabled is server-managed: the deploy - // must NOT re-enable the scheduler, and it converges immediately (no update). - summary, err := svc.Deploy(context.Background(), manifest) - require.NoError(t, err) - assert.Equal(t, 0, summary.SchedulersUpdated) - assert.Equal(t, 1, summary.SchedulersUnchanged) - assert.Empty(t, schedulers.updatedCalls, "omitted enabled must not trigger an update that re-enables a disabled scheduler") -} - -func TestReconcileSchedulersAbortsOnPaginatedList(t *testing.T) { - functionID := uuid.New() - schedulers := newFakeSchedulers() - schedulers.functions["hello"] = &apiclient.Function{Id: functionID, Name: "hello"} - schedulers.hasMore = true // simulate a server that paginates the scheduler list - - svc := NewServiceWithReconcilers(newFakeStorage(), &fakeFunctions{}, schedulers) - manifest := &Manifest{ - Version: 1, - Functions: []FunctionManifest{{ - Name: "hello", - Schedulers: []SchedulerManifest{{Name: "daily", Cron: "0 0 * * *"}}, - }}, - } - - _, err := svc.Deploy(context.Background(), manifest) - require.Error(t, err) - assert.Contains(t, err.Error(), "paginated") - // Must not create when it cannot see the full set (avoids duplicate creation). - assert.Empty(t, schedulers.createdCalls) -} diff --git a/internal/projectconfig/interpolate.go b/internal/projectconfig/interpolate.go new file mode 100644 index 0000000..ae1a3e2 --- /dev/null +++ b/internal/projectconfig/interpolate.go @@ -0,0 +1,80 @@ +package projectconfig + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +// interpolateNode expands ${ENV} references in every string scalar value of +// the YAML document. Mapping keys are left untouched. +func interpolateNode(node *yaml.Node, lookupEnv func(string) (string, bool)) error { + switch node.Kind { + case yaml.DocumentNode, yaml.SequenceNode: + for _, child := range node.Content { + if err := interpolateNode(child, lookupEnv); err != nil { + return err + } + } + case yaml.MappingNode: + for i := 1; i < len(node.Content); i += 2 { + if err := interpolateNode(node.Content[i], lookupEnv); err != nil { + return err + } + } + case yaml.ScalarNode: + if node.Tag != "!!str" { + return nil + } + expanded, err := interpolateString(node.Value, lookupEnv) + if err != nil { + return err + } + node.Value = expanded + } + return nil +} + +// interpolateString expands ${NAME} references from lookupEnv. A reference to +// an unset variable is a hard error; `$$` produces a literal `$`; any other +// `$` passes through unchanged. +func interpolateString(input string, lookupEnv func(string) (string, bool)) (string, error) { + if !strings.ContainsRune(input, '$') { + return input, nil + } + + var out strings.Builder + out.Grow(len(input)) + for i := 0; i < len(input); i++ { + if input[i] != '$' { + out.WriteByte(input[i]) + continue + } + if i+1 < len(input) && input[i+1] == '$' { + out.WriteByte('$') + i++ + continue + } + if i+1 < len(input) && input[i+1] == '{' { + end := strings.IndexByte(input[i+2:], '}') + if end < 0 { + return "", fmt.Errorf("unterminated ${...} reference in %q", input) + } + name := input[i+2 : i+2+end] + if strings.TrimSpace(name) == "" { + return "", fmt.Errorf("empty ${} reference in %q", input) + } + value, ok := lookupEnv(name) + if !ok { + return "", fmt.Errorf("environment variable %q is not set (referenced as ${%s}); export it or use $$ for a literal $", name, name) + } + out.WriteString(value) + // Skip past "{NAME}"; the loop increment consumes the "$". + i += end + 2 + continue + } + out.WriteByte('$') + } + return out.String(), nil +} diff --git a/internal/projectconfig/manifest.go b/internal/projectconfig/manifest.go index 7d95ed1..96b0589 100644 --- a/internal/projectconfig/manifest.go +++ b/internal/projectconfig/manifest.go @@ -1,9 +1,12 @@ -// Package projectconfig handles declarative cloud project configuration -// manifests (volcano-config.yaml) that describe storage buckets, storage -// policies, and function visibility for a project. +// Package projectconfig handles declarative project configuration manifests +// (volcano-config.yaml). The CLI is a thin client: it parses the manifest, +// interpolates ${ENV} references, and uploads it to the server, which owns +// validation and reconciliation. package projectconfig import ( + "bytes" + "encoding/json" "errors" "fmt" "os" @@ -11,58 +14,284 @@ import ( "strings" "gopkg.in/yaml.v3" - - apicommon "github.com/Kong/volcano-cli/internal/apiclient/common" ) const ( // ManifestVersion is the only currently supported manifest schema version. ManifestVersion = 1 + manifestDir = "volcano" nestedManifestPath = "volcano/volcano-config.yaml" rootManifestPath = "volcano-config.yaml" ) -// Manifest is the on-disk shape of volcano-config.yaml. +// Manifest is the on-disk shape of volcano-config.yaml. Field names mirror the +// server's canonical YAML rendering and the JSON tags match the ProjectConfig +// schema. The typed struct drives local decoding and validation; the apply +// request body is produced from the interpolated manifest as a generic shape +// (see uploadBody) rather than by marshaling this struct, so that omitted +// fields stay omitted and the server's required-field validation applies. A +// non-pointer field such as VariableManifest.Value would otherwise serialize an +// omitted value as its zero value ("") and, because empty variable values are +// valid, silently clear the variable instead of being rejected. type Manifest struct { - Version int `yaml:"version"` - Buckets []BucketManifest `yaml:"buckets,omitempty"` - Functions []FunctionManifest `yaml:"functions,omitempty"` + Version int `yaml:"version" json:"version"` + Project *ProjectManifest `yaml:"project,omitempty" json:"project,omitempty"` + Databases *[]DatabaseManifest `yaml:"databases,omitempty" json:"databases,omitempty"` + Variables *[]VariableManifest `yaml:"variables,omitempty" json:"variables,omitempty"` + Buckets *[]BucketManifest `yaml:"buckets,omitempty" json:"buckets,omitempty"` + Realtime *RealtimeManifest `yaml:"realtime,omitempty" json:"realtime,omitempty"` + Auth *AuthManifest `yaml:"auth,omitempty" json:"auth,omitempty"` + Functions *[]FunctionManifest `yaml:"functions,omitempty" json:"functions,omitempty"` + Frontends *[]FrontendManifest `yaml:"frontends,omitempty" json:"frontends,omitempty"` + + // upload is the interpolated manifest decoded into a generic shape; it is the + // source of the apply request body (see uploadBody). Populated by Parse. + upload map[string]any +} + +// ProjectManifest declares project-level settings. +type ProjectManifest struct { + Name *string `yaml:"name,omitempty" json:"name,omitempty"` + AllRegions *bool `yaml:"all_regions,omitempty" json:"all_regions,omitempty"` + SelectedRegions *[]string `yaml:"selected_regions,omitempty" json:"selected_regions,omitempty"` +} + +// DatabaseManifest asserts identity properties of an existing database. +type DatabaseManifest struct { + Name string `yaml:"name" json:"name"` + Region string `yaml:"region" json:"region"` + PgVersion string `yaml:"pg_version" json:"pg_version"` + DatabaseType *string `yaml:"database_type,omitempty" json:"database_type,omitempty"` } -// BucketManifest declares one storage bucket and its desired policies. +// VariableManifest declares one project variable. +type VariableManifest struct { + Name string `yaml:"name" json:"name"` + Value string `yaml:"value" json:"value"` +} + +// BucketManifest declares settings and policies for an existing bucket. type BucketManifest struct { - Name string `yaml:"name"` - FileSizeLimit *int64 `yaml:"file_size_limit,omitempty"` - AllowedMimeTypes *[]string `yaml:"allowed_mime_types,omitempty"` - Policies []PolicyManifest `yaml:"policies,omitempty"` + Name string `yaml:"name" json:"name"` + FileSizeLimit *int64 `yaml:"file_size_limit,omitempty" json:"file_size_limit,omitempty"` + AllowedMimeTypes *[]string `yaml:"allowed_mime_types,omitempty" json:"allowed_mime_types,omitempty"` + Policies *[]PolicyManifest `yaml:"policies,omitempty" json:"policies,omitempty"` } // PolicyManifest declares one storage policy attached to a bucket. type PolicyManifest struct { - Name string `yaml:"name"` - Operation string `yaml:"operation"` - Definition string `yaml:"definition"` + Name string `yaml:"name" json:"name"` + Operation string `yaml:"operation" json:"operation"` + Definition string `yaml:"definition" json:"definition"` +} + +// RealtimeManifest declares realtime feature flags. +type RealtimeManifest struct { + Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + BroadcastEnabled *bool `yaml:"broadcast_enabled,omitempty" json:"broadcast_enabled,omitempty"` + PresenceEnabled *bool `yaml:"presence_enabled,omitempty" json:"presence_enabled,omitempty"` + PostgresChangesEnabled *bool `yaml:"postgres_changes_enabled,omitempty" json:"postgres_changes_enabled,omitempty"` +} + +// AuthManifest groups authentication settings like the dashboard tabs. +type AuthManifest struct { + Tokens *AuthTokensManifest `yaml:"tokens,omitempty" json:"tokens,omitempty"` + Sessions *AuthSessionsManifest `yaml:"sessions,omitempty" json:"sessions,omitempty"` + Signup *AuthSignupManifest `yaml:"signup,omitempty" json:"signup,omitempty"` + RateLimits *AuthRateLimitsManifest `yaml:"rate_limits,omitempty" json:"rate_limits,omitempty"` + Password *AuthPasswordManifest `yaml:"password,omitempty" json:"password,omitempty"` + PasswordReset *AuthPasswordResetManifest `yaml:"password_reset,omitempty" json:"password_reset,omitempty"` + EmailVerification *AuthEmailVerificationManifest `yaml:"email_verification,omitempty" json:"email_verification,omitempty"` + Cors *AuthCORSManifest `yaml:"cors,omitempty" json:"cors,omitempty"` + Providers *AuthProvidersManifest `yaml:"providers,omitempty" json:"providers,omitempty"` + Email *AuthEmailManifest `yaml:"email,omitempty" json:"email,omitempty"` + ManagedPages *AuthManagedPagesManifest `yaml:"managed_pages,omitempty" json:"managed_pages,omitempty"` +} + +// AuthTokensManifest declares token lifetimes in seconds. +type AuthTokensManifest struct { + AccessTokenLifetime *int `yaml:"access_token_lifetime,omitempty" json:"access_token_lifetime,omitempty"` + RefreshTokenLifetime *int `yaml:"refresh_token_lifetime,omitempty" json:"refresh_token_lifetime,omitempty"` + RefreshTokenReuseInterval *int `yaml:"refresh_token_reuse_interval,omitempty" json:"refresh_token_reuse_interval,omitempty"` + PlatformTokenTTL *int `yaml:"platform_token_ttl,omitempty" json:"platform_token_ttl,omitempty"` +} + +// AuthSessionsManifest declares session lifetime limits. +type AuthSessionsManifest struct { + InactivityTimeout *int `yaml:"inactivity_timeout,omitempty" json:"inactivity_timeout,omitempty"` + MaxSessionDuration *int `yaml:"max_session_duration,omitempty" json:"max_session_duration,omitempty"` +} + +// AuthSignupManifest declares signup and access control switches. +type AuthSignupManifest struct { + EnableSignup *bool `yaml:"enable_signup,omitempty" json:"enable_signup,omitempty"` + EnableAnonymousSignins *bool `yaml:"enable_anonymous_signins,omitempty" json:"enable_anonymous_signins,omitempty"` +} + +// AuthRateLimitsManifest declares per-hour auth rate limits. +type AuthRateLimitsManifest struct { + Signup *int `yaml:"signup,omitempty" json:"signup,omitempty"` + Signin *int `yaml:"signin,omitempty" json:"signin,omitempty"` + TokenRefresh *int `yaml:"token_refresh,omitempty" json:"token_refresh,omitempty"` + PasswordReset *int `yaml:"password_reset,omitempty" json:"password_reset,omitempty"` +} + +// AuthPasswordManifest declares password strength requirements. +type AuthPasswordManifest struct { + MinLength *int `yaml:"min_length,omitempty" json:"min_length,omitempty"` + RequireUppercase *bool `yaml:"require_uppercase,omitempty" json:"require_uppercase,omitempty"` + RequireLowercase *bool `yaml:"require_lowercase,omitempty" json:"require_lowercase,omitempty"` + RequireNumbers *bool `yaml:"require_numbers,omitempty" json:"require_numbers,omitempty"` + RequireSpecialChars *bool `yaml:"require_special_chars,omitempty" json:"require_special_chars,omitempty"` +} + +// AuthPasswordResetManifest declares password reset behavior. +type AuthPasswordResetManifest struct { + Allow *bool `yaml:"allow,omitempty" json:"allow,omitempty"` + Timeout *int `yaml:"timeout,omitempty" json:"timeout,omitempty"` + MaxHistory *int `yaml:"max_history,omitempty" json:"max_history,omitempty"` +} + +// AuthEmailVerificationManifest declares email confirmation behavior. +type AuthEmailVerificationManifest struct { + RequireConfirmation *bool `yaml:"require_confirmation,omitempty" json:"require_confirmation,omitempty"` + ConfirmationTimeout *int `yaml:"confirmation_timeout,omitempty" json:"confirmation_timeout,omitempty"` +} + +// AuthCORSManifest declares auth CORS settings. +type AuthCORSManifest struct { + Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + AllowedOrigins *[]string `yaml:"allowed_origins,omitempty" json:"allowed_origins,omitempty"` + AllowCredentials *bool `yaml:"allow_credentials,omitempty" json:"allow_credentials,omitempty"` + MaxAge *int `yaml:"max_age,omitempty" json:"max_age,omitempty"` +} + +// AuthProvidersManifest declares sign-in providers. +type AuthProvidersManifest struct { + EmailPassword *AuthEmailPasswordManifest `yaml:"email_password,omitempty" json:"email_password,omitempty"` + Oauth *[]OAuthProviderManifest `yaml:"oauth,omitempty" json:"oauth,omitempty"` +} + +// AuthEmailPasswordManifest declares the email/password provider switch. +type AuthEmailPasswordManifest struct { + Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` +} + +// OAuthProviderManifest declares one OAuth provider configuration. +type OAuthProviderManifest struct { + Provider string `yaml:"provider" json:"provider"` + Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + ClientID *string `yaml:"client_id,omitempty" json:"client_id,omitempty"` + ClientSecret *string `yaml:"client_secret,omitempty" json:"client_secret,omitempty"` + RedirectURL *string `yaml:"redirect_url,omitempty" json:"redirect_url,omitempty"` + Scopes *[]string `yaml:"scopes,omitempty" json:"scopes,omitempty"` +} + +// AuthEmailManifest declares transactional email settings. +type AuthEmailManifest struct { + Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + From *AuthEmailFromManifest `yaml:"from,omitempty" json:"from,omitempty"` + SMTP *AuthEmailSMTPManifest `yaml:"smtp,omitempty" json:"smtp,omitempty"` + Templates *EmailTemplatesManifest `yaml:"templates,omitempty" json:"templates,omitempty"` +} + +// AuthEmailFromManifest declares the sender identity. +type AuthEmailFromManifest struct { + Address *string `yaml:"address,omitempty" json:"address,omitempty"` + Name *string `yaml:"name,omitempty" json:"name,omitempty"` } -// FunctionManifest declares the desired visibility for one deployed function. +// AuthEmailSMTPManifest declares SMTP delivery settings. +type AuthEmailSMTPManifest struct { + Host *string `yaml:"host,omitempty" json:"host,omitempty"` + Port *int `yaml:"port,omitempty" json:"port,omitempty"` + Username *string `yaml:"username,omitempty" json:"username,omitempty"` + Password *string `yaml:"password,omitempty" json:"password,omitempty"` + UseTLS *bool `yaml:"use_tls,omitempty" json:"use_tls,omitempty"` +} + +// EmailTemplatesManifest declares email templates keyed by type. +type EmailTemplatesManifest struct { + Confirmation *EmailTemplateManifest `yaml:"confirmation,omitempty" json:"confirmation,omitempty"` + PasswordReset *EmailTemplateManifest `yaml:"password_reset,omitempty" json:"password_reset,omitempty"` + PasswordChanged *EmailTemplateManifest `yaml:"password_changed,omitempty" json:"password_changed,omitempty"` + Welcome *EmailTemplateManifest `yaml:"welcome,omitempty" json:"welcome,omitempty"` +} + +// EmailTemplateManifest declares one email template. +type EmailTemplateManifest struct { + Subject *string `yaml:"subject,omitempty" json:"subject,omitempty"` + HTMLBody *string `yaml:"html_body,omitempty" json:"html_body,omitempty"` + TextBody *string `yaml:"text_body,omitempty" json:"text_body,omitempty"` +} + +// AuthManagedPagesManifest declares managed auth page settings. +type AuthManagedPagesManifest struct { + Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + Redirects *AuthRedirectsManifest `yaml:"redirects,omitempty" json:"redirects,omitempty"` + Pages *HostedPagesManifest `yaml:"pages,omitempty" json:"pages,omitempty"` +} + +// AuthRedirectsManifest declares the redirect allowlist and defaults. +type AuthRedirectsManifest struct { + Allowed *[]string `yaml:"allowed,omitempty" json:"allowed,omitempty"` + PostAuth *string `yaml:"post_auth,omitempty" json:"post_auth,omitempty"` + PostLogout *string `yaml:"post_logout,omitempty" json:"post_logout,omitempty"` + DeviceVerification *string `yaml:"device_verification,omitempty" json:"device_verification,omitempty"` +} + +// HostedPagesManifest declares hosted auth pages keyed by page type. +type HostedPagesManifest struct { + Login *HostedPageManifest `yaml:"login,omitempty" json:"login,omitempty"` + ResetPassword *HostedPageManifest `yaml:"reset_password,omitempty" json:"reset_password,omitempty"` +} + +// HostedPageManifest declares one hosted auth page. +type HostedPageManifest struct { + HTML string `yaml:"html" json:"html"` + CSS *string `yaml:"css,omitempty" json:"css,omitempty"` +} + +// FunctionManifest declares configuration for one deployed function. type FunctionManifest struct { - Name string `yaml:"name"` - Public *bool `yaml:"public,omitempty"` - Schedulers []SchedulerManifest `yaml:"schedulers,omitempty"` + Name string `yaml:"name" json:"name"` + Public *bool `yaml:"public,omitempty" json:"public,omitempty"` + Schedulers *[]SchedulerManifest `yaml:"schedulers,omitempty" json:"schedulers,omitempty"` } -// SchedulerManifest declares one scheduler attached to a function. +// SchedulerManifest declares one scheduler attached to a function. Regions is +// parsed only to reject manifests using the removed field with a clear error. type SchedulerManifest struct { - Name string `yaml:"name"` - Cron string `yaml:"cron"` - Enabled *bool `yaml:"enabled,omitempty"` - Payload map[string]any `yaml:"payload,omitempty"` - Regions []string `yaml:"regions,omitempty"` + Name string `yaml:"name" json:"name"` + Cron string `yaml:"cron" json:"cron"` + Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + Payload *map[string]any `yaml:"payload,omitempty" json:"payload,omitempty"` + Regions *[]string `yaml:"regions,omitempty" json:"-"` } -// Load reads, parses, and validates a manifest from disk. The returned path is -// the absolute path that was actually opened. +// FrontendManifest declares configuration for one deployed frontend. +type FrontendManifest struct { + Name string `yaml:"name" json:"name"` + CustomDomain *CustomDomainManifest `yaml:"custom_domain,omitempty" json:"custom_domain,omitempty"` +} + +// CustomDomainManifest declares a frontend custom domain with BYOC TLS. +type CustomDomainManifest struct { + Domain string `yaml:"domain" json:"domain"` + TLS *TLSManifest `yaml:"tls,omitempty" json:"tls,omitempty"` +} + +// TLSManifest declares BYOC TLS material for a custom domain. +type TLSManifest struct { + Mode string `yaml:"mode" json:"mode"` + CertificatePEM string `yaml:"certificate_pem" json:"certificate_pem"` + PrivateKeyPEM string `yaml:"private_key_pem" json:"private_key_pem"` + CertificateChainPEM *string `yaml:"certificate_chain_pem,omitempty" json:"certificate_chain_pem,omitempty"` +} + +// Load reads, interpolates, parses, and validates a manifest from disk. The +// returned path is the absolute path that was actually opened. func Load(filePath string) (*Manifest, string, error) { if strings.TrimSpace(filePath) == "" { return nil, "", errors.New("file path is required") @@ -78,20 +307,92 @@ func Load(filePath string) (*Manifest, string, error) { return nil, "", fmt.Errorf("failed to read configuration file %q: %w", resolved, err) } + manifest, err := Parse(data, os.LookupEnv) + if err != nil { + return nil, "", fmt.Errorf("%s: %w", resolved, err) + } + return manifest, resolved, nil +} + +// Parse interpolates ${ENV} references through lookupEnv, decodes the manifest +// strictly (unknown fields are errors), and validates it. +func Parse(data []byte, lookupEnv func(string) (string, bool)) (*Manifest, error) { + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } + if root.Kind == 0 { + return nil, errors.New("manifest is empty") + } + + if err := interpolateNode(&root, lookupEnv); err != nil { + return nil, err + } + + interpolated, err := yaml.Marshal(&root) + if err != nil { + return nil, fmt.Errorf("failed to re-encode manifest after interpolation: %w", err) + } + + decoder := yaml.NewDecoder(bytes.NewReader(interpolated)) + decoder.KnownFields(true) var manifest Manifest - if err := yaml.Unmarshal(data, &manifest); err != nil { - return nil, "", fmt.Errorf("failed to parse YAML in %q: %w", resolved, err) + if err := decoder.Decode(&manifest); err != nil { + return nil, fmt.Errorf("invalid manifest: %w", err) } if err := manifest.Validate(); err != nil { - return nil, "", err + return nil, err } - return &manifest, resolved, nil + // Capture the interpolated manifest as a generic shape for upload so omitted + // fields stay absent (see uploadBody and the Manifest doc comment). + if err := yaml.Unmarshal(interpolated, &manifest.upload); err != nil { + return nil, fmt.Errorf("failed to prepare manifest for upload: %w", err) + } + return &manifest, nil } -// ResolveManifestPath returns the manifest path that `config deploy` should -// load. An explicit path is used as-is (after existence check); otherwise the +// uploadBody returns the apply request body: the interpolated manifest marshaled +// from its generic shape. Marshaling the generic shape rather than the typed +// struct preserves omitted fields as absent — a non-pointer field such as +// VariableManifest.Value would otherwise serialize an omitted value as "" and +// bypass the server's required-field validation, silently clearing the variable. +func (m *Manifest) uploadBody() ([]byte, error) { + if m.upload == nil { + return nil, errors.New("manifest was not parsed") + } + body, err := json.Marshal(m.upload) + if err != nil { + return nil, fmt.Errorf("failed to encode manifest: %w", err) + } + return body, nil +} + +// Validate performs the minimal local checks: the schema version and the +// removed scheduler regions field. All semantic validation is server-side. +func (m *Manifest) Validate() error { + if m.Version != ManifestVersion { + return fmt.Errorf("unsupported manifest version %d (expected %d)", m.Version, ManifestVersion) + } + if m.Functions == nil { + return nil + } + for _, function := range *m.Functions { + if function.Schedulers == nil { + continue + } + for _, scheduler := range *function.Schedulers { + if scheduler.Regions != nil { + return fmt.Errorf("function %q scheduler %q: the schedulers %q field is no longer supported; scheduler placement is managed by the server — remove the field and re-run", function.Name, scheduler.Name, "regions") + } + } + } + return nil +} + +// ResolveManifestPath returns the manifest path that config commands should +// use. An explicit path is used as-is (after existence check); otherwise the // CLI looks for volcano/volcano-config.yaml then volcano-config.yaml in the // working directory. func ResolveManifestPath(fileArg string) (string, error) { @@ -120,150 +421,19 @@ func ResolveManifestPath(fileArg string) (string, error) { } } -// Validate checks the manifest for structural and semantic errors, normalizing -// field values (trimming, uppercasing operations, dropping empty MIME entries) -// in place so callers receive a ready-to-deploy view. -func (m *Manifest) Validate() error { - if m.Version != ManifestVersion { - return fmt.Errorf("unsupported manifest version %d (expected %d)", m.Version, ManifestVersion) - } - - if len(m.Buckets) == 0 && len(m.Functions) == 0 { - return errors.New("manifest must include at least one bucket or function") - } - - if err := validateBuckets(m.Buckets); err != nil { - return err - } - if err := validateFunctions(m.Functions); err != nil { - return err - } - return validateAllSchedulers(m.Functions) -} - -func validateBuckets(buckets []BucketManifest) error { - seen := make(map[string]struct{}, len(buckets)) - for i := range buckets { - bucket := &buckets[i] - bucket.Name = strings.TrimSpace(bucket.Name) - if bucket.Name == "" { - return errors.New("bucket name is required") - } - if _, exists := seen[bucket.Name]; exists { - return fmt.Errorf("duplicate bucket name %q in manifest", bucket.Name) - } - seen[bucket.Name] = struct{}{} - - if bucket.FileSizeLimit != nil && *bucket.FileSizeLimit <= 0 { - return fmt.Errorf("bucket %q: file_size_limit must be greater than 0", bucket.Name) - } - - if bucket.AllowedMimeTypes != nil { - normalized := normalizeMIMEList(*bucket.AllowedMimeTypes) - if len(*bucket.AllowedMimeTypes) > 0 && len(normalized) == 0 { - return fmt.Errorf("bucket %q: allowed_mime_types contains only empty values", bucket.Name) - } - bucket.AllowedMimeTypes = &normalized - } - - if err := validatePolicies(bucket); err != nil { - return err - } - } - return nil -} - -func validatePolicies(bucket *BucketManifest) error { - seen := make(map[string]struct{}, len(bucket.Policies)) - for j := range bucket.Policies { - policy := &bucket.Policies[j] - policy.Name = strings.TrimSpace(policy.Name) - if policy.Name == "" { - return fmt.Errorf("bucket %q: policy name is required", bucket.Name) - } - if _, exists := seen[policy.Name]; exists { - return fmt.Errorf("bucket %q: duplicate policy name %q", bucket.Name, policy.Name) - } - seen[policy.Name] = struct{}{} - - policy.Operation = strings.ToUpper(strings.TrimSpace(policy.Operation)) - if !apicommon.CreateStoragePolicyRequestOperation(policy.Operation).Valid() { - return fmt.Errorf("bucket %q policy %q: operation must be SELECT, INSERT, UPDATE, or DELETE", bucket.Name, policy.Name) - } - - policy.Definition = strings.TrimSpace(policy.Definition) - if policy.Definition == "" { - return fmt.Errorf("bucket %q policy %q: definition is required", bucket.Name, policy.Name) - } - } - return nil -} - -func validateFunctions(functions []FunctionManifest) error { - seen := make(map[string]struct{}, len(functions)) - for i := range functions { - function := &functions[i] - function.Name = strings.TrimSpace(function.Name) - if function.Name == "" { - return errors.New("function name is required") - } - if _, exists := seen[function.Name]; exists { - return fmt.Errorf("duplicate function name %q in manifest", function.Name) - } - seen[function.Name] = struct{}{} - // A function entry is valid if it sets public OR declares at least one scheduler - if function.Public == nil && len(function.Schedulers) == 0 { - return fmt.Errorf("function %q: must set 'public' or declare at least one scheduler", function.Name) - } - } - return nil -} - -func validateAllSchedulers(functions []FunctionManifest) error { - for i := range functions { - if err := validateSchedulers(&functions[i]); err != nil { - return err - } - } - return nil -} - -func validateSchedulers(function *FunctionManifest) error { - if len(function.Schedulers) == 0 { - return nil - } - - seen := make(map[string]struct{}, len(function.Schedulers)) - for j := range function.Schedulers { - scheduler := &function.Schedulers[j] - scheduler.Name = strings.TrimSpace(scheduler.Name) - if scheduler.Name == "" { - return fmt.Errorf("function %q: scheduler name is required", function.Name) - } - if _, exists := seen[scheduler.Name]; exists { - return fmt.Errorf("function %q: duplicate scheduler name %q", function.Name, scheduler.Name) - } - seen[scheduler.Name] = struct{}{} - - scheduler.Cron = strings.TrimSpace(scheduler.Cron) - if scheduler.Cron == "" { - return fmt.Errorf("function %q scheduler %q: cron is required", function.Name, scheduler.Name) +// DefaultPullPath returns where `config pull` writes when no --file is given: +// an existing manifest location if one is found, else volcano/volcano-config.yaml +// when the volcano directory exists, else ./volcano-config.yaml. +func DefaultPullPath() string { + for _, candidate := range []string{nestedManifestPath, rootManifestPath} { + if fileExists(candidate) { + return candidate } - // Do not parse/validate cron client-side; the server owns cron/region rules } - return nil -} - -func normalizeMIMEList(values []string) []string { - normalized := make([]string, 0, len(values)) - for _, value := range values { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - continue - } - normalized = append(normalized, trimmed) + if info, err := os.Stat(manifestDir); err == nil && info.IsDir() { + return nestedManifestPath } - return normalized + return rootManifestPath } func fileExists(path string) bool { diff --git a/internal/projectconfig/manifest_test.go b/internal/projectconfig/manifest_test.go index f9e9668..c9e2ee2 100644 --- a/internal/projectconfig/manifest_test.go +++ b/internal/projectconfig/manifest_test.go @@ -1,6 +1,7 @@ package projectconfig import ( + "encoding/json" "os" "path/filepath" "testing" @@ -9,290 +10,551 @@ import ( "github.com/stretchr/testify/require" ) -func TestValidate(t *testing.T) { - limit := int64(1024) - zero := int64(0) - pub := true - priv := false +func noEnv(string) (string, bool) { return "", false } +func envMap(values map[string]string) func(string) (string, bool) { + return func(name string) (string, bool) { + value, ok := values[name] + return value, ok + } +} + +func TestParseFullManifest(t *testing.T) { + manifest, err := Parse([]byte(`version: 1 +project: + name: my-app + all_regions: false + selected_regions: [us-east-1, us-west-2] +databases: + - name: appdb + region: aws-us-east-1 + pg_version: "16" + database_type: volcano-db-xs +variables: + - name: STRIPE_SECRET_KEY + value: sk_test_123 +buckets: + - name: avatars + file_size_limit: 5242880 + allowed_mime_types: [image/png] + policies: + - name: public-read + operation: SELECT + definition: "true" +realtime: + enabled: true + broadcast_enabled: false +auth: + tokens: + access_token_lifetime: 3600 + signup: + enable_signup: true + providers: + email_password: + enabled: true + oauth: + - provider: google + enabled: true + client_id: cid + client_secret: secret + redirect_url: https://api.myapp.com/callback + scopes: [openid, email] + - provider: device + enabled: true + email: + enabled: true + from: + address: no-reply@myapp.com + name: My App + smtp: + host: smtp.example.com + port: 587 + username: mailer + password: hunter2 + use_tls: true + templates: + confirmation: + subject: "Confirm" + html_body: "
{{.Token}}
" + text_body: "{{.Token}}" + welcome: + subject: "Welcome" + managed_pages: + enabled: true + redirects: + allowed: [https://myapp.com/welcome] + post_auth: https://myapp.com/welcome + pages: + login: + html: "" + css: "body{}" +functions: + - name: hello + public: true + schedulers: + - name: nightly + cron: "0 3 * * *" + enabled: true + payload: { source: cron } +frontends: + - name: web + custom_domain: + domain: app.myapp.com + tls: + mode: byoc + certificate_pem: CERT + private_key_pem: KEY + certificate_chain_pem: CHAIN +`), noEnv) + require.NoError(t, err) + + assert.Equal(t, 1, manifest.Version) + require.NotNil(t, manifest.Project) + assert.Equal(t, "my-app", *manifest.Project.Name) + require.NotNil(t, manifest.Project.AllRegions) + assert.False(t, *manifest.Project.AllRegions) + assert.Equal(t, []string{"us-east-1", "us-west-2"}, *manifest.Project.SelectedRegions) + + require.NotNil(t, manifest.Databases) + require.Len(t, *manifest.Databases, 1) + assert.Equal(t, "16", (*manifest.Databases)[0].PgVersion) + assert.Equal(t, "volcano-db-xs", *(*manifest.Databases)[0].DatabaseType) + + require.NotNil(t, manifest.Variables) + assert.Equal(t, "sk_test_123", (*manifest.Variables)[0].Value) + + require.NotNil(t, manifest.Buckets) + bucket := (*manifest.Buckets)[0] + assert.EqualValues(t, 5242880, *bucket.FileSizeLimit) + require.NotNil(t, bucket.Policies) + assert.Equal(t, "SELECT", (*bucket.Policies)[0].Operation) + + require.NotNil(t, manifest.Realtime) + assert.True(t, *manifest.Realtime.Enabled) + assert.False(t, *manifest.Realtime.BroadcastEnabled) + assert.Nil(t, manifest.Realtime.PresenceEnabled) + + require.NotNil(t, manifest.Auth) + assert.Equal(t, 3600, *manifest.Auth.Tokens.AccessTokenLifetime) + require.NotNil(t, manifest.Auth.Providers.Oauth) + oauth := *manifest.Auth.Providers.Oauth + require.Len(t, oauth, 2) + assert.Equal(t, "google", oauth[0].Provider) + assert.Equal(t, "secret", *oauth[0].ClientSecret) + assert.Equal(t, "device", oauth[1].Provider) + assert.Equal(t, "hunter2", *manifest.Auth.Email.SMTP.Password) + assert.Equal(t, "Confirm", *manifest.Auth.Email.Templates.Confirmation.Subject) + assert.Nil(t, manifest.Auth.Email.Templates.PasswordReset) + assert.Equal(t, "", manifest.Auth.ManagedPages.Pages.Login.HTML) + + require.NotNil(t, manifest.Functions) + function := (*manifest.Functions)[0] + assert.True(t, *function.Public) + require.NotNil(t, function.Schedulers) + scheduler := (*function.Schedulers)[0] + assert.Equal(t, "0 3 * * *", scheduler.Cron) + require.NotNil(t, scheduler.Payload) + assert.Equal(t, map[string]any{"source": "cron"}, *scheduler.Payload) + + require.NotNil(t, manifest.Frontends) + frontend := (*manifest.Frontends)[0] + require.NotNil(t, frontend.CustomDomain) + assert.Equal(t, "app.myapp.com", frontend.CustomDomain.Domain) + require.NotNil(t, frontend.CustomDomain.TLS) + assert.Equal(t, "byoc", frontend.CustomDomain.TLS.Mode) + assert.Equal(t, "CHAIN", *frontend.CustomDomain.TLS.CertificateChainPEM) +} + +func TestParseErrors(t *testing.T) { tests := []struct { name string - manifest Manifest + yaml string errContains string }{ { - name: "valid buckets only", - manifest: Manifest{ - Version: 1, - Buckets: []BucketManifest{{Name: "uploads"}}, - }, - }, - { - name: "valid functions only", - manifest: Manifest{ - Version: 1, - Functions: []FunctionManifest{{Name: "hello", Public: &pub}}, - }, - }, - { - name: "version 0 rejected", - manifest: Manifest{ - Buckets: []BucketManifest{{Name: "uploads"}}, - }, + name: "version 0 rejected", + yaml: "buckets:\n - name: uploads\n", errContains: "unsupported manifest version 0", }, { name: "version 2 rejected", - manifest: Manifest{Version: 2, Buckets: []BucketManifest{{Name: "uploads"}}}, + yaml: "version: 2\n", errContains: "unsupported manifest version 2", }, { - name: "empty manifest rejected", - manifest: Manifest{Version: 1}, - errContains: "must include at least one bucket or function", - }, - { - name: "duplicate bucket name", - manifest: Manifest{ - Version: 1, - Buckets: []BucketManifest{{Name: "uploads"}, {Name: "uploads"}}, - }, - errContains: `duplicate bucket name "uploads"`, - }, - { - name: "blank bucket name", - manifest: Manifest{ - Version: 1, - Buckets: []BucketManifest{{Name: " "}}, - }, - errContains: "bucket name is required", - }, - { - name: "non-positive file size limit", - manifest: Manifest{ - Version: 1, - Buckets: []BucketManifest{{Name: "uploads", FileSizeLimit: &zero}}, - }, - errContains: "file_size_limit must be greater than 0", - }, - { - name: "all-empty allowed mime types", - manifest: Manifest{ - Version: 1, - Buckets: []BucketManifest{ - {Name: "uploads", AllowedMimeTypes: &[]string{"", " "}}, - }, - }, - errContains: "contains only empty values", - }, - { - name: "duplicate policy name within bucket", - manifest: Manifest{ - Version: 1, - Buckets: []BucketManifest{{ - Name: "uploads", - Policies: []PolicyManifest{ - {Name: "owner", Operation: "select", Definition: "true"}, - {Name: "owner", Operation: "insert", Definition: "true"}, - }, - }}, - }, - errContains: `duplicate policy name "owner"`, - }, - { - name: "invalid policy operation", - manifest: Manifest{ - Version: 1, - Buckets: []BucketManifest{{ - Name: "uploads", - Policies: []PolicyManifest{ - {Name: "owner", Operation: "READ", Definition: "true"}, - }, - }}, - }, - errContains: "operation must be SELECT, INSERT, UPDATE, or DELETE", - }, - { - name: "missing policy definition", - manifest: Manifest{ - Version: 1, - Buckets: []BucketManifest{{ - Name: "uploads", - Policies: []PolicyManifest{ - {Name: "owner", Operation: "select"}, - }, - }}, - }, - errContains: "definition is required", - }, - { - name: "missing function public flag and no schedulers", - manifest: Manifest{ - Version: 1, - Functions: []FunctionManifest{{Name: "hello"}}, - }, - errContains: `function "hello": must set 'public' or declare at least one scheduler`, + name: "empty manifest", + yaml: "", + errContains: "manifest is empty", }, { - name: "function with schedulers only (no public)", - manifest: Manifest{ - Version: 1, - Functions: []FunctionManifest{{ - Name: "hello", - Schedulers: []SchedulerManifest{{ - Name: "daily", - Cron: "0 0 * * *", - }}, - }}, - }, + name: "invalid yaml", + yaml: "not: valid: yaml: :", + errContains: "failed to parse YAML", }, { - name: "function with both public and schedulers", - manifest: Manifest{ - Version: 1, - Functions: []FunctionManifest{{ - Name: "hello", - Public: &pub, - Schedulers: []SchedulerManifest{{ - Name: "hourly", - Cron: "0 * * * *", - }}, - }}, - }, + name: "unknown field rejected", + yaml: "version: 1\nbuckets:\n - name: uploads\n file_size: 10\n", + errContains: "field file_size not found", }, { - name: "scheduler missing name", - manifest: Manifest{ - Version: 1, - Functions: []FunctionManifest{{ - Name: "hello", - Public: &pub, - Schedulers: []SchedulerManifest{{ - Cron: "0 0 * * *", - }}, - }}, - }, - errContains: `function "hello": scheduler name is required`, - }, - { - name: "scheduler missing cron", - manifest: Manifest{ - Version: 1, - Functions: []FunctionManifest{{ - Name: "hello", - Public: &pub, - Schedulers: []SchedulerManifest{{ - Name: "daily", - }}, - }}, - }, - errContains: `function "hello" scheduler "daily": cron is required`, - }, - { - name: "duplicate scheduler name", - manifest: Manifest{ - Version: 1, - Functions: []FunctionManifest{{ - Name: "hello", - Public: &pub, - Schedulers: []SchedulerManifest{ - {Name: "daily", Cron: "0 0 * * *"}, - {Name: "daily", Cron: "0 12 * * *"}, - }, - }}, - }, - errContains: `function "hello": duplicate scheduler name "daily"`, - }, - { - name: "duplicate function name", - manifest: Manifest{ - Version: 1, - Functions: []FunctionManifest{ - {Name: "hello", Public: &pub}, - {Name: "hello", Public: &priv}, - }, - }, - errContains: `duplicate function name "hello"`, - }, - { - name: "scheduler with full fields", - manifest: Manifest{ - Version: 1, - Functions: []FunctionManifest{{ - Name: "hello", - Public: &pub, - Schedulers: []SchedulerManifest{{ - Name: "daily", - Cron: "0 0 * * *", - Enabled: &pub, - Payload: map[string]any{"key": "value"}, - Regions: []string{"us-east-1"}, - }}, - }}, - }, - }, - { - name: "normalization: operation lowercase, file size limit kept", - manifest: Manifest{ - Version: 1, - Buckets: []BucketManifest{{ - Name: "uploads", - FileSizeLimit: &limit, - Policies: []PolicyManifest{ - {Name: "owner", Operation: "select", Definition: "true"}, - }, - }}, - }, + name: "scheduler regions rejected", + yaml: `version: 1 +functions: + - name: hello + schedulers: + - name: nightly + cron: "0 3 * * *" + regions: [aws-us-east-1] +`, + errContains: `scheduler placement is managed by the server`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - m := tc.manifest - err := m.Validate() - if tc.errContains != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.errContains) - return - } - require.NoError(t, err) + _, err := Parse([]byte(tc.yaml), noEnv) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errContains) }) } } -func TestValidateNormalizesPolicyOperationAndMIME(t *testing.T) { - m := Manifest{ - Version: 1, - Buckets: []BucketManifest{{ - Name: "uploads", - AllowedMimeTypes: &[]string{"image/png", " ", "image/jpeg"}, - Policies: []PolicyManifest{ - {Name: "owner", Operation: " select ", Definition: " true "}, - }, - }}, +func TestParseInterpolation(t *testing.T) { + lookup := envMap(map[string]string{ + "SECRET": "s3cr3t", + "EMPTY": "", + "REGION_1": "us-east-1", + }) + + t.Run("values interpolated", func(t *testing.T) { + manifest, err := Parse([]byte(`version: 1 +variables: + - name: API_KEY + value: ${SECRET} + - name: COMPOSED + value: pre-${SECRET}-post + - name: SET_BUT_EMPTY + value: ${EMPTY} +`), lookup) + require.NoError(t, err) + variables := *manifest.Variables + assert.Equal(t, "s3cr3t", variables[0].Value) + assert.Equal(t, "pre-s3cr3t-post", variables[1].Value) + assert.Empty(t, variables[2].Value) + }) + + t.Run("dollar escape", func(t *testing.T) { + manifest, err := Parse([]byte(`version: 1 +variables: + - name: LITERAL + value: cost is $$5 for ${SECRET} + - name: LONE_DOLLAR + value: 5$ and $techno +`), lookup) + require.NoError(t, err) + variables := *manifest.Variables + assert.Equal(t, "cost is $5 for s3cr3t", variables[0].Value) + assert.Equal(t, "5$ and $techno", variables[1].Value) + }) + + t.Run("interpolates nested strings", func(t *testing.T) { + manifest, err := Parse([]byte(`version: 1 +auth: + email: + smtp: + password: ${SECRET} +functions: + - name: hello + schedulers: + - name: nightly + cron: "0 3 * * *" + payload: { key: "${SECRET}" } +project: + selected_regions: ["${REGION_1}"] +`), lookup) + require.NoError(t, err) + assert.Equal(t, "s3cr3t", *manifest.Auth.Email.SMTP.Password) + payload := *(*(*manifest.Functions)[0].Schedulers)[0].Payload + assert.Equal(t, "s3cr3t", payload["key"]) + assert.Equal(t, []string{"us-east-1"}, *manifest.Project.SelectedRegions) + }) + + t.Run("keys are not interpolated", func(t *testing.T) { + manifest, err := Parse([]byte(`version: 1 +functions: + - name: hello + schedulers: + - name: nightly + cron: "0 3 * * *" + payload: + "${SECRET}": raw-key +`), lookup) + require.NoError(t, err) + payload := *(*(*manifest.Functions)[0].Schedulers)[0].Payload + assert.Equal(t, "raw-key", payload["${SECRET}"]) + }) + + t.Run("missing variable is an error", func(t *testing.T) { + _, err := Parse([]byte("version: 1\nvariables:\n - name: A\n value: ${MISSING_VAR}\n"), lookup) + require.Error(t, err) + assert.Contains(t, err.Error(), `environment variable "MISSING_VAR" is not set`) + }) + + t.Run("unterminated reference is an error", func(t *testing.T) { + _, err := Parse([]byte("version: 1\nvariables:\n - name: A\n value: ${SECRET\n"), lookup) + require.Error(t, err) + assert.Contains(t, err.Error(), "unterminated ${...} reference") + }) + + t.Run("empty reference is an error", func(t *testing.T) { + _, err := Parse([]byte("version: 1\nvariables:\n - name: A\n value: ${}\n"), lookup) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty ${} reference") + }) + + t.Run("non-string scalars untouched", func(t *testing.T) { + manifest, err := Parse([]byte("version: 1\nrealtime:\n enabled: true\n"), noEnv) + require.NoError(t, err) + assert.True(t, *manifest.Realtime.Enabled) + }) +} + +// TestManifestJSONShape guards the JSON contract with the apply endpoint: +// omitted sections are absent, declared-empty lists serialize as [] (full +// sync deletes everything), and the rejected regions field never uploads. +func TestManifestJSONShape(t *testing.T) { + manifest, err := Parse([]byte(`version: 1 +variables: [] +buckets: + - name: avatars + policies: [] +functions: + - name: hello + schedulers: + - name: nightly + cron: "0 3 * * *" +`), noEnv) + require.NoError(t, err) + + encoded, err := manifest.uploadBody() + require.NoError(t, err) + + var body map[string]any + require.NoError(t, json.Unmarshal(encoded, &body)) + + assert.EqualValues(t, 1, body["version"]) + assert.Equal(t, []any{}, body["variables"]) + assert.NotContains(t, body, "auth") + assert.NotContains(t, body, "realtime") + assert.NotContains(t, body, "project") + assert.NotContains(t, body, "databases") + assert.NotContains(t, body, "frontends") + + buckets, ok := body["buckets"].([]any) + require.True(t, ok) + bucket, ok := buckets[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, []any{}, bucket["policies"]) + assert.NotContains(t, bucket, "file_size_limit") + + functions, ok := body["functions"].([]any) + require.True(t, ok) + function, ok := functions[0].(map[string]any) + require.True(t, ok) + schedulers, ok := function["schedulers"].([]any) + require.True(t, ok) + scheduler, ok := schedulers[0].(map[string]any) + require.True(t, ok) + assert.NotContains(t, scheduler, "regions") + assert.NotContains(t, scheduler, "enabled") +} + +// TestManifestUploadPreservesVariableValueAbsence guards that an omitted variable +// value uploads as an absent field (so the server's required-field validation +// rejects the typo) rather than an empty string that would silently clear the +// variable, while an explicit empty value is preserved as such. +func TestManifestUploadPreservesVariableValueAbsence(t *testing.T) { + uploadedVariable := func(t *testing.T, yamlDoc string) map[string]any { + t.Helper() + manifest, err := Parse([]byte(yamlDoc), noEnv) + require.NoError(t, err) + body, err := manifest.uploadBody() + require.NoError(t, err) + var decoded map[string]any + require.NoError(t, json.Unmarshal(body, &decoded)) + variables, ok := decoded["variables"].([]any) + require.True(t, ok) + variable, ok := variables[0].(map[string]any) + require.True(t, ok) + return variable } - require.NoError(t, m.Validate()) - bucket := m.Buckets[0] - require.NotNil(t, bucket.AllowedMimeTypes) - assert.Equal(t, []string{"image/png", "image/jpeg"}, *bucket.AllowedMimeTypes) - assert.Equal(t, "SELECT", bucket.Policies[0].Operation) - assert.Equal(t, "true", bucket.Policies[0].Definition) + t.Run("omitted value stays absent", func(t *testing.T) { + variable := uploadedVariable(t, "version: 1\nvariables:\n - name: API_KEY\n") + assert.Equal(t, "API_KEY", variable["name"]) + assert.NotContains(t, variable, "value") + }) + + t.Run("explicit empty value is preserved", func(t *testing.T) { + variable := uploadedVariable(t, "version: 1\nvariables:\n - name: API_KEY\n value: \"\"\n") + assert.Contains(t, variable, "value") + assert.Empty(t, variable["value"]) + }) +} + +// TestManifestUploadFullShape verifies the apply request body emits the complete +// interpolated manifest with the server's snake_case keys and preserves declared +// values across every section, including explicit false booleans — which must +// reach the server rather than be dropped as "empty". This guards the +// generic-shape upload path against silently dropping, renaming, or retyping +// fields. +func TestManifestUploadFullShape(t *testing.T) { + manifest, err := Parse([]byte(`version: 1 +project: + name: my-app + all_regions: false + selected_regions: [us-east-1, us-west-2] +databases: + - name: appdb + region: aws-us-east-1 + pg_version: "16" + database_type: volcano-db-xs +variables: + - name: STRIPE_SECRET_KEY + value: sk_test_123 +buckets: + - name: avatars + file_size_limit: 5242880 + allowed_mime_types: [image/png] + policies: + - name: public-read + operation: SELECT + definition: "true" +realtime: + enabled: true + broadcast_enabled: false +auth: + tokens: + access_token_lifetime: 3600 + providers: + oauth: + - provider: google + enabled: true + client_id: cid + scopes: [openid, email] + email: + smtp: + host: smtp.example.com + port: 587 + managed_pages: + pages: + login: + html: "" + css: "body{}" +functions: + - name: hello + public: true + schedulers: + - name: nightly + cron: "0 3 * * *" + enabled: true + payload: { source: cron } +frontends: + - name: web + custom_domain: + domain: app.myapp.com + tls: + mode: byoc + certificate_pem: CERT + private_key_pem: KEY +`), noEnv) + require.NoError(t, err) + + body, err := manifest.uploadBody() + require.NoError(t, err) + + var m map[string]any + require.NoError(t, json.Unmarshal(body, &m)) + + asMap := func(v any) map[string]any { + got, ok := v.(map[string]any) + require.True(t, ok, "expected object, got %T", v) + return got + } + first := func(v any) any { + got, ok := v.([]any) + require.True(t, ok, "expected array, got %T", v) + require.NotEmpty(t, got) + return got[0] + } + + assert.EqualValues(t, 1, m["version"]) + + // project: an explicitly declared false must be emitted, not dropped. + project := asMap(m["project"]) + assert.Equal(t, "my-app", project["name"]) + assert.Equal(t, false, project["all_regions"]) + assert.Equal(t, []any{"us-east-1", "us-west-2"}, project["selected_regions"]) + + db := asMap(first(m["databases"])) + assert.Equal(t, "appdb", db["name"]) + assert.Equal(t, "16", db["pg_version"]) + assert.Equal(t, "volcano-db-xs", db["database_type"]) + + variable := asMap(first(m["variables"])) + assert.Equal(t, "STRIPE_SECRET_KEY", variable["name"]) + assert.Equal(t, "sk_test_123", variable["value"]) + + bucket := asMap(first(m["buckets"])) + assert.EqualValues(t, 5242880, bucket["file_size_limit"]) + assert.Equal(t, []any{"image/png"}, bucket["allowed_mime_types"]) + policy := asMap(first(bucket["policies"])) + assert.Equal(t, "SELECT", policy["operation"]) + assert.Equal(t, "true", policy["definition"]) + + realtime := asMap(m["realtime"]) + assert.Equal(t, true, realtime["enabled"]) + assert.Equal(t, false, realtime["broadcast_enabled"]) + assert.NotContains(t, realtime, "presence_enabled") + + auth := asMap(m["auth"]) + assert.EqualValues(t, 3600, asMap(auth["tokens"])["access_token_lifetime"]) + oauth := asMap(first(asMap(auth["providers"])["oauth"])) + assert.Equal(t, "google", oauth["provider"]) + assert.Equal(t, true, oauth["enabled"]) + assert.Equal(t, "cid", oauth["client_id"]) + assert.Equal(t, []any{"openid", "email"}, oauth["scopes"]) + smtp := asMap(asMap(auth["email"])["smtp"]) + assert.Equal(t, "smtp.example.com", smtp["host"]) + assert.EqualValues(t, 587, smtp["port"]) + login := asMap(asMap(asMap(auth["managed_pages"])["pages"])["login"]) + assert.Equal(t, "", login["html"]) + assert.Equal(t, "body{}", login["css"]) + + function := asMap(first(m["functions"])) + assert.Equal(t, "hello", function["name"]) + assert.Equal(t, true, function["public"]) + scheduler := asMap(first(function["schedulers"])) + assert.Equal(t, "0 3 * * *", scheduler["cron"]) + assert.Equal(t, true, scheduler["enabled"]) + assert.Equal(t, map[string]any{"source": "cron"}, scheduler["payload"]) + + customDomain := asMap(asMap(first(m["frontends"]))["custom_domain"]) + assert.Equal(t, "app.myapp.com", customDomain["domain"]) + tls := asMap(customDomain["tls"]) + assert.Equal(t, "byoc", tls["mode"]) + assert.Equal(t, "CERT", tls["certificate_pem"]) + assert.Equal(t, "KEY", tls["private_key_pem"]) + assert.NotContains(t, tls, "certificate_chain_pem") } func TestLoad(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "manifest.yaml") require.NoError(t, os.WriteFile(path, []byte(`version: 1 -buckets: - - name: uploads - file_size_limit: 2048 - allowed_mime_types: - - image/png - policies: - - name: owner - operation: select - definition: "auth.uid() = owner_id" +variables: + - name: KEY + value: literal functions: - name: hello public: true @@ -301,33 +563,27 @@ functions: manifest, resolved, err := Load(path) require.NoError(t, err) assert.Equal(t, path, resolved) - require.Len(t, manifest.Buckets, 1) - assert.Equal(t, "uploads", manifest.Buckets[0].Name) - require.NotNil(t, manifest.Buckets[0].FileSizeLimit) - assert.EqualValues(t, 2048, *manifest.Buckets[0].FileSizeLimit) - require.NotNil(t, manifest.Buckets[0].AllowedMimeTypes) - assert.Equal(t, []string{"image/png"}, *manifest.Buckets[0].AllowedMimeTypes) - require.Len(t, manifest.Buckets[0].Policies, 1) - assert.Equal(t, "SELECT", manifest.Buckets[0].Policies[0].Operation) - require.Len(t, manifest.Functions, 1) - require.NotNil(t, manifest.Functions[0].Public) - assert.True(t, *manifest.Functions[0].Public) + require.NotNil(t, manifest.Variables) + assert.Equal(t, "literal", (*manifest.Variables)[0].Value) + require.NotNil(t, manifest.Functions) + assert.True(t, *(*manifest.Functions)[0].Public) } -func TestLoadEmptyPath(t *testing.T) { - _, _, err := Load("") - require.Error(t, err) - assert.Contains(t, err.Error(), "file path is required") -} - -func TestLoadInvalidYAML(t *testing.T) { +func TestLoadInterpolatesFromProcessEnv(t *testing.T) { + t.Setenv("VOLCANO_TEST_MANIFEST_SECRET", "from-env") dir := t.TempDir() path := filepath.Join(dir, "manifest.yaml") - require.NoError(t, os.WriteFile(path, []byte("not: valid: yaml: :"), 0o644)) + require.NoError(t, os.WriteFile(path, []byte("version: 1\nvariables:\n - name: KEY\n value: ${VOLCANO_TEST_MANIFEST_SECRET}\n"), 0o644)) - _, _, err := Load(path) + manifest, _, err := Load(path) + require.NoError(t, err) + assert.Equal(t, "from-env", (*manifest.Variables)[0].Value) +} + +func TestLoadEmptyPath(t *testing.T) { + _, _, err := Load("") require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse YAML") + assert.Contains(t, err.Error(), "file path is required") } func TestLoadMissingFile(t *testing.T) { @@ -337,17 +593,6 @@ func TestLoadMissingFile(t *testing.T) { assert.Contains(t, err.Error(), "failed to read configuration file") } -func TestLoadValidationFailure(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "manifest.yaml") - require.NoError(t, os.WriteFile(path, []byte("version: 1\n"), 0o644)) - - _, _, err := Load(path) - require.Error(t, err) - assert.Contains(t, err.Error(), "must include at least one bucket or function") - assert.NotContains(t, err.Error(), "failed to parse") -} - // withTempWorkingDir chdirs into a fresh temp directory for the test body and // restores the original cwd afterwards. ResolveManifestPath inspects the cwd, // so tests must isolate themselves from each other and from the developer's @@ -423,3 +668,33 @@ func TestResolveManifestPath_ErrorsWhenMissing(t *testing.T) { assert.Contains(t, err.Error(), "no volcano-config.yaml file found") }) } + +func TestDefaultPullPath(t *testing.T) { + t.Run("existing nested manifest wins", func(t *testing.T) { + withTempWorkingDir(t, func(_ string) { + require.NoError(t, os.MkdirAll("volcano", 0o755)) + require.NoError(t, os.WriteFile(filepath.Join("volcano", "volcano-config.yaml"), []byte("version: 1\n"), 0o644)) + assert.Equal(t, filepath.Join("volcano", "volcano-config.yaml"), DefaultPullPath()) + }) + }) + + t.Run("existing root manifest wins", func(t *testing.T) { + withTempWorkingDir(t, func(_ string) { + require.NoError(t, os.WriteFile("volcano-config.yaml", []byte("version: 1\n"), 0o644)) + assert.Equal(t, "volcano-config.yaml", DefaultPullPath()) + }) + }) + + t.Run("volcano directory preferred", func(t *testing.T) { + withTempWorkingDir(t, func(_ string) { + require.NoError(t, os.MkdirAll("volcano", 0o755)) + assert.Equal(t, filepath.Join("volcano", "volcano-config.yaml"), DefaultPullPath()) + }) + }) + + t.Run("falls back to root", func(t *testing.T) { + withTempWorkingDir(t, func(_ string) { + assert.Equal(t, "volcano-config.yaml", DefaultPullPath()) + }) + }) +} diff --git a/internal/projectconfig/service.go b/internal/projectconfig/service.go new file mode 100644 index 0000000..67208cc --- /dev/null +++ b/internal/projectconfig/service.go @@ -0,0 +1,51 @@ +package projectconfig + +import ( + "context" + "errors" + + apicommon "github.com/Kong/volcano-cli/internal/apiclient/common" + cliruntime "github.com/Kong/volcano-cli/internal/runtime" + clisession "github.com/Kong/volcano-cli/internal/session" +) + +// Service uploads and downloads declarative project configuration. All +// reconciliation happens server-side. +type Service struct { + sessions clisession.Factory +} + +// NewService returns a projectconfig service. +func NewService(deps cliruntime.Deps) Service { + return Service{sessions: clisession.NewFactory(deps)} +} + +// Deploy uploads the manifest to the server, which validates and reconciles +// the project configuration. With dryRun the server only reports projected +// actions. +func (s Service) Deploy(ctx context.Context, manifest *Manifest, dryRun bool) (*apicommon.ProjectConfigApplyResult, error) { + if manifest == nil { + return nil, errors.New("manifest is required") + } + + authenticated, err := s.sessions.CurrentProject() + if err != nil { + return nil, err + } + + body, err := manifest.uploadBody() + if err != nil { + return nil, err + } + return authenticated.API.ApplyProjectConfig(ctx, authenticated.ProjectID, body, dryRun) +} + +// Pull downloads the project's current configuration as the server-rendered +// canonical YAML manifest. +func (s Service) Pull(ctx context.Context) ([]byte, error) { + authenticated, err := s.sessions.CurrentProject() + if err != nil { + return nil, err + } + return authenticated.API.GetProjectConfigYAML(ctx, authenticated.ProjectID) +} diff --git a/internal/projectinit/starters/README.md b/internal/projectinit/starters/README.md index c6d2340..ee593a1 100644 --- a/internal/projectinit/starters/README.md +++ b/internal/projectinit/starters/README.md @@ -77,7 +77,9 @@ content and tests cover the conflict/idempotency behavior. ## Configuration Manifests Only include `volcano/volcano-config.yaml` when the starter has at least one -real config resource to manage. An empty manifest like this is invalid: +real config resource to manage. Declared sections are fully synced on deploy, +so a manifest like this is harmful rather than a no-op — an empty declared +`functions:` list reports every deployed function as missing from the manifest: ```yaml version: 1 diff --git a/internal/projectinit/starters/javascript-hello-world/volcano/volcano-config.yaml b/internal/projectinit/starters/javascript-hello-world/volcano/volcano-config.yaml index 1e69e0e..55ea5fe 100644 --- a/internal/projectinit/starters/javascript-hello-world/volcano/volcano-config.yaml +++ b/internal/projectinit/starters/javascript-hello-world/volcano/volcano-config.yaml @@ -1,5 +1,9 @@ version: 1 +# Deploy with `volcano config deploy` after `volcano functions deploy`. +# Declared sections are the source of truth: schedulers listed under a +# function are fully synced, and entries for functions that are not +# deployed yet are skipped with a warning. functions: - name: hello public: true diff --git a/internal/projectinit/starters/javascript/volcano/volcano-config.yaml b/internal/projectinit/starters/javascript/volcano/volcano-config.yaml index 1e69e0e..55ea5fe 100644 --- a/internal/projectinit/starters/javascript/volcano/volcano-config.yaml +++ b/internal/projectinit/starters/javascript/volcano/volcano-config.yaml @@ -1,5 +1,9 @@ version: 1 +# Deploy with `volcano config deploy` after `volcano functions deploy`. +# Declared sections are the source of truth: schedulers listed under a +# function are fully synced, and entries for functions that are not +# deployed yet are skipped with a warning. functions: - name: hello public: true diff --git a/tests/e2e/api/config_test.go b/tests/e2e/api/config_test.go index 43cd9e5..7300b8e 100644 --- a/tests/e2e/api/config_test.go +++ b/tests/e2e/api/config_test.go @@ -1,7 +1,18 @@ package api -import "testing" +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) +// TestAPIE2ECloudConfig covers the declarative config workflow end to end +// (E2E matrix items 18-21): full-manifest deploy with ${ENV} interpolation +// and report rendering, pull round-trip with --force semantics, dry-run plus +// exact variables sync, plan-gate validation failure, and skipped/missing +// warnings. func TestAPIE2ECloudConfig(t *testing.T) { env := setupAPIE2E(t, "cloud-config") writeAPIE2EBaseProject(t, env.projectDir) @@ -13,12 +24,147 @@ func TestAPIE2ECloudConfig(t *testing.T) { }) configBucket := "cli-e2e-config-" + apiE2ESuffix(t) - writeAPIE2EConfig(t, env.projectDir, configBucket, "hello") - env.runCloudCLI(t, "config", "deploy").requireSuccess(t, "Configuration deployed", "Buckets:", "Policies:", "Functions:") - env.runCloudCLI(t, "storage", "bucket", "get", configBucket).requireSuccess(t, configBucket, "8.0 KiB", "text/plain") - env.runCloudCLI(t, "storage", "policy", "get", configBucket, "config-read").requireSuccess(t, "config-read", "SELECT", "true") - env.runCloudCLI(t, "functions", "get", "hello").requireSuccess(t, "Visibility: public") + env.runCloudCLI(t, "storage", "bucket", "create", configBucket).requireSuccess(t, configBucket) t.Cleanup(func() { _ = env.runCloudCLI(t, "storage", "bucket", "delete", configBucket, "--yes") }) + + // SMOKE_MESSAGE exists server-side but is absent from the manifest below: + // the deploy must delete it (variables are fully synced). + env.runCloudCLI(t, "variables", "deploy").requireSuccess(t, "SMOKE_MESSAGE", "variable(s) saved") + + manifestPath := filepath.Join(env.projectDir, "volcano", "volcano-config.yaml") + interpolatedValue := "interpolated-" + apiE2ESuffix(t) + secretEnv := []string{"CLI_E2E_CONFIG_SECRET=" + interpolatedValue} + + // Item 18: full manifest (every FREE-plan section) with ${ENV} interpolation. + writeAPIE2EFile(t, manifestPath, fmt.Sprintf(` +version: 1 +variables: + - name: CONFIG_SECRET + value: ${CLI_E2E_CONFIG_SECRET} + - name: CONFIG_PLAIN + value: plain-value +buckets: + - name: %s + file_size_limit: 8192 + allowed_mime_types: + - text/plain + policies: + - name: config-read + operation: SELECT + definition: "true" +realtime: + enabled: true + broadcast_enabled: true +auth: + rate_limits: + signup: 42 + password: + min_length: 12 + email: + templates: + confirmation: + subject: "CLI E2E confirm subject" +functions: + - name: hello + public: true +`, configBucket)) + + deploy := env.runCloudCLIWithEnv(t, secretEnv, "config", "deploy") + deploy.requireSuccess(t, + "Configuration deployed from volcano-config.yaml", + "variables:", + "buckets:", + "buckets.policies:", + "realtime:", + "auth:", + "functions:", + "Summary:", + ) + deploy.requireNotContains(t, "Warning:", "Error:") + + env.runCloudCLI(t, "storage", "bucket", "get", configBucket).requireSuccess(t, configBucket, "8.0 KiB", "text/plain") + env.runCloudCLI(t, "storage", "policy", "get", configBucket, "config-read").requireSuccess(t, "config-read", "SELECT", "true") + env.runCloudCLI(t, "functions", "get", "hello").requireSuccess(t, "Visibility: public") + variables := env.runCloudCLI(t, "variables", "list") + variables.requireSuccess(t, "CONFIG_SECRET", "CONFIG_PLAIN") + variables.requireNotContains(t, "SMOKE_MESSAGE") + + // Item 19: pull refuses to overwrite, --force succeeds, the export carries + // the interpolated value (variable values are included) but no write-only + // secrets, and re-deploying the pulled file unchanged is a no-op. + env.runCloudCLI(t, "config", "pull").requireFailure(t, "refusing to overwrite", "--force") + env.runCloudCLI(t, "config", "pull", "--force").requireSuccess(t, "Configuration written to", "write-only secrets") + + pulled, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatalf("failed to read pulled manifest: %v", err) + } + pulledText := string(pulled) + for _, needle := range []string{"version: 1", "CONFIG_SECRET", interpolatedValue, "config-read", "Write-only secrets are omitted"} { + if !strings.Contains(pulledText, needle) { + t.Fatalf("pulled manifest missing %q:\n%s", needle, pulledText) + } + } + + redeploy := env.runCloudCLI(t, "config", "deploy") + redeploy.requireSuccess(t, "Configuration deployed from volcano-config.yaml", "Summary: 0 created, 0 updated, 0 deleted") + redeploy.requireNotContains(t, "Warning:", "Error:") + + // Item 20a: dry run projects the variable deletion without applying it. + writeAPIE2EFile(t, manifestPath, ` +version: 1 +variables: + - name: CONFIG_SECRET + value: ${CLI_E2E_CONFIG_SECRET} +`) + dryRun := env.runCloudCLIWithEnv(t, secretEnv, "config", "deploy", "--dry-run") + dryRun.requireSuccess(t, "Dry run", "1 deleted") + dryRun.requireNotContains(t, "Configuration deployed") + env.runCloudCLI(t, "variables", "list").requireSuccess(t, "CONFIG_PLAIN") + + // Item 20b: the real deploy syncs variables exactly (deletes the extra). + env.runCloudCLIWithEnv(t, secretEnv, "config", "deploy").requireSuccess(t, "Configuration deployed", "1 deleted") + afterSync := env.runCloudCLI(t, "variables", "list") + afterSync.requireSuccess(t, "CONFIG_SECRET") + afterSync.requireNotContains(t, "CONFIG_PLAIN") + + // Item 20c: a plan-gated manifest (region subset on FREE) exits non-zero + // with the server's 422 error list rendered, and nothing is applied. + writeAPIE2EFile(t, manifestPath, ` +version: 1 +project: + all_regions: false + selected_regions: + - us-east-1 +variables: + - name: CONFIG_GATED + value: must-not-land +`) + gated := env.runCloudCLI(t, "config", "deploy") + gated.requireFailure(t, "validation error", "selected_regions customization is only available on PRO plan", "nothing was applied") + env.runCloudCLI(t, "variables", "list").requireNotContains(t, "CONFIG_GATED") + + // Item 21: skipped/missing warnings render prominently but exit 0. + writeAPIE2EFile(t, manifestPath, ` +version: 1 +functions: + - name: ghost-fn + public: true +`) + coverage := env.runCloudCLI(t, "config", "deploy") + coverage.requireSuccess(t, + `Warning: function "ghost-fn" is declared in the manifest but not deployed`, + `Warning: function "hello" exists but is not covered by your manifest`, + ) + + // Missing env var fails locally before any upload. + writeAPIE2EFile(t, manifestPath, ` +version: 1 +variables: + - name: BROKEN + value: ${CLI_E2E_UNSET_VARIABLE} +`) + env.runCloudCLI(t, "config", "deploy").requireFailure(t, `environment variable "CLI_E2E_UNSET_VARIABLE" is not set`) } diff --git a/tests/e2e/api/helpers_fixtures_test.go b/tests/e2e/api/helpers_fixtures_test.go index 9c4e8f6..7801fef 100644 --- a/tests/e2e/api/helpers_fixtures_test.go +++ b/tests/e2e/api/helpers_fixtures_test.go @@ -1,7 +1,6 @@ package api import ( - "fmt" "os" "path/filepath" "strings" @@ -43,25 +42,6 @@ export default function Home() { `) } -func writeAPIE2EConfig(t *testing.T, projectDir, bucket, function string) { - t.Helper() - writeAPIE2EFile(t, filepath.Join(projectDir, "volcano", "volcano-config.yaml"), fmt.Sprintf(` -version: 1 -buckets: - - name: %s - file_size_limit: 8192 - allowed_mime_types: - - text/plain - policies: - - name: config-read - operation: SELECT - definition: "true" -functions: - - name: %s - public: true -`, bucket, function)) -} - func writeAPIE2EFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { diff --git a/tests/e2e/localmode/localmode_test.go b/tests/e2e/localmode/localmode_test.go index e96318d..e41cc44 100644 --- a/tests/e2e/localmode/localmode_test.go +++ b/tests/e2e/localmode/localmode_test.go @@ -78,6 +78,8 @@ func TestLocalModeE2ESmoke(t *testing.T) { functionID := waitForVolcanoLocalModeE2EFunctionID(t, info, "hello") waitForVolcanoLocalModeE2EInvokeContains(t, info, functionID, `"ok":true`) + runLocalModeConfigSmoke(t, volcanoBin, env, projectDir) + variableDeleteOutput := runVolcanoLocalModeE2E(t, volcanoBin, env, projectDir, "variables", "delete", "SMOKE_MESSAGE", "--yes") requireContains(t, variableDeleteOutput, "deleted") variablesAfterDelete := runVolcanoLocalModeE2E(t, volcanoBin, env, projectDir, "variables", "list") @@ -204,6 +206,62 @@ func writeLocalModeE2EFile(t *testing.T, projectDir, relativePath, content strin } } +func readLocalModeE2EFile(t *testing.T, projectDir, relativePath string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(projectDir, relativePath)) + if err != nil { + t.Fatalf("failed to read %s: %v", relativePath, err) + } + return string(data) +} + +// runLocalModeConfigSmoke exercises `config deploy` + `config pull` against the +// local-mode server. It is self-contained: it declares SMOKE_MESSAGE (already +// present, kept by the full sync) plus CONFIG_SMOKE, verifies both commands, +// then removes CONFIG_SMOKE so the shared cleanup only handles SMOKE_MESSAGE. +// +// Older local-mode images predate the /projects/{id}/config endpoints; against +// those the CLI returns an upgrade hint and this smoke is skipped rather than +// failed, so the CLI can ship ahead of the server image that carries the +// endpoints (cross-repo rollout). +func runLocalModeConfigSmoke(t *testing.T, binary string, env []string, projectDir string) { + t.Helper() + writeLocalModeE2EFile(t, projectDir, filepath.Join("volcano", "volcano-config.yaml"), ` +version: 1 +variables: + - name: SMOKE_MESSAGE + value: hello-from-volcano-cli + - name: CONFIG_SMOKE + value: from-config-deploy +functions: + - name: hello + public: true +`) + + deployOutput, err := runVolcanoLocalModeE2EAllowFailure(t, binary, env, projectDir, "config", "deploy") + if err != nil { + if strings.Contains(deployOutput, "does not support declarative config apply") { + t.Logf("skipping config deploy/pull smoke: local-mode image predates the config endpoints\n%s", deployOutput) + return + } + t.Fatalf("volcano config deploy failed: %v\n%s", err, deployOutput) + } + requireContains(t, deployOutput, "Configuration deployed from volcano-config.yaml") + requireContains(t, deployOutput, "variables:") + + variablesAfterConfig := runVolcanoLocalModeE2E(t, binary, env, projectDir, "variables", "list") + requireContains(t, variablesAfterConfig, "CONFIG_SMOKE") + + configPullOutput := runVolcanoLocalModeE2E(t, binary, env, projectDir, "config", "pull", "--force") + requireContains(t, configPullOutput, "Configuration written to") + pulledConfig := readLocalModeE2EFile(t, projectDir, filepath.Join("volcano", "volcano-config.yaml")) + requireContains(t, pulledConfig, "CONFIG_SMOKE") + requireContains(t, pulledConfig, "version: 1") + + configDeleteOutput := runVolcanoLocalModeE2E(t, binary, env, projectDir, "variables", "delete", "CONFIG_SMOKE", "--yes") + requireContains(t, configDeleteOutput, "deleted") +} + func runVolcanoLocalModeE2E(t *testing.T, binary string, env []string, dir string, args ...string) string { t.Helper() output, err := runVolcanoLocalModeE2EAllowFailure(t, binary, env, dir, args...)