From ac17f11268c74804a9f2d286f9434aec9082240e Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Thu, 2 Jul 2026 17:16:34 +0400 Subject: [PATCH 1/5] feat(gcp): support arbitrary Cloud SQL database flags on gcp-cloudsql-postgres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a generic databaseFlags map to the resource config, e.g.: postgres: type: gcp-cloudsql-postgres config: maxConnections: 200 databaseFlags: cloudsql.iam_authentication: "on" Previously max_connections (via maxConnections) was the only flag the resource could manage, so features gated on instance flags — IAM database authentication in particular — could not be enabled through IaC. Flags merge with maxConnections (an explicit max_connections entry in databaseFlags wins) and render sorted by name so the resulting array is deterministic across updates. The adoption path preserves unmanaged existing flags and overrides only the configured ones, generalizing the previous max_connections-only override. Signed-off-by: Dmitrii Creed --- pkg/clouds/gcloud/postgres.go | 16 ++-- pkg/clouds/gcloud/resources_test.go | 9 +++ pkg/clouds/pulumi/gcp/adopt_postgres.go | 21 ++---- pkg/clouds/pulumi/gcp/postgres.go | 39 ++++++++-- pkg/clouds/pulumi/gcp/postgres_flags_test.go | 79 ++++++++++++++++++++ 5 files changed, 137 insertions(+), 27 deletions(-) create mode 100644 pkg/clouds/pulumi/gcp/postgres_flags_test.go diff --git a/pkg/clouds/gcloud/postgres.go b/pkg/clouds/gcloud/postgres.go index 49910884..ac85963f 100644 --- a/pkg/clouds/gcloud/postgres.go +++ b/pkg/clouds/gcloud/postgres.go @@ -8,12 +8,16 @@ 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"` + 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..054c3646 100644 --- a/pkg/clouds/gcloud/resources_test.go +++ b/pkg/clouds/gcloud/resources_test.go @@ -196,6 +196,10 @@ func TestPostgresqlGcpCloudsqlReadConfig(t *testing.T) { "deletionProtection": true, "availabilityType": "REGIONAL", "requireSsl": true, + "databaseFlags": map[string]any{ + "cloudsql.iam_authentication": "on", + "log_min_duration_statement": "500", + }, "usersProvisionRuntime": map[string]any{ "type": "kube-job", "resourceName": "gke-cluster", @@ -213,6 +217,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 +243,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..eb23c9f4 100644 --- a/pkg/clouds/pulumi/gcp/adopt_postgres.go +++ b/pkg/clouds/pulumi/gcp/adopt_postgres.go @@ -112,13 +112,13 @@ 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 + // Preserve existing database flags, overridden by any flags set in config + // (MaxConnections and the generic DatabaseFlags map). + configuredFlags := configuredDatabaseFlags(pgCfg) - // First, copy all existing database flags + var databaseFlags sql.DatabaseInstanceSettingsDatabaseFlagArray for _, flag := range existingSettings.DatabaseFlags { - // Skip max_connections if we're overriding it from config - if flag.Name == "max_connections" && pgCfg.MaxConnections != nil { + if _, overridden := configuredFlags[flag.Name]; overridden { continue } databaseFlags = append(databaseFlags, sql.DatabaseInstanceSettingsDatabaseFlagArgs{ @@ -126,14 +126,9 @@ func AdoptPostgres(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, p 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) + databaseFlags = append(databaseFlags, toDatabaseFlagArray(configuredFlags)...) + if len(configuredFlags) > 0 { + params.Log.Info(ctx.Context(), "overriding database flags with config values: %v", configuredFlags) } pgInstance, err := sql.NewDatabaseInstance(ctx, postgresName, &sql.DatabaseInstanceArgs{ diff --git a/pkg/clouds/pulumi/gcp/postgres.go b/pkg/clouds/pulumi/gcp/postgres.go index 6623a325..cf7bdfac 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,32 @@ 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{} + if pgCfg.MaxConnections != nil { + flags["max_connections"] = fmt.Sprintf("%d", *pgCfg.MaxConnections) + } + for name, value := range pgCfg.DatabaseFlags { + flags[name] = value + } + return flags +} + +// 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 { + names := lo.Keys(flags) + sort.Strings(names) + var res sql.DatabaseInstanceSettingsDatabaseFlagArray + for _, name := range names { + 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..99ca8fbf --- /dev/null +++ b/pkg/clouds/pulumi/gcp/postgres_flags_test.go @@ -0,0 +1,79 @@ +// 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 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"})) + }) + + t.Run("array is sorted by flag name for deterministic diffs", func(t *testing.T) { + RegisterTestingT(t) + arr := toDatabaseFlagArray(map[string]string{ + "max_connections": "200", + "cloudsql.iam_authentication": "on", + "log_min_duration_statement": "500", + }) + Expect(arr).To(HaveLen(3)) + var names []string + for _, f := range arr { + args, ok := f.(sql.DatabaseInstanceSettingsDatabaseFlagArgs) + Expect(ok).To(BeTrue()) + names = append(names, string(args.Name.(sdk.String))) + } + Expect(names).To(Equal([]string{ + "cloudsql.iam_authentication", + "log_min_duration_statement", + "max_connections", + })) + }) +} From da206fa62b4e376b072324119e1706449a18bea3 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Thu, 2 Jul 2026 17:59:27 +0400 Subject: [PATCH 2/5] test: expect empty DatabaseFlags map in resolved postgres fixtures Placeholder resolution rebuilds maps via reflect.MakeMap, so an absent databaseFlags arrives as an empty (non-nil) map after resolution. Encode that in the resolved fixtures rather than changing resolution behavior, which other code may rely on. Signed-off-by: Dmitrii Creed --- pkg/api/tests/refapp.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pkg/api/tests/refapp.go b/pkg/api/tests/refapp.go index 2b4a32ff..36639760 100644 --- a/pkg/api/tests/refapp.go +++ b/pkg/api/tests/refapp.go @@ -481,8 +481,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: "", @@ -502,8 +503,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: "", From 4dadeda2867bea37dcd99db9dc3f8aa50f27f404 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Thu, 2 Jul 2026 21:06:45 +0400 Subject: [PATCH 3/5] fix(gcp): honor databaseFlags on adoption path; regenerate schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings: - The adoption path listed settings.databaseFlags in IgnoreChanges, so the configured-flag override logic was dead code and the override log line lied. Keep ignoring the path only while no flags are configured. - Merge existing + configured flags through one map so the rendered array is fully sorted; a mixed existing-order/sorted-tail array would churn on every update. Extracted as mergeDatabaseFlags with a unit test (preserve unmanaged, override configured, sort all). - Log only overridden flag names — values could carry substituted secrets. - Regenerate docs/schemas (schema-gen): picks up databaseFlags plus fields added since the last regeneration (backup config, availabilityType, aws storageEncrypted, etc.). Signed-off-by: Dmitrii Creed --- .../aws/cloudtrailsecurityalertsconfig.json | 82 +++++ docs/schemas/aws/mysqlconfig.json | 3 + docs/schemas/aws/postgresconfig.json | 3 + docs/schemas/core/clientdescriptor.json | 6 +- docs/schemas/core/scandescriptor.json | 3 + docs/schemas/core/securitydescriptor.json | 6 +- docs/schemas/core/signingdescriptor.json | 3 - docs/schemas/core/stackconfigcompose.json | 15 +- docs/schemas/core/stackconfigsingleimage.json | 6 +- docs/schemas/gcp/gkeautopilotresource.json | 104 ++++++ .../gcp/postgresgcpcloudsqlconfig.json | 27 ++ docs/schemas/gcp/templateconfig.json | 3 +- .../kubernetes/kubernetescloudextras.json | 333 +++++++++++++++--- pkg/clouds/pulumi/gcp/adopt_postgres.go | 46 +-- pkg/clouds/pulumi/gcp/postgres.go | 14 + pkg/clouds/pulumi/gcp/postgres_flags_test.go | 23 ++ 16 files changed, 584 insertions(+), 93 deletions(-) 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/clouds/pulumi/gcp/adopt_postgres.go b/pkg/clouds/pulumi/gcp/adopt_postgres.go index eb23c9f4..b8fb4314 100644 --- a/pkg/clouds/pulumi/gcp/adopt_postgres.go +++ b/pkg/clouds/pulumi/gcp/adopt_postgres.go @@ -5,8 +5,10 @@ package gcp import ( "fmt" + "sort" "github.com/pkg/errors" + "github.com/samber/lo" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/sql" sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" @@ -95,16 +97,33 @@ 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{ + // 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(configuredFlags) > 0 { + names := lo.Keys(configuredFlags) + sort.Strings(names) + // Names only — flag values could carry substituted secrets. + params.Log.Info(ctx.Context(), "overriding database flags from config: %v", names) + } + + // Use standardized adoption protection options. settings.databaseFlags is + // ignored only while no flags are configured — otherwise Pulumi would + // silently skip the configured overrides. + ignoreChanges := []string{ // Instance configuration that might drift - "settings.insightsConfig", "settings.databaseFlags", "settings.maintenanceWindow", + "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(configuredFlags) == 0 { + ignoreChanges = append(ignoreChanges, "settings.databaseFlags") + } + adoptionOpts := pApi.AdoptionProtectionOptions(ignoreChanges) opts := append([]sdk.ResourceOption{ sdk.Provider(params.Provider), @@ -112,25 +131,6 @@ func AdoptPostgres(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, p sdk.Import(sdk.ID(instanceResourceId)), }, adoptionOpts...) - // Preserve existing database flags, overridden by any flags set in config - // (MaxConnections and the generic DatabaseFlags map). - configuredFlags := configuredDatabaseFlags(pgCfg) - - var databaseFlags sql.DatabaseInstanceSettingsDatabaseFlagArray - for _, flag := range existingSettings.DatabaseFlags { - if _, overridden := configuredFlags[flag.Name]; overridden { - continue - } - databaseFlags = append(databaseFlags, sql.DatabaseInstanceSettingsDatabaseFlagArgs{ - Name: sdk.String(flag.Name), - Value: sdk.String(flag.Value), - }) - } - databaseFlags = append(databaseFlags, toDatabaseFlagArray(configuredFlags)...) - if len(configuredFlags) > 0 { - params.Log.Info(ctx.Context(), "overriding database flags with config values: %v", configuredFlags) - } - pgInstance, err := sql.NewDatabaseInstance(ctx, postgresName, &sql.DatabaseInstanceArgs{ Name: sdk.String(pgCfg.InstanceName), Region: sdk.StringPtr(region), diff --git a/pkg/clouds/pulumi/gcp/postgres.go b/pkg/clouds/pulumi/gcp/postgres.go index cf7bdfac..0e77ab9e 100644 --- a/pkg/clouds/pulumi/gcp/postgres.go +++ b/pkg/clouds/pulumi/gcp/postgres.go @@ -151,6 +151,20 @@ func configuredDatabaseFlags(pgCfg *gcloud.PostgresGcpCloudsqlConfig) map[string return flags } +// 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) +} + // 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 { diff --git a/pkg/clouds/pulumi/gcp/postgres_flags_test.go b/pkg/clouds/pulumi/gcp/postgres_flags_test.go index 99ca8fbf..c7d2ce1d 100644 --- a/pkg/clouds/pulumi/gcp/postgres_flags_test.go +++ b/pkg/clouds/pulumi/gcp/postgres_flags_test.go @@ -56,6 +56,29 @@ func TestConfiguredDatabaseFlags(t *testing.T) { Expect(flags).To(Equal(map[string]string{"max_connections": "500"})) }) + t.Run("adopt merge 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"}, + } + arr := mergeDatabaseFlags(existing, map[string]string{ + "max_connections": "200", + "cloudsql.iam_authentication": "on", + }) + var got []string + for _, f := range arr { + args, ok := f.(sql.DatabaseInstanceSettingsDatabaseFlagArgs) + Expect(ok).To(BeTrue()) + got = append(got, string(args.Name.(sdk.String))+"="+string(args.Value.(sdk.String))) + } + Expect(got).To(Equal([]string{ + "cloudsql.iam_authentication=on", + "log_connections=on", + "max_connections=200", + })) + }) + t.Run("array is sorted by flag name for deterministic diffs", func(t *testing.T) { RegisterTestingT(t) arr := toDatabaseFlagArray(map[string]string{ From 0885b0bb29a5d155bde638d6dd817a96674f485c Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Thu, 2 Jul 2026 22:28:54 +0400 Subject: [PATCH 4/5] fix(gcp): gate adopted-instance flag application on explicit databaseFlags Follow-up review pass: - Gate the adoption-path IgnoreChanges exemption on the NEW DatabaseFlags field instead of the merged flag set: legacy adopted stacks carrying only maxConnections keep their historical no-op instead of suddenly applying a restart-requiring static flag on the next deploy. Flag application on adopted instances is now an explicit opt-in, with a Warn covering the restart risk and the import-must-match caveat. - Extract adoptIgnoreChanges + sortedFlagNames helpers; unit-test both ignore-list branches; split flag tests per helper. - Lock in yaml.v3 scalar coercion with an unquoted-int databaseFlags test case (users write flag values bare). - Mark MaxConnections deprecated in favor of databaseFlags. - Document databaseFlags in supported-resources.md, including the removal-semantics asymmetry between provisioned and adopted instances. - Comment the {}-not-nil resolution artifact at the fixture site. Signed-off-by: Dmitrii Creed --- docs/docs/reference/supported-resources.md | 7 +- pkg/api/tests/refapp.go | 7 +- pkg/clouds/gcloud/postgres.go | 14 ++-- pkg/clouds/gcloud/resources_test.go | 4 +- pkg/clouds/pulumi/gcp/adopt_postgres.go | 62 ++++++++++------ pkg/clouds/pulumi/gcp/postgres.go | 21 ++---- pkg/clouds/pulumi/gcp/postgres_flags_test.go | 75 +++++++++++++------- 7 files changed, 116 insertions(+), 74 deletions(-) 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/pkg/api/tests/refapp.go b/pkg/api/tests/refapp.go index 36639760..9209268c 100644 --- a/pkg/api/tests/refapp.go +++ b/pkg/api/tests/refapp.go @@ -481,8 +481,11 @@ var ResolvedRefappServerDescriptor = &api.ServerDescriptor{ "postgres": { Type: gcloud.ResourceTypePostgresGcpCloudsql, Config: api.Config{Config: &gcloud.PostgresGcpCloudsqlConfig{ - Version: "14.5", - Project: "refapp", + 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{ diff --git a/pkg/clouds/gcloud/postgres.go b/pkg/clouds/gcloud/postgres.go index ac85963f..9b8fd215 100644 --- a/pkg/clouds/gcloud/postgres.go +++ b/pkg/clouds/gcloud/postgres.go @@ -8,12 +8,14 @@ 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. diff --git a/pkg/clouds/gcloud/resources_test.go b/pkg/clouds/gcloud/resources_test.go index 054c3646..44ff1a70 100644 --- a/pkg/clouds/gcloud/resources_test.go +++ b/pkg/clouds/gcloud/resources_test.go @@ -198,7 +198,9 @@ func TestPostgresqlGcpCloudsqlReadConfig(t *testing.T) { "requireSsl": true, "databaseFlags": map[string]any{ "cloudsql.iam_authentication": "on", - "log_min_duration_statement": "500", + // 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", diff --git a/pkg/clouds/pulumi/gcp/adopt_postgres.go b/pkg/clouds/pulumi/gcp/adopt_postgres.go index b8fb4314..624baf41 100644 --- a/pkg/clouds/pulumi/gcp/adopt_postgres.go +++ b/pkg/clouds/pulumi/gcp/adopt_postgres.go @@ -5,10 +5,8 @@ package gcp import ( "fmt" - "sort" "github.com/pkg/errors" - "github.com/samber/lo" "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/sql" sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" @@ -101,29 +99,15 @@ func AdoptPostgres(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, p // (MaxConnections and the generic DatabaseFlags map). configuredFlags := configuredDatabaseFlags(pgCfg) databaseFlags := mergeDatabaseFlags(existingSettings.DatabaseFlags, configuredFlags) - if len(configuredFlags) > 0 { - names := lo.Keys(configuredFlags) - sort.Strings(names) + if len(pgCfg.DatabaseFlags) > 0 { // Names only — flag values could carry substituted secrets. - params.Log.Info(ctx.Context(), "overriding database flags from config: %v", names) + 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) } - // Use standardized adoption protection options. settings.databaseFlags is - // ignored only while no flags are configured — otherwise Pulumi would - // silently skip the configured overrides. - 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(configuredFlags) == 0 { - ignoreChanges = append(ignoreChanges, "settings.databaseFlags") - } - adoptionOpts := pApi.AdoptionProtectionOptions(ignoreChanges) + adoptionOpts := pApi.AdoptionProtectionOptions(adoptIgnoreChanges(pgCfg.DatabaseFlags)) opts := append([]sdk.ResourceOption{ sdk.Provider(params.Provider), @@ -287,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 0e77ab9e..c02fe527 100644 --- a/pkg/clouds/pulumi/gcp/postgres.go +++ b/pkg/clouds/pulumi/gcp/postgres.go @@ -151,27 +151,18 @@ func configuredDatabaseFlags(pgCfg *gcloud.PostgresGcpCloudsqlConfig) map[string return flags } -// 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) +// 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 { - names := lo.Keys(flags) - sort.Strings(names) var res sql.DatabaseInstanceSettingsDatabaseFlagArray - for _, name := range names { + for _, name := range sortedFlagNames(flags) { res = append(res, sql.DatabaseInstanceSettingsDatabaseFlagArgs{ Name: sdk.String(name), Value: sdk.String(flags[name]), diff --git a/pkg/clouds/pulumi/gcp/postgres_flags_test.go b/pkg/clouds/pulumi/gcp/postgres_flags_test.go index c7d2ce1d..d1670513 100644 --- a/pkg/clouds/pulumi/gcp/postgres_flags_test.go +++ b/pkg/clouds/pulumi/gcp/postgres_flags_test.go @@ -15,6 +15,15 @@ import ( "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) @@ -55,48 +64,60 @@ func TestConfiguredDatabaseFlags(t *testing.T) { }) 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", + })) + }) +} - t.Run("adopt merge preserves unmanaged flags, overrides configured, sorts all", func(t *testing.T) { +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"}, } - arr := mergeDatabaseFlags(existing, map[string]string{ + got := renderedFlags(mergeDatabaseFlags(existing, map[string]string{ "max_connections": "200", "cloudsql.iam_authentication": "on", - }) - var got []string - for _, f := range arr { - args, ok := f.(sql.DatabaseInstanceSettingsDatabaseFlagArgs) - Expect(ok).To(BeTrue()) - got = append(got, string(args.Name.(sdk.String))+"="+string(args.Value.(sdk.String))) - } + })) Expect(got).To(Equal([]string{ "cloudsql.iam_authentication=on", "log_connections=on", "max_connections=200", })) }) +} + +func TestAdoptIgnoreChanges(t *testing.T) { + RegisterTestingT(t) - t.Run("array is sorted by flag name for deterministic diffs", func(t *testing.T) { + t.Run("no databaseFlags keeps the legacy no-op on settings.databaseFlags", func(t *testing.T) { RegisterTestingT(t) - arr := toDatabaseFlagArray(map[string]string{ - "max_connections": "200", - "cloudsql.iam_authentication": "on", - "log_min_duration_statement": "500", - }) - Expect(arr).To(HaveLen(3)) - var names []string - for _, f := range arr { - args, ok := f.(sql.DatabaseInstanceSettingsDatabaseFlagArgs) - Expect(ok).To(BeTrue()) - names = append(names, string(args.Name.(sdk.String))) - } - Expect(names).To(Equal([]string{ - "cloudsql.iam_authentication", - "log_min_duration_statement", - "max_connections", - })) + 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")) }) } From 3af80bd85bc071f8fe83ef9b6989561a4c85d28a Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Thu, 2 Jul 2026 22:39:15 +0400 Subject: [PATCH 5/5] chore: nolint SA1019 on the MaxConnections compat shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deprecation marker trips staticcheck on the shim's own read of the field — the one legitimate internal consumer. Signed-off-by: Dmitrii Creed --- pkg/clouds/pulumi/gcp/postgres.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/clouds/pulumi/gcp/postgres.go b/pkg/clouds/pulumi/gcp/postgres.go index c02fe527..f2b34f69 100644 --- a/pkg/clouds/pulumi/gcp/postgres.go +++ b/pkg/clouds/pulumi/gcp/postgres.go @@ -142,6 +142,7 @@ func toPostgresName(input api.ResourceInput, resName string) string { // 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) }