diff --git a/docs/docs/reference/supported-resources.md b/docs/docs/reference/supported-resources.md index c1b1aa85..f8877829 100644 --- a/docs/docs/reference/supported-resources.md +++ b/docs/docs/reference/supported-resources.md @@ -886,7 +886,10 @@ resources: version: "POSTGRES_14" # PostgreSQL version tier: "db-f1-micro" # Instance tier region: "us-central1" # GCP region - maxConnections: 100 # Maximum connections (optional) + maxConnections: 100 # Maximum connections (optional, deprecated: prefer databaseFlags) + databaseFlags: # Arbitrary Cloud SQL database flags (optional) + cloudsql.iam_authentication: "on" # e.g. enable IAM database authentication + log_min_duration_statement: "500" # max_connections here overrides maxConnections deletionProtection: true # Enable deletion protection (optional) queryInsightsEnabled: false # Enable query insights (optional) queryStringLength: 1024 # Query string length limit (optional) @@ -895,6 +898,8 @@ resources: resourceName: "postgres-job-runner" ``` +**Database flag removal semantics:** on freshly provisioned instances, deleting an entry from `databaseFlags` reverts that flag to its engine default on the next update (static flags restart the instance). On **adopted** instances (`adopt: true`), flags are only managed while `databaseFlags` is set — deleting the whole block re-enters drift protection and leaves the last-applied flags in place; to disable a flag, set it to its off value (e.g. `"off"`) instead of removing the line. + **Client Access:** When this resource is used in a client stack via the `uses` section, Simple Container automatically injects environment variables and template placeholders for PostgreSQL connection details. diff --git a/docs/schemas/aws/cloudtrailsecurityalertsconfig.json b/docs/schemas/aws/cloudtrailsecurityalertsconfig.json index 591d7159..6ead1bb4 100644 --- a/docs/schemas/aws/cloudtrailsecurityalertsconfig.json +++ b/docs/schemas/aws/cloudtrailsecurityalertsconfig.json @@ -49,6 +49,12 @@ "alerts": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { + "accessKeyCreation": { + "type": "boolean" + }, + "anonymousProbes": { + "type": "boolean" + }, "cloudTrailTampering": { "type": "boolean" }, @@ -61,18 +67,88 @@ "failedConsoleLogins": { "type": "boolean" }, + "guardDutyDisabled": { + "type": "boolean" + }, "iamPolicyChanges": { "type": "boolean" }, "kmsKeyDeletion": { "type": "boolean" }, + "kmsKeyGrants": { + "type": "boolean" + }, + "kmsKeyPolicy": { + "type": "boolean" + }, + "lambdaUrlPublic": { + "type": "boolean" + }, "naclChanges": { "type": "boolean" }, "networkGatewayChanges": { "type": "boolean" }, + "organizationsChanges": { + "type": "boolean" + }, + "overrides": { + "additionalProperties": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "evaluationPeriods": { + "type": "integer" + }, + "excludeInvokedBy": { + "items": { + "type": "string" + }, + "type": "array" + }, + "excludePrincipalIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "excludeUserArnGlobs": { + "items": { + "type": "string" + }, + "type": "array" + }, + "excludeUserArns": { + "items": { + "type": "string" + }, + "type": "array" + }, + "excludeUserNames": { + "items": { + "type": "string" + }, + "type": "array" + }, + "excludeUserTypes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "period": { + "type": "integer" + }, + "threshold": { + "type": "number" + } + }, + "required": [], + "type": "object" + }, + "type": "object" + }, "rootAccountUsage": { "type": "boolean" }, @@ -82,9 +158,15 @@ "s3BucketPolicyChanges": { "type": "boolean" }, + "s3PublicAccessChanges": { + "type": "boolean" + }, "securityGroupChanges": { "type": "boolean" }, + "securityHubDisabled": { + "type": "boolean" + }, "unauthorizedApiCalls": { "type": "boolean" }, diff --git a/docs/schemas/aws/mysqlconfig.json b/docs/schemas/aws/mysqlconfig.json index 78d4343a..2b13993d 100644 --- a/docs/schemas/aws/mysqlconfig.json +++ b/docs/schemas/aws/mysqlconfig.json @@ -67,6 +67,9 @@ "password": { "type": "string" }, + "storageEncrypted": { + "type": "boolean" + }, "username": { "type": "string" } diff --git a/docs/schemas/aws/postgresconfig.json b/docs/schemas/aws/postgresconfig.json index 0ec5bbbc..92de6d13 100644 --- a/docs/schemas/aws/postgresconfig.json +++ b/docs/schemas/aws/postgresconfig.json @@ -67,6 +67,9 @@ "password": { "type": "string" }, + "storageEncrypted": { + "type": "boolean" + }, "username": { "type": "string" } diff --git a/docs/schemas/core/clientdescriptor.json b/docs/schemas/core/clientdescriptor.json index 6d89d598..50fabf05 100644 --- a/docs/schemas/core/clientdescriptor.json +++ b/docs/schemas/core/clientdescriptor.json @@ -254,6 +254,9 @@ "required": { "type": "boolean" }, + "softFail": { + "type": "boolean" + }, "tools": { "items": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -295,9 +298,6 @@ "keyless": { "type": "boolean" }, - "password": { - "type": "string" - }, "privateKey": { "type": "string" }, diff --git a/docs/schemas/core/scandescriptor.json b/docs/schemas/core/scandescriptor.json index cef6d044..bc1ce783 100644 --- a/docs/schemas/core/scandescriptor.json +++ b/docs/schemas/core/scandescriptor.json @@ -47,6 +47,9 @@ "required": { "type": "boolean" }, + "softFail": { + "type": "boolean" + }, "tools": { "items": { "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/docs/schemas/core/securitydescriptor.json b/docs/schemas/core/securitydescriptor.json index f3ff7dff..4a84979b 100644 --- a/docs/schemas/core/securitydescriptor.json +++ b/docs/schemas/core/securitydescriptor.json @@ -235,6 +235,9 @@ "required": { "type": "boolean" }, + "softFail": { + "type": "boolean" + }, "tools": { "items": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -276,9 +279,6 @@ "keyless": { "type": "boolean" }, - "password": { - "type": "string" - }, "privateKey": { "type": "string" }, diff --git a/docs/schemas/core/signingdescriptor.json b/docs/schemas/core/signingdescriptor.json index 9dee550e..4682bdfe 100644 --- a/docs/schemas/core/signingdescriptor.json +++ b/docs/schemas/core/signingdescriptor.json @@ -15,9 +15,6 @@ "keyless": { "type": "boolean" }, - "password": { - "type": "string" - }, "privateKey": { "type": "string" }, diff --git a/docs/schemas/core/stackconfigcompose.json b/docs/schemas/core/stackconfigcompose.json index c71e7093..8f72a855 100644 --- a/docs/schemas/core/stackconfigcompose.json +++ b/docs/schemas/core/stackconfigcompose.json @@ -349,11 +349,18 @@ }, "https": { "type": "boolean" + }, + "siteExtraHelpers": { + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ "extraHelpers", - "https" + "https", + "siteExtraHelpers" ], "type": "object" }, @@ -655,6 +662,9 @@ "required": { "type": "boolean" }, + "softFail": { + "type": "boolean" + }, "tools": { "items": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -696,9 +706,6 @@ "keyless": { "type": "boolean" }, - "password": { - "type": "string" - }, "privateKey": { "type": "string" }, diff --git a/docs/schemas/core/stackconfigsingleimage.json b/docs/schemas/core/stackconfigsingleimage.json index ac7774e1..97c1959b 100644 --- a/docs/schemas/core/stackconfigsingleimage.json +++ b/docs/schemas/core/stackconfigsingleimage.json @@ -348,6 +348,9 @@ "required": { "type": "boolean" }, + "softFail": { + "type": "boolean" + }, "tools": { "items": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -389,9 +392,6 @@ "keyless": { "type": "boolean" }, - "password": { - "type": "string" - }, "privateKey": { "type": "string" }, diff --git a/docs/schemas/gcp/gkeautopilotresource.json b/docs/schemas/gcp/gkeautopilotresource.json index 72594afc..463784db 100644 --- a/docs/schemas/gcp/gkeautopilotresource.json +++ b/docs/schemas/gcp/gkeautopilotresource.json @@ -49,6 +49,9 @@ "enable": { "type": "boolean" }, + "externalTrafficPolicy": { + "type": "string" + }, "hstsValue": { "type": "string" }, @@ -58,6 +61,9 @@ "namespace": { "type": "string" }, + "preStopSleepSeconds": { + "type": "integer" + }, "provisionIngress": { "type": "boolean" }, @@ -67,6 +73,9 @@ "resources": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { + "ephemeral": { + "type": "string" + }, "limits": { "additionalProperties": { "type": "string" @@ -81,6 +90,7 @@ } }, "required": [ + "ephemeral", "limits", "requests" ], @@ -89,6 +99,15 @@ "serviceType": { "type": "string" }, + "terminationGracePeriodSeconds": { + "type": "integer" + }, + "trustedProxies": { + "items": { + "type": "string" + }, + "type": "array" + }, "usePrefixes": { "type": "boolean" }, @@ -98,12 +117,87 @@ "vpa": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { + "containerPolicies": { + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "containerName": { + "type": "string" + }, + "controlledResources": { + "items": { + "type": "string" + }, + "type": "array" + }, + "controlledValues": { + "type": "string" + }, + "maxAllowed": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "cpu": { + "type": "string" + }, + "ephemeral-storage": { + "type": "string" + }, + "memory": { + "type": "string" + } + }, + "required": [ + "cpu", + "ephemeral-storage", + "memory" + ], + "type": "object" + }, + "minAllowed": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "cpu": { + "type": "string" + }, + "ephemeral-storage": { + "type": "string" + }, + "memory": { + "type": "string" + } + }, + "required": [ + "cpu", + "ephemeral-storage", + "memory" + ], + "type": "object" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "containerName", + "controlledResources", + "controlledValues", + "maxAllowed", + "minAllowed", + "mode" + ], + "type": "object" + }, + "type": "array" + }, "controlledResources": { "items": { "type": "string" }, "type": "array" }, + "controlledValues": { + "type": "string" + }, "enabled": { "type": "boolean" }, @@ -113,12 +207,16 @@ "cpu": { "type": "string" }, + "ephemeral-storage": { + "type": "string" + }, "memory": { "type": "string" } }, "required": [ "cpu", + "ephemeral-storage", "memory" ], "type": "object" @@ -129,12 +227,16 @@ "cpu": { "type": "string" }, + "ephemeral-storage": { + "type": "string" + }, "memory": { "type": "string" } }, "required": [ "cpu", + "ephemeral-storage", "memory" ], "type": "object" @@ -144,7 +246,9 @@ } }, "required": [ + "containerPolicies", "controlledResources", + "controlledValues", "enabled", "maxAllowed", "minAllowed", diff --git a/docs/schemas/gcp/postgresgcpcloudsqlconfig.json b/docs/schemas/gcp/postgresgcpcloudsqlconfig.json index c761d7b7..7d0d0f37 100644 --- a/docs/schemas/gcp/postgresgcpcloudsqlconfig.json +++ b/docs/schemas/gcp/postgresgcpcloudsqlconfig.json @@ -34,9 +34,24 @@ "adopt": { "type": "boolean" }, + "availabilityType": { + "type": "string" + }, + "backupEnabled": { + "type": "boolean" + }, + "backupStartTime": { + "type": "string" + }, "connectionName": { "type": "string" }, + "databaseFlags": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "deletionProtection": { "type": "boolean" }, @@ -46,6 +61,9 @@ "maxConnections": { "type": "integer" }, + "pointInTimeRecoveryEnabled": { + "type": "boolean" + }, "project": { "type": "string" }, @@ -58,12 +76,21 @@ "region": { "type": "string" }, + "requireSsl": { + "type": "boolean" + }, + "retainedBackups": { + "type": "integer" + }, "rootPassword": { "type": "string" }, "tier": { "type": "string" }, + "transactionLogRetentionDays": { + "type": "integer" + }, "usersProvisionRuntime": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { diff --git a/docs/schemas/gcp/templateconfig.json b/docs/schemas/gcp/templateconfig.json index 91a9a800..d7927303 100644 --- a/docs/schemas/gcp/templateconfig.json +++ b/docs/schemas/gcp/templateconfig.json @@ -5,8 +5,7 @@ "description": "Google Cloud Platform deployment template configuration", "goPackage": "pkg/clouds/gcp/", "goStruct": "TemplateConfig", - "resourceType": "cloudrun", - "templateType": "cloudrun", + "resourceType": "gcp-static-website", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { diff --git a/docs/schemas/kubernetes/kubernetescloudextras.json b/docs/schemas/kubernetes/kubernetescloudextras.json index ae49b046..28f80da2 100644 --- a/docs/schemas/kubernetes/kubernetescloudextras.json +++ b/docs/schemas/kubernetes/kubernetescloudextras.json @@ -9,36 +9,6 @@ "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { - "ephemeralVolumes": { - "description": "Generic ephemeral volumes for large temporary storage (>10GB). These volumes bypass GKE Autopilot's 10GB ephemeral storage limit by using PersistentVolumeClaims that are automatically created and deleted with pods.", - "items": { - "properties": { - "mountPath": { - "description": "Path where the volume should be mounted inside the container", - "type": "string" - }, - "name": { - "description": "Name of the ephemeral volume (will be sanitized for Kubernetes RFC 1123 compliance)", - "type": "string" - }, - "size": { - "description": "Size of the volume (e.g., '10Gi', '100Gi', '1Ti'). Can be much larger than the 10GB regular ephemeral storage limit.", - "type": "string" - }, - "storageClassName": { - "description": "Storage class to use for the PersistentVolumeClaim (e.g., 'standard-rwo', 'pd-balanced'). Defaults to cluster default if not specified.", - "type": "string" - } - }, - "required": [ - "name", - "mountPath", - "size" - ], - "type": "object" - }, - "type": "array" - }, "affinity": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -546,6 +516,33 @@ ], "type": "object" }, + "ephemeralVolumes": { + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "mountPath": { + "type": "string" + }, + "name": { + "type": "string" + }, + "size": { + "type": "string" + }, + "storageClassName": { + "type": "string" + } + }, + "required": [ + "mountPath", + "name", + "size", + "storageClassName" + ], + "type": "object" + }, + "type": "array" + }, "livenessProbe": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -555,12 +552,6 @@ "httpGet": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { - "path": { - "type": "string" - }, - "port": { - "type": "integer" - }, "httpHeaders": { "items": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -579,6 +570,12 @@ "type": "object" }, "type": "array" + }, + "path": { + "type": "string" + }, + "port": { + "type": "integer" } }, "required": [ @@ -596,6 +593,9 @@ "interval": { "type": "integer" }, + "periodSeconds": { + "type": "integer" + }, "successThreshold": { "type": "integer" }, @@ -609,6 +609,7 @@ "initialDelaySeconds", "intervaSeconds", "interval", + "periodSeconds", "successThreshold", "timeoutSeconds" ], @@ -620,6 +621,9 @@ }, "type": "object" }, + "priorityClassName": { + "type": "string" + }, "readinessProbe": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -629,12 +633,6 @@ "httpGet": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { - "path": { - "type": "string" - }, - "port": { - "type": "integer" - }, "httpHeaders": { "items": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -653,6 +651,12 @@ "type": "object" }, "type": "array" + }, + "path": { + "type": "string" + }, + "port": { + "type": "integer" } }, "required": [ @@ -670,6 +674,9 @@ "interval": { "type": "integer" }, + "periodSeconds": { + "type": "integer" + }, "successThreshold": { "type": "integer" }, @@ -683,6 +690,7 @@ "initialDelaySeconds", "intervaSeconds", "interval", + "periodSeconds", "successThreshold", "timeoutSeconds" ], @@ -704,6 +712,78 @@ ], "type": "object" }, + "startupProbe": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "failureThreshold": { + "type": "integer" + }, + "httpGet": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "httpHeaders": { + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "type": "string" + }, + "port": { + "type": "integer" + } + }, + "required": [ + "path", + "port" + ], + "type": "object" + }, + "initialDelaySeconds": { + "type": "integer" + }, + "intervaSeconds": { + "type": "integer" + }, + "interval": { + "type": "integer" + }, + "periodSeconds": { + "type": "integer" + }, + "successThreshold": { + "type": "integer" + }, + "timeoutSeconds": { + "type": "integer" + } + }, + "required": [ + "failureThreshold", + "httpGet", + "initialDelaySeconds", + "intervaSeconds", + "interval", + "periodSeconds", + "successThreshold", + "timeoutSeconds" + ], + "type": "object" + }, "tolerations": { "items": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -728,15 +808,160 @@ }, "type": "array" }, + "topologySpreadConstraints": { + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "labelSelector": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "matchExpressions": { + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "key": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "values": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "key", + "operator", + "values" + ], + "type": "object" + }, + "type": "array" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + }, + "required": [ + "matchExpressions", + "matchLabels" + ], + "type": "object" + }, + "maxSkew": { + "type": "integer" + }, + "minDomains": { + "type": "integer" + }, + "topologyKey": { + "type": "string" + }, + "whenUnsatisfiable": { + "type": "string" + } + }, + "required": [ + "labelSelector", + "maxSkew", + "minDomains", + "topologyKey", + "whenUnsatisfiable" + ], + "type": "object" + }, + "type": "array" + }, "vpa": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { + "containerPolicies": { + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "containerName": { + "type": "string" + }, + "controlledResources": { + "items": { + "type": "string" + }, + "type": "array" + }, + "controlledValues": { + "type": "string" + }, + "maxAllowed": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "cpu": { + "type": "string" + }, + "ephemeral-storage": { + "type": "string" + }, + "memory": { + "type": "string" + } + }, + "required": [ + "cpu", + "ephemeral-storage", + "memory" + ], + "type": "object" + }, + "minAllowed": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "cpu": { + "type": "string" + }, + "ephemeral-storage": { + "type": "string" + }, + "memory": { + "type": "string" + } + }, + "required": [ + "cpu", + "ephemeral-storage", + "memory" + ], + "type": "object" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "containerName", + "controlledResources", + "controlledValues", + "maxAllowed", + "minAllowed", + "mode" + ], + "type": "object" + }, + "type": "array" + }, "controlledResources": { "items": { "type": "string" }, "type": "array" }, + "controlledValues": { + "type": "string" + }, "enabled": { "type": "boolean" }, @@ -746,12 +971,16 @@ "cpu": { "type": "string" }, + "ephemeral-storage": { + "type": "string" + }, "memory": { "type": "string" } }, "required": [ "cpu", + "ephemeral-storage", "memory" ], "type": "object" @@ -762,12 +991,16 @@ "cpu": { "type": "string" }, + "ephemeral-storage": { + "type": "string" + }, "memory": { "type": "string" } }, "required": [ "cpu", + "ephemeral-storage", "memory" ], "type": "object" @@ -777,33 +1010,29 @@ } }, "required": [ + "containerPolicies", "controlledResources", + "controlledValues", "enabled", "maxAllowed", "minAllowed", "updateMode" ], "type": "object" - }, - "priorityClassName": { - "description": "Kubernetes PriorityClass to assign to pods. This affects pod scheduling and preemption behavior. Higher priority pods are scheduled before lower priority pods and can preempt them when resources are scarce. The PriorityClass must already exist in the cluster. Common values include 'system-cluster-critical' (priority 2000000000), 'system-node-critical' (priority 2000000000), or custom PriorityClasses created by cluster administrators.", - "examples": [ - "high-priority", - "system-cluster-critical", - "workload-production" - ], - "type": "string" } }, "required": [ - "ephemeralVolumes", "affinity", "disruptionBudget", + "ephemeralVolumes", "livenessProbe", "nodeSelector", + "priorityClassName", "readinessProbe", "rollingUpdate", + "startupProbe", "tolerations", + "topologySpreadConstraints", "vpa" ], "type": "object" diff --git a/pkg/api/tests/refapp.go b/pkg/api/tests/refapp.go index 2b4a32ff..9209268c 100644 --- a/pkg/api/tests/refapp.go +++ b/pkg/api/tests/refapp.go @@ -483,6 +483,10 @@ var ResolvedRefappServerDescriptor = &api.ServerDescriptor{ Config: api.Config{Config: &gcloud.PostgresGcpCloudsqlConfig{ Version: "14.5", Project: "refapp", + // Resolved fixtures carry {} not nil: placeholder + // resolution rebuilds maps via reflect.MakeMap + // (placeholders.go). + DatabaseFlags: map[string]string{}, Credentials: gcloud.Credentials{ Credentials: api.Credentials{ Credentials: "", @@ -502,8 +506,9 @@ var ResolvedRefappServerDescriptor = &api.ServerDescriptor{ "postgres": { Type: gcloud.ResourceTypePostgresGcpCloudsql, Config: api.Config{Config: &gcloud.PostgresGcpCloudsqlConfig{ - Version: "14.5", - Project: "refapp", + Version: "14.5", + Project: "refapp", + DatabaseFlags: map[string]string{}, Credentials: gcloud.Credentials{ Credentials: api.Credentials{ Credentials: "", diff --git a/pkg/clouds/gcloud/postgres.go b/pkg/clouds/gcloud/postgres.go index 49910884..9b8fd215 100644 --- a/pkg/clouds/gcloud/postgres.go +++ b/pkg/clouds/gcloud/postgres.go @@ -8,12 +8,18 @@ import "github.com/simple-container-com/api/pkg/api" const ResourceTypePostgresGcpCloudsql = "gcp-cloudsql-postgres" type PostgresGcpCloudsqlConfig struct { - Credentials `json:",inline" yaml:",inline"` - Version string `json:"version" yaml:"version"` - Project string `json:"project" yaml:"project"` - Tier *string `json:"tier" yaml:"tier"` - Region *string `json:"region" yaml:"region"` - MaxConnections *int `json:"maxConnections" yaml:"maxConnections"` + Credentials `json:",inline" yaml:",inline"` + Version string `json:"version" yaml:"version"` + Project string `json:"project" yaml:"project"` + Tier *string `json:"tier" yaml:"tier"` + Region *string `json:"region" yaml:"region"` + // Deprecated: prefer DatabaseFlags["max_connections"]; kept for + // backward compatibility. + MaxConnections *int `json:"maxConnections" yaml:"maxConnections"` + // DatabaseFlags sets arbitrary Cloud SQL database flags by name + // (e.g. cloudsql.iam_authentication: "on"). An explicit max_connections + // entry here takes precedence over MaxConnections. + DatabaseFlags map[string]string `json:"databaseFlags,omitempty" yaml:"databaseFlags,omitempty"` DeletionProtection *bool `json:"deletionProtection" yaml:"deletionProtection"` QueryInsightsEnabled *bool `json:"queryInsightsEnabled" yaml:"queryInsightsEnabled"` QueryStringLength *int `json:"queryStringLength" yaml:"queryStringLength"` diff --git a/pkg/clouds/gcloud/resources_test.go b/pkg/clouds/gcloud/resources_test.go index 40c94506..44ff1a70 100644 --- a/pkg/clouds/gcloud/resources_test.go +++ b/pkg/clouds/gcloud/resources_test.go @@ -196,6 +196,12 @@ func TestPostgresqlGcpCloudsqlReadConfig(t *testing.T) { "deletionProtection": true, "availabilityType": "REGIONAL", "requireSsl": true, + "databaseFlags": map[string]any{ + "cloudsql.iam_authentication": "on", + // Unquoted YAML int — users write flag values bare; + // yaml.v3 coerces scalars into the string map. + "log_min_duration_statement": 500, + }, "usersProvisionRuntime": map[string]any{ "type": "kube-job", "resourceName": "gke-cluster", @@ -213,6 +219,10 @@ func TestPostgresqlGcpCloudsqlReadConfig(t *testing.T) { Expect(*pg.Region).To(Equal("europe-west1")) Expect(pg.MaxConnections).ToNot(BeNil()) Expect(*pg.MaxConnections).To(Equal(200)) + Expect(pg.DatabaseFlags).To(Equal(map[string]string{ + "cloudsql.iam_authentication": "on", + "log_min_duration_statement": "500", + })) Expect(pg.DeletionProtection).ToNot(BeNil()) Expect(*pg.DeletionProtection).To(BeTrue()) Expect(pg.AvailabilityType).ToNot(BeNil()) @@ -235,6 +245,7 @@ func TestPostgresqlGcpCloudsqlReadConfig(t *testing.T) { Expect(pg.Tier).To(BeNil()) Expect(pg.Region).To(BeNil()) Expect(pg.MaxConnections).To(BeNil()) + Expect(pg.DatabaseFlags).To(BeNil()) Expect(pg.UsersProvisionRuntime).To(BeNil()) }) diff --git a/pkg/clouds/pulumi/gcp/adopt_postgres.go b/pkg/clouds/pulumi/gcp/adopt_postgres.go index ee2ad1f0..624baf41 100644 --- a/pkg/clouds/pulumi/gcp/adopt_postgres.go +++ b/pkg/clouds/pulumi/gcp/adopt_postgres.go @@ -95,16 +95,19 @@ func AdoptPostgres(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, p // The instance resource ID in GCP is: projects/{project}/instances/{instance} instanceResourceId := fmt.Sprintf("projects/%s/instances/%s", pgCfg.ProjectId, pgCfg.InstanceName) - // Use standardized adoption protection options - adoptionOpts := pApi.AdoptionProtectionOptions([]string{ - // Instance configuration that might drift - "settings.insightsConfig", "settings.databaseFlags", "settings.maintenanceWindow", - "settings.backupConfiguration", "settings.ipConfiguration", - // Version and upgrade settings - "masterInstanceName", "replicaConfiguration", "restoreBackupContext", - // Advanced settings that might be managed outside of Pulumi - "settings.userLabels", "settings.availabilityType", "settings.diskAutoresize", - }) + // Preserve existing database flags, overridden by any flags set in config + // (MaxConnections and the generic DatabaseFlags map). + configuredFlags := configuredDatabaseFlags(pgCfg) + databaseFlags := mergeDatabaseFlags(existingSettings.DatabaseFlags, configuredFlags) + if len(pgCfg.DatabaseFlags) > 0 { + // Names only — flag values could carry substituted secrets. + params.Log.Warn(ctx.Context(), + "applying database flags %v to adopted instance %q: static flags restart the instance; "+ + "on first-time adoption values must match the live instance or the import fails", + sortedFlagNames(configuredFlags), pgCfg.InstanceName) + } + + adoptionOpts := pApi.AdoptionProtectionOptions(adoptIgnoreChanges(pgCfg.DatabaseFlags)) opts := append([]sdk.ResourceOption{ sdk.Provider(params.Provider), @@ -112,30 +115,6 @@ func AdoptPostgres(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, p sdk.Import(sdk.ID(instanceResourceId)), }, adoptionOpts...) - // Preserve existing database flags and optionally override max_connections - var databaseFlags sql.DatabaseInstanceSettingsDatabaseFlagArray - - // First, copy all existing database flags - for _, flag := range existingSettings.DatabaseFlags { - // Skip max_connections if we're overriding it from config - if flag.Name == "max_connections" && pgCfg.MaxConnections != nil { - continue - } - databaseFlags = append(databaseFlags, sql.DatabaseInstanceSettingsDatabaseFlagArgs{ - Name: sdk.String(flag.Name), - Value: sdk.String(flag.Value), - }) - } - - // Add max_connections override if specified in config - if pgCfg.MaxConnections != nil { - databaseFlags = append(databaseFlags, sql.DatabaseInstanceSettingsDatabaseFlagArgs{ - Name: sdk.String("max_connections"), - Value: sdk.String(fmt.Sprintf("%d", *pgCfg.MaxConnections)), - }) - params.Log.Info(ctx.Context(), "overriding max_connections with config value: %d", *pgCfg.MaxConnections) - } - pgInstance, err := sql.NewDatabaseInstance(ctx, postgresName, &sql.DatabaseInstanceArgs{ Name: sdk.String(pgCfg.InstanceName), Region: sdk.StringPtr(region), @@ -292,3 +271,37 @@ func buildSettingsFromExisting(ctx *sdk.Context, existingSettings *sql.GetDataba // Other auto-managed fields are excluded to prevent configuration errors } } + +// mergeDatabaseFlags overlays configured flags onto an adopted instance's +// existing flags and renders the union fully sorted — a mixed +// existing-order/sorted-tail array would churn on every update. +func mergeDatabaseFlags(existing []sql.GetDatabaseInstanceSettingDatabaseFlag, configured map[string]string) sql.DatabaseInstanceSettingsDatabaseFlagArray { + merged := map[string]string{} + for _, flag := range existing { + merged[flag.Name] = flag.Value + } + for name, value := range configured { + merged[name] = value + } + return toDatabaseFlagArray(merged) +} + +// adoptIgnoreChanges returns the adoption protection ignore list. +// settings.databaseFlags stays ignored unless the DatabaseFlags field is +// explicitly set: legacy adopted stacks carrying only maxConnections keep the +// historical no-op instead of suddenly applying a restart-requiring flag. +func adoptIgnoreChanges(databaseFlags map[string]string) []string { + ignoreChanges := []string{ + // Instance configuration that might drift + "settings.insightsConfig", "settings.maintenanceWindow", + "settings.backupConfiguration", "settings.ipConfiguration", + // Version and upgrade settings + "masterInstanceName", "replicaConfiguration", "restoreBackupContext", + // Advanced settings that might be managed outside of Pulumi + "settings.userLabels", "settings.availabilityType", "settings.diskAutoresize", + } + if len(databaseFlags) == 0 { + ignoreChanges = append(ignoreChanges, "settings.databaseFlags") + } + return ignoreChanges +} diff --git a/pkg/clouds/pulumi/gcp/postgres.go b/pkg/clouds/pulumi/gcp/postgres.go index 6623a325..f2b34f69 100644 --- a/pkg/clouds/pulumi/gcp/postgres.go +++ b/pkg/clouds/pulumi/gcp/postgres.go @@ -5,6 +5,7 @@ package gcp import ( "fmt" + "sort" "github.com/pkg/errors" "github.com/samber/lo" @@ -56,14 +57,7 @@ func Postgres(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params } ctx.Export(rootPasswordExport, rootPassword.Result) - var databaseFlags sql.DatabaseInstanceSettingsDatabaseFlagArray - - if pgCfg.MaxConnections != nil { - databaseFlags = append(databaseFlags, sql.DatabaseInstanceSettingsDatabaseFlagArgs{ - Name: sdk.String("max_connections"), - Value: sdk.String(fmt.Sprintf("%d", *pgCfg.MaxConnections)), - }) - } + databaseFlags := toDatabaseFlagArray(configuredDatabaseFlags(pgCfg)) pgInstance, err := sql.NewDatabaseInstance(ctx, postgresName, &sql.DatabaseInstanceArgs{ Name: sdk.String(postgresName), @@ -142,3 +136,38 @@ func toPostgresRootPasswordExport(resName string) string { func toPostgresName(input api.ResourceInput, resName string) string { return input.ToResName(resName) } + +// configuredDatabaseFlags merges the legacy MaxConnections field with the +// generic DatabaseFlags map. An explicit max_connections entry in +// DatabaseFlags takes precedence over MaxConnections. +func configuredDatabaseFlags(pgCfg *gcloud.PostgresGcpCloudsqlConfig) map[string]string { + flags := map[string]string{} + //nolint:staticcheck // the compat shim is the one legitimate reader of the deprecated field + if pgCfg.MaxConnections != nil { + flags["max_connections"] = fmt.Sprintf("%d", *pgCfg.MaxConnections) + } + for name, value := range pgCfg.DatabaseFlags { + flags[name] = value + } + return flags +} + +// sortedFlagNames returns flag names in stable order for rendering and logs. +func sortedFlagNames(flags map[string]string) []string { + names := lo.Keys(flags) + sort.Strings(names) + return names +} + +// toDatabaseFlagArray renders flags sorted by name — map iteration order is +// random and would produce phantom diffs on every update. +func toDatabaseFlagArray(flags map[string]string) sql.DatabaseInstanceSettingsDatabaseFlagArray { + var res sql.DatabaseInstanceSettingsDatabaseFlagArray + for _, name := range sortedFlagNames(flags) { + res = append(res, sql.DatabaseInstanceSettingsDatabaseFlagArgs{ + Name: sdk.String(name), + Value: sdk.String(flags[name]), + }) + } + return res +} diff --git a/pkg/clouds/pulumi/gcp/postgres_flags_test.go b/pkg/clouds/pulumi/gcp/postgres_flags_test.go new file mode 100644 index 00000000..d1670513 --- /dev/null +++ b/pkg/clouds/pulumi/gcp/postgres_flags_test.go @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) Simple Container + +package gcp + +import ( + "testing" + + . "github.com/onsi/gomega" + "github.com/samber/lo" + + "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/sql" + sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + + "github.com/simple-container-com/api/pkg/clouds/gcloud" +) + +func renderedFlags(arr sql.DatabaseInstanceSettingsDatabaseFlagArray) []string { + var got []string + for _, f := range arr { + args := f.(sql.DatabaseInstanceSettingsDatabaseFlagArgs) + got = append(got, string(args.Name.(sdk.String))+"="+string(args.Value.(sdk.String))) + } + return got +} + +func TestConfiguredDatabaseFlags(t *testing.T) { + RegisterTestingT(t) + + t.Run("empty config yields no flags", func(t *testing.T) { + RegisterTestingT(t) + flags := configuredDatabaseFlags(&gcloud.PostgresGcpCloudsqlConfig{}) + Expect(flags).To(BeEmpty()) + Expect(toDatabaseFlagArray(flags)).To(BeNil()) + }) + + t.Run("maxConnections maps to max_connections", func(t *testing.T) { + RegisterTestingT(t) + flags := configuredDatabaseFlags(&gcloud.PostgresGcpCloudsqlConfig{ + MaxConnections: lo.ToPtr(200), + }) + Expect(flags).To(Equal(map[string]string{"max_connections": "200"})) + }) + + t.Run("databaseFlags merge with maxConnections", func(t *testing.T) { + RegisterTestingT(t) + flags := configuredDatabaseFlags(&gcloud.PostgresGcpCloudsqlConfig{ + MaxConnections: lo.ToPtr(200), + DatabaseFlags: map[string]string{ + "cloudsql.iam_authentication": "on", + }, + }) + Expect(flags).To(Equal(map[string]string{ + "max_connections": "200", + "cloudsql.iam_authentication": "on", + })) + }) + + t.Run("explicit max_connections in databaseFlags wins", func(t *testing.T) { + RegisterTestingT(t) + flags := configuredDatabaseFlags(&gcloud.PostgresGcpCloudsqlConfig{ + MaxConnections: lo.ToPtr(200), + DatabaseFlags: map[string]string{"max_connections": "500"}, + }) + Expect(flags).To(Equal(map[string]string{"max_connections": "500"})) + }) +} + +func TestToDatabaseFlagArray(t *testing.T) { + RegisterTestingT(t) + + t.Run("sorted by flag name for deterministic diffs", func(t *testing.T) { + RegisterTestingT(t) + got := renderedFlags(toDatabaseFlagArray(map[string]string{ + "max_connections": "200", + "cloudsql.iam_authentication": "on", + "log_min_duration_statement": "500", + })) + Expect(got).To(Equal([]string{ + "cloudsql.iam_authentication=on", + "log_min_duration_statement=500", + "max_connections=200", + })) + }) +} + +func TestMergeDatabaseFlags(t *testing.T) { + RegisterTestingT(t) + + t.Run("preserves unmanaged flags, overrides configured, sorts all", func(t *testing.T) { + RegisterTestingT(t) + existing := []sql.GetDatabaseInstanceSettingDatabaseFlag{ + {Name: "max_connections", Value: "100"}, + {Name: "log_connections", Value: "on"}, + } + got := renderedFlags(mergeDatabaseFlags(existing, map[string]string{ + "max_connections": "200", + "cloudsql.iam_authentication": "on", + })) + Expect(got).To(Equal([]string{ + "cloudsql.iam_authentication=on", + "log_connections=on", + "max_connections=200", + })) + }) +} + +func TestAdoptIgnoreChanges(t *testing.T) { + RegisterTestingT(t) + + t.Run("no databaseFlags keeps the legacy no-op on settings.databaseFlags", func(t *testing.T) { + RegisterTestingT(t) + Expect(adoptIgnoreChanges(nil)).To(ContainElement("settings.databaseFlags")) + }) + + t.Run("explicit databaseFlags un-ignores settings.databaseFlags", func(t *testing.T) { + RegisterTestingT(t) + got := adoptIgnoreChanges(map[string]string{"cloudsql.iam_authentication": "on"}) + Expect(got).ToNot(ContainElement("settings.databaseFlags")) + // The rest of the protection list must stay intact. + Expect(got).To(ContainElement("settings.backupConfiguration")) + }) +}