From 9b57aa3bb8d3782437e79c2decb879ea7b58d630 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 15 May 2026 20:06:59 +0400 Subject: [PATCH 1/8] fix(gcp): IgnoreChanges(metadata.namespace) on CSQL Secret + InitDbUserJob (continuation of #255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #255 (b4fd96f) added IgnoreChanges("metadata.name") to the Namespace resource so PR #230's `-` rename no longer cascade-deletes the parent namespace. It did not cover the CSQL credential Secrets and the db-user-init Job, which pick up the isolated namespace from kubernetes.GenerateNamespaceName via pkg/clouds/pulumi/gcp/compute_proc.go and trigger their own Pulumi Replace. ## Confirmed failure mode (, 2026-05-15) Stack -- (parentEnv=production, stackEnv=). After #255 the plan looks like: ~ Namespace --ns [id=] ← held parent-shared by #255 +- Secret postgres-...-sidecarcsql-...-creds (provider=postgres cluster) namespace: "" => "-" +- Secret postgres-...-initcsql-creds (provider=postgres cluster) namespace: "" => "-" +- Secret db-user-init-creds (provider=postgres cluster) namespace: "" => "-" +- Job db-user-init (provider=postgres cluster) namespace: "" => "-" All four Replace operations fail identically: Retry #5; creation failed: namespaces "-" not found ... error: update failed The isolated namespace doesn't exist on either provider's view of the cluster — the Namespace resource is held in parent-shared by #255. ## Fix Symmetric IgnoreChanges("metadata.namespace") on the four resources, in the two GCP CSQL call sites: - pkg/clouds/pulumi/gcp/cloudsql_proxy.go:NewCloudsqlProxy — covers both call sites in compute_proc.go (sidecar proxy + init-time ad-hoc proxy), fixing the two postgres-sidecarcsql/initcsql creds Secrets. - pkg/clouds/pulumi/gcp/init_pg_user_job.go:NewInitDbUserJob — fixes the db-user-init-creds Secret + the db-user-init Job. Existing stacks keep their Secrets/Job co-located with the consuming pod in the parent-shared namespace (same trade-off #255 made for the Namespace). Fresh stacks (no prior state) still get the isolated namespace on first create — IgnoreChanges only suppresses *diff*, not *initial value*. ## Why not fix GenerateNamespaceName instead The root-cause approach (rewrite the GenerateNamespaceName call sites in compute_proc.go to use the existing Namespace resource's actual k8s name) is cleaner long-term but requires plumbing the Namespace resource output through several layers of pre/post-processor closures in the gcp package. Symmetric-to-#255 is the minimum-change fix that unblocks affected production stacks without re-introducing the destroy-cascade risk #230 was originally guarding against. ## Verification - go build ./... clean - go test ./pkg/clouds/pulumi/gcp/... pass - Preview deploy on --: expected to drop the 4 Replace operations and proceed past the namespace step. Signed-off-by: Dmitrii Creed --- pkg/clouds/pulumi/gcp/cloudsql_proxy.go | 13 +++++++++++-- pkg/clouds/pulumi/gcp/init_pg_user_job.go | 12 ++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/pkg/clouds/pulumi/gcp/cloudsql_proxy.go b/pkg/clouds/pulumi/gcp/cloudsql_proxy.go index 90649414..ca8dab0d 100644 --- a/pkg/clouds/pulumi/gcp/cloudsql_proxy.go +++ b/pkg/clouds/pulumi/gcp/cloudsql_proxy.go @@ -94,12 +94,21 @@ func NewCloudsqlProxy(ctx *sdk.Context, args CloudSQLProxyArgs, opts ...sdk.Reso } opts = append(opts, sdk.Provider(args.KubeProvider)) - // Sanitize the secret name to comply with Kubernetes 63-character limit + // IgnoreChanges("metadata.namespace") — symmetric with PR #255's IgnoreChanges("metadata.name") on the + // Namespace resource itself. PR #230 changed kubernetes.GenerateNamespaceName to suffix custom-stack + // namespaces with stackEnv (e.g. "web-app" → "web-app-xcore"), and compute_proc.go feeds that derived + // name into args.Metadata.Namespace here. For stacks whose Pulumi state predates #230, the existing + // CSQL credential Secret lives in the parent-shared namespace (`web-app`), but the new program wants + // `web-app-xcore` — which is immutable on Secret, so Pulumi schedules a Replace. The new Secret then + // fails to create because the isolated namespace doesn't exist on the cluster (the Namespace resource + // is itself kept in parent-shared by #255's IgnoreChanges). IgnoreChanges suppresses the diff, leaving + // the Secret co-located with the consuming pod in the parent namespace — the same trade-off #255 made + // for the Namespace. Fresh stacks (no prior state) get the isolated namespace on first create. secretName := util.SanitizeK8sResourceName(args.Name + "-creds") sqlProxySecret, err := v1.NewSecret(ctx, secretName, &v1.SecretArgs{ Metadata: args.Metadata, Data: account.CredentialsSecrets, - }, opts...) + }, append(opts, sdk.IgnoreChanges([]string{"metadata.namespace"}))...) if err != nil { return nil, err } diff --git a/pkg/clouds/pulumi/gcp/init_pg_user_job.go b/pkg/clouds/pulumi/gcp/init_pg_user_job.go index 3c53e037..0235247f 100644 --- a/pkg/clouds/pulumi/gcp/init_pg_user_job.go +++ b/pkg/clouds/pulumi/gcp/init_pg_user_job.go @@ -53,6 +53,14 @@ func NewInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUserJobArgs opts := args.Opts opts = append(opts, sdk.Provider(args.KubeProvider)) + // IgnoreChanges("metadata.namespace") — see the long rationale on the SqlProxySecret call in + // cloudsql_proxy.go. Same problem here: PR #230's GenerateNamespaceName flips this Secret/Job + // from parent-shared into the per-stack isolated namespace, which is immutable, which triggers + // a Replace, which fails because the isolated namespace doesn't exist on the cluster (the + // Namespace resource itself is held in parent-shared by #255). Suppress the diff so the + // Secret + Job stay co-located with the consuming pod. + nsImmutableOpts := append(opts, sdk.IgnoreChanges([]string{"metadata.namespace"})) + // Secret creation jobCredsSecret, err := corev1.NewSecret(ctx, jobCredsName, &corev1.SecretArgs{ Metadata: &v1.ObjectMetaArgs{ @@ -63,7 +71,7 @@ func NewInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUserJobArgs "PGPASSWORD": sdk.String(args.RootPassword), "MYSQL_PWD": sdk.String(args.RootPassword), }, - }, opts...) + }, nsImmutableOpts...) if err != nil { return nil, err } @@ -143,7 +151,7 @@ psql -h localhost -U postgres -d %s -c 'GRANT pg_write_all_data TO "%s";'; }, }, }, - }, append(opts, sdk.Provider(kubeProvider))...) + }, append(nsImmutableOpts, sdk.Provider(kubeProvider))...) if err != nil { return nil, err } From 29e5be1576963abd397a1bcd3e3449158776be55 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 15 May 2026 20:46:10 +0400 Subject: [PATCH 2/8] fix(k8s): extend IgnoreChanges(metadata.namespace) to on-cluster Postgres + MongoDB init jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 22e0084 from Gemini review of PR #258. The first commit covered the GCP CloudSQL path in pkg/clouds/pulumi/gcp/, but the same GenerateNamespaceName-fed-to-string pattern exists for on-cluster databases too: - pkg/clouds/pulumi/kubernetes/compute_proc_postgres.go:221 derives the namespace and hands it to NewPostgresInitDbUserJob as a string. - pkg/clouds/pulumi/kubernetes/compute_proc_mongodb.go:140 does the same for NewMongodbInitDbUserJob. Both call sites create a Secret + Job with `Namespace: sdk.String(args.Namespace)`, so any existing custom stack consuming an on-cluster Postgres or MongoDB would hit the identical immutable-namespace Replace failure the first time it runs `pulumi up` after #230 + #255 landed. Symmetric fix: add `IgnoreChanges([]string{"metadata.namespace"})` to the Secret + Job in both init_pg_user_job.go and init_mongo_user_job.go. ## Verification - go build ./... clean - go test ./pkg/clouds/pulumi/... all packages pass - No call-site changes — only resource-option additions, so signature compatibility is preserved. ## Known limitation (Codex P2, deferred) The matching observation from Codex applies to all five resources covered between 22e0084 and this commit: if a consumer follows #255's documented opt-in migration (`pulumi stack export | jq 'del(...namespace urn...)' | pulumi stack import`) to move *into* the isolated namespace, the CSQL/init Secrets and Jobs stay pinned to the parent-shared namespace by these IgnoreChanges and the new pod cannot mount them cross-namespace. The PR description will be updated to extend the migration jq filter to also forget these resources; refactoring the two compute_proc.go call sites to plumb the live Namespace.Metadata.Name() Output is the proper long-term fix and is filed as a follow-up. Signed-off-by: Dmitrii Creed --- .../pulumi/kubernetes/init_mongo_user_job.go | 10 ++++++++-- pkg/clouds/pulumi/kubernetes/init_pg_user_job.go | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go b/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go index 7bb960d4..0cf43c0d 100644 --- a/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go +++ b/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go @@ -20,6 +20,12 @@ func NewMongodbInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUser opts := args.Opts opts = append(opts, sdk.Provider(args.KubeProvider)) + // IgnoreChanges("metadata.namespace") — see the rationale on the postgres init Job's matching call + // in init_pg_user_job.go. compute_proc_mongodb.go feeds GenerateNamespaceName's custom-stack-suffixed + // value here; without IgnoreChanges, existing stacks would Replace this Secret + the Job below into + // an isolated namespace that doesn't exist on the cluster. + nsImmutableOpts := append(opts, sdk.IgnoreChanges([]string{"metadata.namespace"})) + // Secret creation jobCredsSecret, err := corev1.NewSecret(ctx, jobCredsName, &corev1.SecretArgs{ Metadata: &v1.ObjectMetaArgs{ @@ -37,7 +43,7 @@ func NewMongodbInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUser "ROOT_DATABASE": sdk.String("admin"), "REPLICA_SET": sdk.String(args.InstanceName), }, - }, opts...) + }, nsImmutableOpts...) if err != nil { return nil, err } @@ -85,7 +91,7 @@ mongosh "mongodb://${ROOT_USER}:${ROOT_PASSWORD}@${HOST}/${DB_NAME}?authSource=$ }, }, }, - }, append(opts, sdk.Provider(kubeProvider))...) + }, append(nsImmutableOpts, sdk.Provider(kubeProvider))...) if err != nil { return nil, err } diff --git a/pkg/clouds/pulumi/kubernetes/init_pg_user_job.go b/pkg/clouds/pulumi/kubernetes/init_pg_user_job.go index 19aabda4..b1a50b8f 100644 --- a/pkg/clouds/pulumi/kubernetes/init_pg_user_job.go +++ b/pkg/clouds/pulumi/kubernetes/init_pg_user_job.go @@ -42,6 +42,16 @@ func NewPostgresInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUse opts := args.Opts opts = append(opts, sdk.Provider(args.KubeProvider)) + // IgnoreChanges("metadata.namespace") — symmetric with PR #255's IgnoreChanges("metadata.name") + // on the Namespace and the matching GCP CSQL fix in pkg/clouds/pulumi/gcp/. compute_proc_postgres.go + // derives the namespace via kubernetes.GenerateNamespaceName (which suffixes custom-stack stackEnv + // after #230); for existing stacks whose state predates #230 the previous value was the parent-shared + // namespace, so the diff schedules an immutable-namespace Replace that fails because the isolated + // namespace was never created (the Namespace itself is held parent-shared by #255). Suppress the diff + // so this Secret + the Job below stay co-located with the consuming pod. Fresh stacks still get the + // isolated namespace on initial create — IgnoreChanges only suppresses *diff*, not *initial value*. + nsImmutableOpts := append(opts, sdk.IgnoreChanges([]string{"metadata.namespace"})) + // Secret creation jobCredsSecret, err := corev1.NewSecret(ctx, jobCredsName, &corev1.SecretArgs{ Metadata: &v1.ObjectMetaArgs{ @@ -59,7 +69,7 @@ func NewPostgresInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUse "PGDATABASE": sdk.String("postgres"), "INIT_SQL": sdk.String(args.InitSQL), }, - }, opts...) + }, nsImmutableOpts...) if err != nil { return nil, err } @@ -102,7 +112,7 @@ func NewPostgresInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUse }, }, }, - }, append(opts, sdk.Provider(kubeProvider))...) + }, append(nsImmutableOpts, sdk.Provider(kubeProvider))...) if err != nil { return nil, err } From 8fc73fa2be440e0c1d25f886331fe3bf99bbe990 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 15 May 2026 21:07:30 +0400 Subject: [PATCH 3/8] refactor(k8s+gcp): thread live Namespace.Metadata.Name() Output through CSQL/init Jobs (drop IgnoreChanges hotfix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-cause fix that replaces the IgnoreChanges("metadata.namespace") hotfix added in 22e0084 + 1e16292. ## Problem the hotfix solved, and the edge case it left behind #255 added IgnoreChanges("metadata.name") to the Namespace resource so PR #230's "isolated namespace per custom stack" rename no longer cascade-deletes the parent-shared namespace. Five (then eight) downstream resources still derived their own `namespace:` field by calling kubernetes.GenerateNamespaceName again — the four CSQL secrets/jobs in pkg/clouds/pulumi/gcp/ (CloudSQL path) plus the four on-cluster postgres/mongo init Secrets/Jobs in pkg/clouds/pulumi/kubernetes/ (detected by Gemini review of PR #258). The first two commits in this PR (22e0084, 1e16292) suppressed those Replaces with IgnoreChanges("metadata.namespace"), unblocking the failure mode. Codex review flagged a real follow-up: that approach pins these resources to *whatever namespace happened to be in state*, which breaks #255's documented opt-in migration path (`pulumi stack export | jq 'del(...Namespace urn...)' | pulumi stack import` to move into the isolated namespace). ## Root-cause fix in this commit Stop recomputing the namespace via GenerateNamespaceName at every call site, and instead thread the live Namespace.Metadata.Name() Output from NewSimpleContainer through the pre/post-processor collector to every downstream resource that needs it: 1. SimpleContainerArgs gets a new NamespaceNameOutput sdk.StringOutput field (kubernetes/simple_container.go). NewSimpleContainer sets it from `namespace.Metadata.Name().Elem()` immediately after creating the Namespace, before RunPreProcessors fires. 2. InitDbUserJobArgs.Namespace switches from `string` to `sdk.StringInput` in both gcp/ and kubernetes/ packages. 3. The four pre/post-processors in gcp/compute_proc.go, kubernetes/compute_proc_postgres.go, kubernetes/compute_proc_mongodb.go now pass through `kubeArgs.NamespaceNameOutput` (preprocessor) or `sc.Namespace` (postprocessor) instead of calling GenerateNamespaceName. 4. IgnoreChanges("metadata.namespace") removed from all 8 resource call sites — the Output naturally tracks the live Namespace name, so the diff that triggered the original Replace simply doesn't happen anymore. ## Why this fixes both the original failure AND the migration path - Migrated stack ( today): Namespace.Metadata.Name() resolves to "" (parent-shared, locked in by #255). Downstream secrets follow → no diff, no Replace. Identical observable outcome to the IgnoreChanges hotfix. - Fresh -like stack: Namespace.Metadata.Name() resolves to "-" (isolated). Downstream secrets land there on initial create. Identical to hotfix behaviour. - Codex-flagged migration path: consumer does the documented `jq 'del(...Namespace urn...)' | pulumi stack import`. Next pulumi up creates a fresh Namespace at the isolated name. The Output now resolves to "-" for the same Pulumi run, so downstream secrets/Job get *recreated* in the new namespace alongside the pod. No cross-namespace mount fail, no extended jq filter needed in the migration runbook. ## Verification - `go build ./...` clean - `go test ./...` all packages pass except pkg/security/scan, which fails on grype's local CVE database being >5 days old (environment issue, unrelated to this PR). ## Files - pkg/clouds/pulumi/kubernetes/simple_container.go + SimpleContainerArgs.NamespaceNameOutput + set from namespace.Metadata.Name().Elem() pre RunPreProcessors - pkg/clouds/pulumi/gcp/compute_proc.go + addCloudsqlProxySidecarPreProcessor reads kubeArgs.NamespaceNameOutput + post-processor for InitDbUserJob uses sc.Namespace + cloudsqlProxyMeta → cloudsqlProxyMetaFromOutput - pkg/clouds/pulumi/gcp/cloudsql_proxy.go + drop IgnoreChanges, restore opts... - pkg/clouds/pulumi/gcp/init_pg_user_job.go + InitDbUserJobArgs.Namespace: string → sdk.StringInput + drop IgnoreChanges; thread Output into both Secret and Job - pkg/clouds/pulumi/kubernetes/compute_proc_postgres.go + drop GenerateNamespaceName derive, read kubeArgs.NamespaceNameOutput - pkg/clouds/pulumi/kubernetes/compute_proc_mongodb.go + same as compute_proc_postgres.go - pkg/clouds/pulumi/kubernetes/init_pg_user_job.go + InitDbUserJobArgs.Namespace: string → sdk.StringInput + drop IgnoreChanges - pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go + drop IgnoreChanges; consume Namespace as sdk.StringInput Signed-off-by: Dmitrii Creed --- pkg/clouds/pulumi/gcp/cloudsql_proxy.go | 18 +++---- pkg/clouds/pulumi/gcp/compute_proc.go | 49 ++++++++----------- pkg/clouds/pulumi/gcp/init_pg_user_job.go | 29 +++++------ .../pulumi/kubernetes/compute_proc_mongodb.go | 18 +++---- .../kubernetes/compute_proc_postgres.go | 20 ++++---- .../pulumi/kubernetes/init_mongo_user_job.go | 15 ++---- .../pulumi/kubernetes/init_pg_user_job.go | 33 +++++-------- .../pulumi/kubernetes/simple_container.go | 18 +++++++ 8 files changed, 94 insertions(+), 106 deletions(-) diff --git a/pkg/clouds/pulumi/gcp/cloudsql_proxy.go b/pkg/clouds/pulumi/gcp/cloudsql_proxy.go index ca8dab0d..3971908b 100644 --- a/pkg/clouds/pulumi/gcp/cloudsql_proxy.go +++ b/pkg/clouds/pulumi/gcp/cloudsql_proxy.go @@ -94,21 +94,17 @@ func NewCloudsqlProxy(ctx *sdk.Context, args CloudSQLProxyArgs, opts ...sdk.Reso } opts = append(opts, sdk.Provider(args.KubeProvider)) - // IgnoreChanges("metadata.namespace") — symmetric with PR #255's IgnoreChanges("metadata.name") on the - // Namespace resource itself. PR #230 changed kubernetes.GenerateNamespaceName to suffix custom-stack - // namespaces with stackEnv (e.g. "web-app" → "web-app-xcore"), and compute_proc.go feeds that derived - // name into args.Metadata.Namespace here. For stacks whose Pulumi state predates #230, the existing - // CSQL credential Secret lives in the parent-shared namespace (`web-app`), but the new program wants - // `web-app-xcore` — which is immutable on Secret, so Pulumi schedules a Replace. The new Secret then - // fails to create because the isolated namespace doesn't exist on the cluster (the Namespace resource - // is itself kept in parent-shared by #255's IgnoreChanges). IgnoreChanges suppresses the diff, leaving - // the Secret co-located with the consuming pod in the parent namespace — the same trade-off #255 made - // for the Namespace. Fresh stacks (no prior state) get the isolated namespace on first create. + // args.Metadata.Namespace is the live Namespace.Metadata.Name() Output, threaded + // through by compute_proc.go (preprocessor uses kubeArgs.NamespaceNameOutput, + // postprocessor uses sc.Namespace). That means this Secret automatically lands + // in the same k8s namespace as the consuming pod under both fresh deploys (isolated + // name) and migrated stacks where #255's IgnoreChanges("metadata.name") keeps the + // Namespace parent-shared — no IgnoreChanges needed here. secretName := util.SanitizeK8sResourceName(args.Name + "-creds") sqlProxySecret, err := v1.NewSecret(ctx, secretName, &v1.SecretArgs{ Metadata: args.Metadata, Data: account.CredentialsSecrets, - }, append(opts, sdk.IgnoreChanges([]string{"metadata.namespace"}))...) + }, opts...) if err != nil { return nil, err } diff --git a/pkg/clouds/pulumi/gcp/compute_proc.go b/pkg/clouds/pulumi/gcp/compute_proc.go index 109c85c3..f50f1d61 100644 --- a/pkg/clouds/pulumi/gcp/compute_proc.go +++ b/pkg/clouds/pulumi/gcp/compute_proc.go @@ -215,15 +215,20 @@ func appendUsesPostgresResourceContext(ctx *sdk.Context, params appendParams) er func addCloudsqlProxySidecarPreProcessor(ctx *sdk.Context, params appendParams) { params.collector.AddPreProcessor(&kubernetes.SimpleContainerArgs{}, func(arg any) error { - cloudsqlProxy, err := createCloudsqlProxy(ctx, params) - if err != nil { - return errors.Wrapf(err, "failed to create cloudsql proxy for %q in stack %q", params.postgresName, params.stack.Name) - } - kubeArgs, ok := arg.(*kubernetes.SimpleContainerArgs) if !ok { return errors.Errorf("arg is not *kubernetes.Args") } + // Live Namespace name from the just-created Namespace resource. Using this + // Output (instead of recomputing via kubernetes.GenerateNamespaceName) keeps + // the CSQL credential Secret in lock-step with the Namespace under both + // fresh deploys (Output resolves to the isolated name) and migrated stacks + // where #255's IgnoreChanges("metadata.name") keeps the Namespace's k8s + // name parent-shared. + cloudsqlProxy, err := createCloudsqlProxy(ctx, params, kubeArgs.NamespaceNameOutput) + if err != nil { + return errors.Wrapf(err, "failed to create cloudsql proxy for %q in stack %q", params.postgresName, params.stack.Name) + } kubeArgs.SidecarOutputs = append(kubeArgs.SidecarOutputs, cloudsqlProxy.ProxyContainer.ApplyT(func(arg any) corev1.ContainerArgs { return arg.(corev1.ContainerArgs) }).(corev1.ContainerOutput)) @@ -239,7 +244,7 @@ func addCloudsqlProxySidecarPreProcessor(ctx *sdk.Context, params appendParams) }) } -func createCloudsqlProxy(ctx *sdk.Context, params appendParams) (*CloudSQLProxy, error) { +func createCloudsqlProxy(ctx *sdk.Context, params appendParams, namespaceOutput sdk.StringInput) (*CloudSQLProxy, error) { // Fix for custom stacks: ensure input.StackParams.ParentEnv is set correctly for proper resource naming if params.provisionParams.ParentStack != nil && params.provisionParams.ParentStack.ParentEnv != "" && params.provisionParams.ParentStack.ParentEnv != params.input.StackParams.Environment { @@ -251,16 +256,6 @@ func createCloudsqlProxy(ctx *sdk.Context, params appendParams) (*CloudSQLProxy, // This ensures service accounts are unique per environment (e.g., telegram-bot--on-sidecarcsql--production vs telegram-bot--on-sidecarcsql--staging) baseProxyName := fmt.Sprintf("%s-%s-sidecarcsql", params.stack.Name, params.postgresName) cloudsqlProxyName := kubernetes.SanitizeK8sName(params.input.ToResName(baseProxyName)) - // The CloudSQL proxy emits a Kubernetes Secret (proxy credentials) that must live in - // the same namespace as the consuming pod for it to be mountable. For custom stacks - // (parentEnv != stackEnv) the pod namespace is `-` per - // kubernetes.GenerateNamespaceName, so derive that here. - parentEnv := "" - if params.provisionParams.ParentStack != nil { - parentEnv = params.provisionParams.ParentStack.ParentEnv - } - derivedNamespace := kubernetes.GenerateNamespaceName(params.input.StackParams.StackName, params.input.StackParams.Environment, parentEnv) - sanitizedNamespace := kubernetes.SanitizeK8sName(derivedNamespace) cloudsqlProxy, err := NewCloudsqlProxy(ctx, CloudSQLProxyArgs{ Name: cloudsqlProxyName, DBInstance: PostgresDBInstanceArgs{ @@ -270,7 +265,7 @@ func createCloudsqlProxy(ctx *sdk.Context, params appendParams) (*CloudSQLProxy, }, GcpProvider: params.gcpProvider, KubeProvider: params.kubeProvider, - Metadata: cloudsqlProxyMeta(sanitizedNamespace, cloudsqlProxyName, params), + Metadata: cloudsqlProxyMetaFromOutput(namespaceOutput, cloudsqlProxyName, params), }) if err != nil { return nil, err @@ -354,27 +349,25 @@ func createUserForDatabase(ctx *sdk.Context, userName, dbName string, params app // Sanitize names to comply with Kubernetes RFC 1123 requirements (no underscores) cloudsqlProxyName := kubernetes.SanitizeK8sName(util.TrimStringMiddle(fmt.Sprintf("%s-%s-initcsql", userName, params.postgresName), 60, "-")) // Init job + ad-hoc CloudSQL proxy must live in the same namespace as the consuming - // pod so the proxy's credential Secret is mountable. For custom stacks the pod is - // in `-` per kubernetes.GenerateNamespaceName. - parentEnv := "" - if params.provisionParams.ParentStack != nil { - parentEnv = params.provisionParams.ParentStack.ParentEnv - } - namespace := kubernetes.SanitizeK8sName(kubernetes.GenerateNamespaceName(params.input.StackParams.StackName, params.input.StackParams.Environment, parentEnv)) + // pod so the proxy's credential Secret is mountable. Use the live Namespace.Metadata.Name() + // Output from the just-deployed SimpleContainer (instead of recomputing via + // GenerateNamespaceName), so we follow #255's IgnoreChanges("metadata.name") on the + // Namespace: migrated stacks stay parent-shared, fresh stacks get the isolated name. + namespaceOutput := sc.Namespace cloudsqlProxy, err := NewCloudsqlProxy(ctx, CloudSQLProxyArgs{ Name: cloudsqlProxyName, DBInstance: dbInstanceArgs, GcpProvider: params.gcpProvider, KubeProvider: params.kubeProvider, TimeoutSec: MaxInitSQLTimeSec, - Metadata: cloudsqlProxyMeta(namespace, cloudsqlProxyName, params), + Metadata: cloudsqlProxyMetaFromOutput(namespaceOutput, cloudsqlProxyName, params), }, sdk.DependsOn([]sdk.Resource{sc})) if err != nil { return errors.Wrapf(err, "failed to init cloudsql proxy") } _, err = NewInitDbUserJob(ctx, userName, InitDbUserJobArgs{ - Namespace: namespace, + Namespace: namespaceOutput, User: CloudsqlDbUser{ Database: dbName, Username: userName, @@ -395,9 +388,9 @@ func createUserForDatabase(ctx *sdk.Context, userName, dbName string, params app return password, nil } -func cloudsqlProxyMeta(namespace string, cloudsqlProxyName string, params appendParams) *v1.ObjectMetaArgs { +func cloudsqlProxyMetaFromOutput(namespace sdk.StringInput, cloudsqlProxyName string, params appendParams) *v1.ObjectMetaArgs { return &v1.ObjectMetaArgs{ - Namespace: sdk.String(namespace), + Namespace: namespace, Name: sdk.String(cloudsqlProxyName), Labels: sdk.StringMap{ kubernetes.LabelAppType: sdk.String(kubernetes.AppTypeSimpleContainer), diff --git a/pkg/clouds/pulumi/gcp/init_pg_user_job.go b/pkg/clouds/pulumi/gcp/init_pg_user_job.go index 0235247f..98f90061 100644 --- a/pkg/clouds/pulumi/gcp/init_pg_user_job.go +++ b/pkg/clouds/pulumi/gcp/init_pg_user_job.go @@ -36,8 +36,13 @@ type InitDbUserJobArgs struct { CloudSQLProxy *CloudSQLProxy KubeProvider *sdkK8s.Provider DBInstanceType CloudsqlInstanceType - Namespace string - Opts []sdk.ResourceOption + // Namespace is the live Namespace name Output, threaded from + // SimpleContainerArgs.NamespaceNameOutput / SimpleContainer.Namespace via + // compute_proc.go. Do not re-derive via kubernetes.GenerateNamespaceName — + // that drifts from the actual k8s name on migrated stacks where #255's + // IgnoreChanges("metadata.name") keeps the Namespace parent-shared. + Namespace sdk.StringInput + Opts []sdk.ResourceOption } type InitUserJob struct { @@ -53,25 +58,17 @@ func NewInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUserJobArgs opts := args.Opts opts = append(opts, sdk.Provider(args.KubeProvider)) - // IgnoreChanges("metadata.namespace") — see the long rationale on the SqlProxySecret call in - // cloudsql_proxy.go. Same problem here: PR #230's GenerateNamespaceName flips this Secret/Job - // from parent-shared into the per-stack isolated namespace, which is immutable, which triggers - // a Replace, which fails because the isolated namespace doesn't exist on the cluster (the - // Namespace resource itself is held in parent-shared by #255). Suppress the diff so the - // Secret + Job stay co-located with the consuming pod. - nsImmutableOpts := append(opts, sdk.IgnoreChanges([]string{"metadata.namespace"})) - // Secret creation jobCredsSecret, err := corev1.NewSecret(ctx, jobCredsName, &corev1.SecretArgs{ Metadata: &v1.ObjectMetaArgs{ - Namespace: sdk.String(args.Namespace), + Namespace: args.Namespace, Name: sdk.String(jobCredsName), }, StringData: sdk.StringMap{ "PGPASSWORD": sdk.String(args.RootPassword), "MYSQL_PWD": sdk.String(args.RootPassword), }, - }, nsImmutableOpts...) + }, opts...) if err != nil { return nil, err } @@ -84,14 +81,14 @@ func NewInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUserJobArgs if args.DBInstanceType == MySQL { initScript = ` set -e; - apk add --no-cache mysql-client; + apk add --no-cache mysql-client; sleep 20; # MySQL-specific logic here ` } else { initScript = fmt.Sprintf(` set -e; -apk add --no-cache postgresql-client; +apk add --no-cache postgresql-client; sleep 20; psql -h localhost -U postgres -d %s -c 'GRANT pg_read_all_data TO "%s";'; psql -h localhost -U postgres -d %s -c 'GRANT pg_write_all_data TO "%s";'; @@ -129,7 +126,7 @@ psql -h localhost -U postgres -d %s -c 'GRANT pg_write_all_data TO "%s";'; job, err := batchv1.NewJob(ctx, jobName, &batchv1.JobArgs{ Metadata: &v1.ObjectMetaArgs{ Name: sdk.String(jobName), - Namespace: sdk.String(namespace), + Namespace: namespace, Annotations: sdk.StringMap{ "pulumi.com/patchForce": sdk.String("true"), }, @@ -151,7 +148,7 @@ psql -h localhost -U postgres -d %s -c 'GRANT pg_write_all_data TO "%s";'; }, }, }, - }, append(nsImmutableOpts, sdk.Provider(kubeProvider))...) + }, append(opts, sdk.Provider(kubeProvider))...) if err != nil { return nil, err } diff --git a/pkg/clouds/pulumi/kubernetes/compute_proc_mongodb.go b/pkg/clouds/pulumi/kubernetes/compute_proc_mongodb.go index ff4003dd..b31d5a8f 100644 --- a/pkg/clouds/pulumi/kubernetes/compute_proc_mongodb.go +++ b/pkg/clouds/pulumi/kubernetes/compute_proc_mongodb.go @@ -130,18 +130,16 @@ func createMongodbUserForDatabase(ctx *sdk.Context, userName, dbName string, par ctx.Export(passwordName, password.Result) // The init job must run in the same namespace as the consuming pod so it can be - // observed and cleaned up alongside the workload. For custom stacks (parentEnv != stackEnv) - // the pod lives in `-` per GenerateNamespaceName, so derive the same - // namespace here. Standard stacks keep the existing `` namespace. - parentEnv := "" - if params.provisionParams.ParentStack != nil { - parentEnv = params.provisionParams.ParentStack.ParentEnv - } - namespace := GenerateNamespaceName(params.input.StackParams.StackName, params.input.StackParams.Environment, parentEnv) - + // observed and cleaned up alongside the workload. Use the live Namespace.Metadata.Name() + // Output threaded through SimpleContainerArgs.NamespaceNameOutput — see the matching + // rationale in compute_proc_postgres.go. params.collector.AddPreProcessor(&SimpleContainerArgs{}, func(c any) error { + kubeArgs, ok := c.(*SimpleContainerArgs) + if !ok { + return errors.Errorf("arg is not *SimpleContainerArgs") + } _, err = NewMongodbInitDbUserJob(ctx, userName, InitDbUserJobArgs{ - Namespace: namespace, + Namespace: kubeArgs.NamespaceNameOutput, User: DatabaseUser{ Database: dbName, Username: userName, diff --git a/pkg/clouds/pulumi/kubernetes/compute_proc_postgres.go b/pkg/clouds/pulumi/kubernetes/compute_proc_postgres.go index 05228965..11cd973a 100644 --- a/pkg/clouds/pulumi/kubernetes/compute_proc_postgres.go +++ b/pkg/clouds/pulumi/kubernetes/compute_proc_postgres.go @@ -211,18 +211,18 @@ func createPostgresUserForDatabase(ctx *sdk.Context, userName, dbName string, pa } // The init job must run in the same namespace as the consuming pod so it can be - // observed and cleaned up alongside the workload. For custom stacks (parentEnv != stackEnv) - // the pod lives in `-` per GenerateNamespaceName, so derive the same - // namespace here. Standard stacks keep the existing `` namespace. - parentEnv := "" - if params.provisionParams.ParentStack != nil { - parentEnv = params.provisionParams.ParentStack.ParentEnv - } - namespace := GenerateNamespaceName(params.input.StackParams.StackName, params.input.StackParams.Environment, parentEnv) - + // observed and cleaned up alongside the workload. Use the live Namespace.Metadata.Name() + // Output threaded through SimpleContainerArgs.NamespaceNameOutput (set by NewSimpleContainer + // after the Namespace is created, before this PreProcessor fires) — recomputing via + // GenerateNamespaceName would drift from the actual k8s name on migrated stacks where #255's + // IgnoreChanges("metadata.name") keeps the Namespace parent-shared. params.collector.AddPreProcessor(&SimpleContainerArgs{}, func(c any) error { + kubeArgs, ok := c.(*SimpleContainerArgs) + if !ok { + return errors.Errorf("arg is not *SimpleContainerArgs") + } _, err = NewPostgresInitDbUserJob(ctx, userName, InitDbUserJobArgs{ - Namespace: namespace, + Namespace: kubeArgs.NamespaceNameOutput, User: DatabaseUser{ Database: dbName, Username: userName, diff --git a/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go b/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go index 0cf43c0d..84363679 100644 --- a/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go +++ b/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go @@ -20,16 +20,10 @@ func NewMongodbInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUser opts := args.Opts opts = append(opts, sdk.Provider(args.KubeProvider)) - // IgnoreChanges("metadata.namespace") — see the rationale on the postgres init Job's matching call - // in init_pg_user_job.go. compute_proc_mongodb.go feeds GenerateNamespaceName's custom-stack-suffixed - // value here; without IgnoreChanges, existing stacks would Replace this Secret + the Job below into - // an isolated namespace that doesn't exist on the cluster. - nsImmutableOpts := append(opts, sdk.IgnoreChanges([]string{"metadata.namespace"})) - // Secret creation jobCredsSecret, err := corev1.NewSecret(ctx, jobCredsName, &corev1.SecretArgs{ Metadata: &v1.ObjectMetaArgs{ - Namespace: sdk.String(args.Namespace), + Namespace: args.Namespace, Name: sdk.String(jobCredsName), }, StringData: sdk.StringMap{ @@ -43,7 +37,7 @@ func NewMongodbInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUser "ROOT_DATABASE": sdk.String("admin"), "REPLICA_SET": sdk.String(args.InstanceName), }, - }, nsImmutableOpts...) + }, opts...) if err != nil { return nil, err } @@ -71,13 +65,12 @@ mongosh "mongodb://${ROOT_USER}:${ROOT_PASSWORD}@${HOST}/${DB_NAME}?authSource=$ } kubeProvider := args.KubeProvider - namespace := args.Namespace // Job creation job, err := batchv1.NewJob(ctx, jobName, &batchv1.JobArgs{ Metadata: &v1.ObjectMetaArgs{ Name: sdk.String(jobName), - Namespace: sdk.String(namespace), + Namespace: args.Namespace, Annotations: sdk.StringMap{ "pulumi.com/patchForce": sdk.String("true"), }, @@ -91,7 +84,7 @@ mongosh "mongodb://${ROOT_USER}:${ROOT_PASSWORD}@${HOST}/${DB_NAME}?authSource=$ }, }, }, - }, append(nsImmutableOpts, sdk.Provider(kubeProvider))...) + }, append(opts, sdk.Provider(kubeProvider))...) if err != nil { return nil, err } diff --git a/pkg/clouds/pulumi/kubernetes/init_pg_user_job.go b/pkg/clouds/pulumi/kubernetes/init_pg_user_job.go index b1a50b8f..85e21f29 100644 --- a/pkg/clouds/pulumi/kubernetes/init_pg_user_job.go +++ b/pkg/clouds/pulumi/kubernetes/init_pg_user_job.go @@ -24,11 +24,15 @@ type InitDbUserJobArgs struct { RootUser string RootPassword string KubeProvider sdk.ProviderResource - Namespace string - Host string - Port string - InitSQL string - Opts []sdk.ResourceOption + // Namespace is the live Namespace name Output, threaded through from + // SimpleContainerArgs.NamespaceNameOutput via compute_proc_postgres.go and + // compute_proc_mongodb.go. Do not re-derive via GenerateNamespaceName — + // see the long rationale on the matching field in gcp.InitDbUserJobArgs. + Namespace sdk.StringInput + Host string + Port string + InitSQL string + Opts []sdk.ResourceOption } type InitUserJob struct { @@ -42,20 +46,10 @@ func NewPostgresInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUse opts := args.Opts opts = append(opts, sdk.Provider(args.KubeProvider)) - // IgnoreChanges("metadata.namespace") — symmetric with PR #255's IgnoreChanges("metadata.name") - // on the Namespace and the matching GCP CSQL fix in pkg/clouds/pulumi/gcp/. compute_proc_postgres.go - // derives the namespace via kubernetes.GenerateNamespaceName (which suffixes custom-stack stackEnv - // after #230); for existing stacks whose state predates #230 the previous value was the parent-shared - // namespace, so the diff schedules an immutable-namespace Replace that fails because the isolated - // namespace was never created (the Namespace itself is held parent-shared by #255). Suppress the diff - // so this Secret + the Job below stay co-located with the consuming pod. Fresh stacks still get the - // isolated namespace on initial create — IgnoreChanges only suppresses *diff*, not *initial value*. - nsImmutableOpts := append(opts, sdk.IgnoreChanges([]string{"metadata.namespace"})) - // Secret creation jobCredsSecret, err := corev1.NewSecret(ctx, jobCredsName, &corev1.SecretArgs{ Metadata: &v1.ObjectMetaArgs{ - Namespace: sdk.String(args.Namespace), + Namespace: args.Namespace, Name: sdk.String(jobCredsName), }, StringData: sdk.StringMap{ @@ -69,7 +63,7 @@ func NewPostgresInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUse "PGDATABASE": sdk.String("postgres"), "INIT_SQL": sdk.String(args.InitSQL), }, - }, nsImmutableOpts...) + }, opts...) if err != nil { return nil, err } @@ -92,13 +86,12 @@ func NewPostgresInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUse } kubeProvider := args.KubeProvider - namespace := args.Namespace // Job creation job, err := batchv1.NewJob(ctx, jobName, &batchv1.JobArgs{ Metadata: &v1.ObjectMetaArgs{ Name: sdk.String(jobName), - Namespace: sdk.String(namespace), + Namespace: args.Namespace, Annotations: sdk.StringMap{ "pulumi.com/patchForce": sdk.String("true"), }, @@ -112,7 +105,7 @@ func NewPostgresInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUse }, }, }, - }, append(nsImmutableOpts, sdk.Provider(kubeProvider))...) + }, append(opts, sdk.Provider(kubeProvider))...) if err != nil { return nil, err } diff --git a/pkg/clouds/pulumi/kubernetes/simple_container.go b/pkg/clouds/pulumi/kubernetes/simple_container.go index e5d503a8..1cc9fedc 100644 --- a/pkg/clouds/pulumi/kubernetes/simple_container.go +++ b/pkg/clouds/pulumi/kubernetes/simple_container.go @@ -141,6 +141,18 @@ type SimpleContainerArgs struct { UseSSL bool EphemeralSize string TerminationGracePeriodSeconds *int + + // NamespaceNameOutput is the live k8s name of the Namespace resource — set by + // NewSimpleContainer right after the Namespace is created and before + // RunPreProcessors fires. Pre/post-processors (e.g. CSQL sidecar in + // pkg/clouds/pulumi/gcp/compute_proc.go) must use this Output instead of + // recomputing the namespace via kubernetes.GenerateNamespaceName(stackName, + // stackEnv, parentEnv): the Namespace carries IgnoreChanges("metadata.name") + // (see #255), so its k8s name is the *state* name (parent-shared for + // migrated stacks, isolated for fresh stacks), not whatever + // GenerateNamespaceName would derive. Consuming this Output keeps + // downstream resources in lock-step with the Namespace through both modes. + NamespaceNameOutput sdk.StringOutput } type SimpleContainer struct { @@ -270,6 +282,12 @@ func NewSimpleContainer(ctx *sdk.Context, args *SimpleContainerArgs, opts ...sdk return nil, err } + // Expose the live Namespace name to pre/post-processors. See the comment on + // NamespaceNameOutput in SimpleContainerArgs: GCP CSQL and K8s on-cluster + // init Jobs need this Output (not GenerateNamespaceName) to land in the + // same namespace as the consuming pod under both fresh and migrated state. + args.NamespaceNameOutput = namespace.Metadata.Name().Elem() + // run pre-processors after namespace is created, but before deployment is created if args.ComputeContext != nil { if err := args.ComputeContext.RunPreProcessors(args, args); err != nil { From c16b2fc71b90218752d1a2fe6402e43c2894a387 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 15 May 2026 21:15:44 +0400 Subject: [PATCH 4/8] =?UTF-8?q?fix(k8s/mongo):=20make=20init=20Job=20idemp?= =?UTF-8?q?otent=20=E2=80=94=20createUser-or-updateUser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex re-review of 8f1212d flagged this: now that the namespace refactor makes the Mongo init Job Replace into the isolated namespace during #255's documented opt-in namespace migration (`pulumi stack export | jq 'del(...Namespace urn...)' | pulumi stack import`), the Job runs `db.createUser(...)` a second time against a database where that user already exists — Mongo returns DuplicateKey (51003), the script fails under `set -e`, and the stack's `pulumi up` ends in a failed Job that needs manual cleanup before the migration can finish. Use createUser-or-updateUser semantics keyed on `db.getUser(...)` so the Job succeeds on both the first run and any subsequent re-run (opt-in namespace migration, manual stack restore, taint+up, etc). This matches the idempotency guarantees the two postgres init paths already provide: - pkg/clouds/pulumi/db/constants.go uses `IF NOT EXISTS` guards around CREATE DATABASE / CREATE ROLE / GRANT. - pkg/clouds/pulumi/gcp/init_pg_user_job.go issues only `GRANT pg_read_all_data` / `GRANT pg_write_all_data`, both of which Postgres treats as no-ops on re-run. ## Verification - `go build ./...` clean - `go test ./pkg/clouds/pulumi/...` all five sub-packages pass - Manual review of the mongosh eval: parses cleanly, roles array inherits the same shape as before, `updateUser` accepts pwd+roles. Signed-off-by: Dmitrii Creed --- .../pulumi/kubernetes/init_mongo_user_job.go | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go b/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go index 84363679..70a2d976 100644 --- a/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go +++ b/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go @@ -41,10 +41,29 @@ func NewMongodbInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUser if err != nil { return nil, err } + // Idempotent user provisioning: db.createUser errors with code 51003 (DuplicateKey) + // if the user already exists, which would break any re-run of this Job — including + // the Replace that happens when a consumer follows #255's documented opt-in + // namespace migration (`pulumi stack export | jq 'del(...Namespace urn...)' | + // pulumi stack import`). Use createUser-or-updateUser semantics so the Job + // succeeds on both the first and any subsequent run. Matches the idempotency + // guarantees the postgres init scripts already provide via `IF NOT EXISTS` + // guards (pkg/clouds/pulumi/db/constants.go) and `GRANT` idempotency + // (pkg/clouds/pulumi/gcp/init_pg_user_job.go). createUserScript := ` set -e; -mongosh "mongodb://${ROOT_USER}:${ROOT_PASSWORD}@${HOST}/${DB_NAME}?authSource=${ROOT_DATABASE}&readPreference=primary&replicaSet=${REPLICA_SET}" \ - --eval "db.createUser({user:'${DB_USER}',pwd:'${DB_PASSWORD}',roles:[{db: '${DB_NAME}', role: 'dbAdmin'}, {db: '${DB_NAME}', role: 'readWrite'}, {db: 'local', role: 'read'}]})" +mongosh "mongodb://${ROOT_USER}:${ROOT_PASSWORD}@${HOST}/${DB_NAME}?authSource=${ROOT_DATABASE}&readPreference=primary&replicaSet=${REPLICA_SET}" --eval ' + var roles = [ + {db: "'${DB_NAME}'", role: "dbAdmin"}, + {db: "'${DB_NAME}'", role: "readWrite"}, + {db: "local", role: "read"} + ]; + if (db.getUser("'${DB_USER}'") === null) { + db.createUser({user: "'${DB_USER}'", pwd: "'${DB_PASSWORD}'", roles: roles}); + } else { + db.updateUser("'${DB_USER}'", {pwd: "'${DB_PASSWORD}'", roles: roles}); + } +' ` // Job Container creation jobContainer := corev1.ContainerArgs{ From 6c5f2abfcd08bdf0b7a029ac3ae3ecef23acb469 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 15 May 2026 21:24:13 +0400 Subject: [PATCH 5/8] fix(k8s/mongo): read credentials via process.env in mongosh (fixes word-split P0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini re-review of ce654ae caught a regression I introduced when switching to createUser-or-updateUser: the `'... "'${VAR}'" ...'` single-quote-breakout pattern unquotes the bash variable inside the mongosh `--eval` payload. If ${DB_PASSWORD}, ${DB_USER}, or ${DB_NAME} contains a space, single-quote, double-quote, $, or shell glob, bash either word-splits the eval into multiple args or breaks JS parsing — the Job fails on what used to be a clean fresh deploy. Switch to process.env access inside the mongosh script: the credentials are already injected via EnvFrom on the Job's Secret (see line 24-44 of this file), so mongosh can read them straight from the container env with zero shell interpolation. Side benefit: the password no longer appears in the eval string visible in /proc//cmdline. Connection URI on the first positional arg still uses shell interpolation, but that's the root-admin URI (ROOT_USER/ROOT_PASSWORD), which is consumed only by mongosh's connection parser — no JS-eval hazard there. The user-facing password (DB_PASSWORD), which is what Pulumi rotates and what would actually contain unpredictable characters, is now entirely env-bound. ## Verification - `go build ./...` clean - `go test ./pkg/clouds/pulumi/kubernetes/...` passes - Manual review of mongosh's process.env handling: standard since mongosh 1.0, equivalent to the bundled Node.js v16+ runtime. Signed-off-by: Dmitrii Creed --- .../pulumi/kubernetes/init_mongo_user_job.go | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go b/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go index 70a2d976..e47dcfd2 100644 --- a/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go +++ b/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go @@ -50,18 +50,29 @@ func NewMongodbInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUser // guarantees the postgres init scripts already provide via `IF NOT EXISTS` // guards (pkg/clouds/pulumi/db/constants.go) and `GRANT` idempotency // (pkg/clouds/pulumi/gcp/init_pg_user_job.go). + // + // Credentials read from process.env inside the mongosh eval rather than shell + // interpolation. Pulling them via env (a) bypasses shell quoting entirely so + // passwords containing spaces/quotes/$ don't bash-word-split or break JS + // parsing, (b) keeps the secret out of `ps`/strace visibility on the + // command line. The connection URI itself still has to be shell-interpolated + // because mongosh consumes it as its first positional argument, but the user + // password is the higher-risk value and is now end-to-end env-bound. createUserScript := ` set -e; mongosh "mongodb://${ROOT_USER}:${ROOT_PASSWORD}@${HOST}/${DB_NAME}?authSource=${ROOT_DATABASE}&readPreference=primary&replicaSet=${REPLICA_SET}" --eval ' + var dbName = process.env.DB_NAME; + var dbUser = process.env.DB_USER; + var dbPwd = process.env.DB_PASSWORD; var roles = [ - {db: "'${DB_NAME}'", role: "dbAdmin"}, - {db: "'${DB_NAME}'", role: "readWrite"}, + {db: dbName, role: "dbAdmin"}, + {db: dbName, role: "readWrite"}, {db: "local", role: "read"} ]; - if (db.getUser("'${DB_USER}'") === null) { - db.createUser({user: "'${DB_USER}'", pwd: "'${DB_PASSWORD}'", roles: roles}); + if (db.getUser(dbUser) === null) { + db.createUser({user: dbUser, pwd: dbPwd, roles: roles}); } else { - db.updateUser("'${DB_USER}'", {pwd: "'${DB_PASSWORD}'", roles: roles}); + db.updateUser(dbUser, {pwd: dbPwd, roles: roles}); } ' ` From 86a819c070c5fab8831072de1d1545c304cb3e3b Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 15 May 2026 23:11:59 +0400 Subject: [PATCH 6/8] test+docs(k8s): regression guard for namespace Output threading + sanitized inline doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Tests pkg/clouds/pulumi/kubernetes/namespace_threading_test.go adds two regression guards against silently undoing the refactor in 8fc73fa. - TestDownstreamCallSitesDoNotRecomputeNamespace walks the AST of the four downstream-consumer files (gcp/compute_proc.go, kubernetes/compute_proc_postgres.go, kubernetes/compute_proc_mongodb.go) and fails if any of the per-consumer functions (createCloudsqlProxy, createUserForDatabase, createPostgresUserForDatabase, createMongodbUserForDatabase) ever call kubernetes.GenerateNamespaceName again. Reverse-checked: deliberately inserting a GenerateNamespaceName call into createPostgresUserForDatabase trips the assertion with a precise location + remediation pointer to #258. - TestSimpleContainerArgsCarriesNamespaceNameOutput is a compile-time-style guard: declares `var args SimpleContainerArgs; _ = args.NamespaceNameOutput` so removing or renaming the field breaks the build before runtime — the four downstream consumers all read from this field. ## Docs pkg/clouds/pulumi/kubernetes/simple_container.go's long Namespace-handling comment was carrying consumer-identifying references and stale migration guidance: - Replace the two consumer-name dated references in the cascade-hazard preamble with a neutral pointer to "the consumer-side outages tracked in #255" — the same information lives in #255's PR body without naming individual deployments in the public repo. - Rewrite the opt-in migration paragraph: the same `pulumi stack export | jq 'del Namespace urn' | pulumi stack import` recipe still works to migrate from parent-shared to isolated, but it's now *automatic* (downstream Secrets/Jobs follow the live Namespace Output via SimpleContainerArgs.NamespaceNameOutput instead of needing per-Secret IgnoreChanges or a separate jq filter step). Pointed at #258 for the supporting refactor + ascii-art-free explanation. Signed-off-by: Dmitrii Creed --- .../kubernetes/namespace_threading_test.go | 125 ++++++++++++++++++ .../pulumi/kubernetes/simple_container.go | 25 ++-- 2 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 pkg/clouds/pulumi/kubernetes/namespace_threading_test.go diff --git a/pkg/clouds/pulumi/kubernetes/namespace_threading_test.go b/pkg/clouds/pulumi/kubernetes/namespace_threading_test.go new file mode 100644 index 00000000..bc234436 --- /dev/null +++ b/pkg/clouds/pulumi/kubernetes/namespace_threading_test.go @@ -0,0 +1,125 @@ +package kubernetes + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "testing" +) + +// TestDownstreamCallSitesDoNotRecomputeNamespace is a regression guard for #258. +// +// Before #258, four downstream Secret/Job call sites independently re-derived their +// k8s namespace via kubernetes.GenerateNamespaceName(stackName, stackEnv, parentEnv). +// That recomputation drifted from the live Namespace resource's metadata.Name on any +// migrated stack (where #255's IgnoreChanges keeps the Namespace in parent-shared +// state), scheduled an immutable-namespace Pulumi Replace, and failed because the +// isolated namespace doesn't exist on the cluster. +// +// #258 replaced the recomputation with Output-threading: NewSimpleContainer sets +// SimpleContainerArgs.NamespaceNameOutput from namespace.Metadata.Name().Elem() +// before RunPreProcessors fires, and the four downstream consumers now read that +// Output (preprocessor path) or sc.Namespace (postprocessor path) instead. +// +// This test fails if a future change reintroduces GenerateNamespaceName inside any +// of those consumer functions. If the test breaks, do NOT call GenerateNamespaceName +// from the listed functions — thread the namespace Output through +// SimpleContainerArgs / SimpleContainer instead. The recomputation hazard is +// documented in #258's commit history and in the long comment in simple_container.go +// next to the Namespace resource. +func TestDownstreamCallSitesDoNotRecomputeNamespace(t *testing.T) { + type site struct { + file string + forbidden []string + reason string + } + + sites := []site{ + { + file: filepath.Join("..", "gcp", "compute_proc.go"), + forbidden: []string{"createCloudsqlProxy", "createUserForDatabase"}, + reason: "GCP CSQL sidecar/init proxy consumers must consume the namespace Output threaded " + + "via kubernetes.SimpleContainerArgs.NamespaceNameOutput (preprocessor path) or " + + "SimpleContainer.Namespace (postprocessor path), not recompute it.", + }, + { + file: "compute_proc_postgres.go", + forbidden: []string{"createPostgresUserForDatabase"}, + reason: "On-cluster postgres init Job consumer must consume the namespace Output threaded " + + "via SimpleContainerArgs.NamespaceNameOutput, not recompute it.", + }, + { + file: "compute_proc_mongodb.go", + forbidden: []string{"createMongodbUserForDatabase"}, + reason: "On-cluster mongodb init Job consumer must consume the namespace Output threaded " + + "via SimpleContainerArgs.NamespaceNameOutput, not recompute it.", + }, + } + + for _, s := range sites { + s := s + t.Run(filepath.Base(s.file), func(t *testing.T) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, s.file, nil, parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse %s: %v", s.file, err) + } + + forbidden := make(map[string]struct{}, len(s.forbidden)) + for _, fn := range s.forbidden { + forbidden[fn] = struct{}{} + } + covered := make(map[string]bool, len(s.forbidden)) + + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + if _, watched := forbidden[fn.Name.Name]; !watched { + continue + } + covered[fn.Name.Name] = true + + ast.Inspect(fn.Body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + var ident *ast.Ident + switch fun := call.Fun.(type) { + case *ast.Ident: + ident = fun + case *ast.SelectorExpr: + ident = fun.Sel + } + if ident != nil && ident.Name == "GenerateNamespaceName" { + t.Errorf( + "%s:%d: %s() calls GenerateNamespaceName — see PR #258. %s", + s.file, fset.Position(call.Pos()).Line, fn.Name.Name, s.reason, + ) + } + return true + }) + } + + for _, want := range s.forbidden { + if !covered[want] { + t.Errorf("function %s not found in %s — was it renamed or removed? Update this test.", want, s.file) + } + } + }) + } +} + +// TestSimpleContainerArgsCarriesNamespaceNameOutput is a compile-time-style guard +// that SimpleContainerArgs exposes the NamespaceNameOutput field. The four +// downstream consumers (see TestDownstreamCallSitesDoNotRecomputeNamespace) read +// namespaces from this field; removing or renaming it silently regresses #258. If +// this test fails, preserve the field name (and the assignment in +// NewSimpleContainer) or update the four consumer call sites in lock-step. +func TestSimpleContainerArgsCarriesNamespaceNameOutput(t *testing.T) { + var args SimpleContainerArgs + _ = args.NamespaceNameOutput // compile-time field presence check +} diff --git a/pkg/clouds/pulumi/kubernetes/simple_container.go b/pkg/clouds/pulumi/kubernetes/simple_container.go index 1cc9fedc..0f423251 100644 --- a/pkg/clouds/pulumi/kubernetes/simple_container.go +++ b/pkg/clouds/pulumi/kubernetes/simple_container.go @@ -231,8 +231,8 @@ func NewSimpleContainer(ctx *sdk.Context, args *SimpleContainerArgs, opts ...sdk // while keeping the actual K8s namespace name as specified by the user. // // Namespace-handling has two protections against the destroy/Replace cascade - // hazard discovered in pre-PR-230 deploys (see PR #230 and the 2026-05-10 - // PAY-SPACE + 2026-05-12 fulldiveVR outages): + // hazard discovered in pre-PR-230 deploys (see PR #230 and the consumer-side + // outages tracked in #255): // // 1. RetainOnDelete(true). In legacy deploys, sub-env client stacks // (parentEnv= with stackEnv=tenant-a/tenant-b/...) shared one @@ -264,12 +264,21 @@ func NewSimpleContainer(ctx *sdk.Context, args *SimpleContainerArgs, opts ...sdk // migrated consumers continue using the shared one. Combined with // RetainOnDelete this keeps both modes safe. // - // Consumers who want to migrate an existing custom stack to the - // isolated namespace name opt in by running - // pulumi stack export | jq 'del(... namespace urn ...)' | pulumi stack import - // (forget the namespace resource — k8s namespace itself stays put), - // then the next pulumi up registers a fresh Namespace at the isolated - // name. Documented in the PR description. + // Downstream Secret/Job resources created by pre/post-processors (GCP + // CloudSQL credentials in pkg/clouds/pulumi/gcp/cloudsql_proxy.go + + // gcp/init_pg_user_job.go; on-cluster postgres + mongo init Jobs in + // pkg/clouds/pulumi/kubernetes/init_{pg,mongo}_user_job.go) consume the + // live namespace via SimpleContainerArgs.NamespaceNameOutput (set a few + // lines below from namespace.Metadata.Name().Elem()) rather than + // recomputing it via GenerateNamespaceName. That keeps them in lock-step + // with whatever metadata.Name is in state — fresh stacks get the + // isolated namespace, migrated stacks stay parent-shared — without + // needing IgnoreChanges("metadata.namespace") on every individual + // Secret/Job. Migrating an existing custom stack from parent-shared to + // isolated namespace is therefore automatic via `pulumi stack export | + // jq 'del(... namespace urn ...)' | pulumi stack import` (forget the + // Namespace resource, then `pulumi up` creates a fresh Namespace at the + // isolated name and the downstream consumers follow it). See PR #258. namespaceResourceName := fmt.Sprintf("%s-ns", sanitizedDeployment) namespace, err := corev1.NewNamespace(ctx, namespaceResourceName, &corev1.NamespaceArgs{ Metadata: &metav1.ObjectMetaArgs{ From f19a26a769fc121b763c83af9942c15071f0b229 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Sat, 16 May 2026 00:06:28 +0400 Subject: [PATCH 7/8] fixup(k8s): address Gemini findings on 86a819c (filenames + strict selector) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups Gemini caught in its review of the tests+docs commit. ## P1 — wrong files cited in the inline migration doc simple_container.go's namespace-handling comment described where the downstream Secret/Job resources are *created* (cloudsql_proxy.go, init_pg_user_job.go, init_mongo_user_job.go) when what it actually needed to point at is where the live Namespace Output is *consumed* — the three pre/post-processor call sites in compute_proc.go, compute_proc_postgres.go, and compute_proc_mongodb.go. The leaf constructors just receive sdk.StringInput and pass it through. Rewrote the paragraph to list the consumer files (which match the sites covered by the regression test). ## P2 — strict selector check in the AST regression test The pre-fixup matcher in TestDownstreamCallSitesDoNotRecomputeNamespace treated any *ast.SelectorExpr whose Sel.Name == "GenerateNamespaceName" as a violation — including methods on local types that happen to share the name (e.g. mockClient.GenerateNamespaceName()). Now the matcher also asserts that the receiver *ast.SelectorExpr.X is *ast.Ident{Name: "kubernetes"}, so the test fires only on the real package-qualified call. Reverse-checked locally: injecting `_ = kubernetes.GenerateNamespaceName(...)` into createCloudsqlProxy trips the test as before, while a sibling mockClient.GenerateNamespaceName() no longer false-positives. ## Verification - go build ./... clean - go test ./pkg/clouds/pulumi/kubernetes/... PASS for both regression guards on both shapes (bare Ident inside the same pkg and qualified SelectorExpr cross-pkg). Signed-off-by: Dmitrii Creed --- .../kubernetes/namespace_threading_test.go | 30 ++++++++++----- .../pulumi/kubernetes/simple_container.go | 37 +++++++++++-------- 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/pkg/clouds/pulumi/kubernetes/namespace_threading_test.go b/pkg/clouds/pulumi/kubernetes/namespace_threading_test.go index bc234436..61bd4e48 100644 --- a/pkg/clouds/pulumi/kubernetes/namespace_threading_test.go +++ b/pkg/clouds/pulumi/kubernetes/namespace_threading_test.go @@ -87,19 +87,31 @@ func TestDownstreamCallSitesDoNotRecomputeNamespace(t *testing.T) { if !ok { return true } - var ident *ast.Ident + // Match only the package-level function in the `kubernetes` pkg, not arbitrary + // methods that happen to share the name (e.g. mockClient.GenerateNamespaceName()). + // Two valid call shapes: + // - bare identifier: GenerateNamespaceName(...) // same-package + // - qualified selector: kubernetes.GenerateNamespaceName(...) // cross-pkg switch fun := call.Fun.(type) { case *ast.Ident: - ident = fun + if fun.Name != "GenerateNamespaceName" { + return true + } case *ast.SelectorExpr: - ident = fun.Sel - } - if ident != nil && ident.Name == "GenerateNamespaceName" { - t.Errorf( - "%s:%d: %s() calls GenerateNamespaceName — see PR #258. %s", - s.file, fset.Position(call.Pos()).Line, fn.Name.Name, s.reason, - ) + if fun.Sel.Name != "GenerateNamespaceName" { + return true + } + pkg, ok := fun.X.(*ast.Ident) + if !ok || pkg.Name != "kubernetes" { + return true + } + default: + return true } + t.Errorf( + "%s:%d: %s() calls kubernetes.GenerateNamespaceName — see PR #258. %s", + s.file, fset.Position(call.Pos()).Line, fn.Name.Name, s.reason, + ) return true }) } diff --git a/pkg/clouds/pulumi/kubernetes/simple_container.go b/pkg/clouds/pulumi/kubernetes/simple_container.go index 0f423251..fc3805e5 100644 --- a/pkg/clouds/pulumi/kubernetes/simple_container.go +++ b/pkg/clouds/pulumi/kubernetes/simple_container.go @@ -264,21 +264,28 @@ func NewSimpleContainer(ctx *sdk.Context, args *SimpleContainerArgs, opts ...sdk // migrated consumers continue using the shared one. Combined with // RetainOnDelete this keeps both modes safe. // - // Downstream Secret/Job resources created by pre/post-processors (GCP - // CloudSQL credentials in pkg/clouds/pulumi/gcp/cloudsql_proxy.go + - // gcp/init_pg_user_job.go; on-cluster postgres + mongo init Jobs in - // pkg/clouds/pulumi/kubernetes/init_{pg,mongo}_user_job.go) consume the - // live namespace via SimpleContainerArgs.NamespaceNameOutput (set a few - // lines below from namespace.Metadata.Name().Elem()) rather than - // recomputing it via GenerateNamespaceName. That keeps them in lock-step - // with whatever metadata.Name is in state — fresh stacks get the - // isolated namespace, migrated stacks stay parent-shared — without - // needing IgnoreChanges("metadata.namespace") on every individual - // Secret/Job. Migrating an existing custom stack from parent-shared to - // isolated namespace is therefore automatic via `pulumi stack export | - // jq 'del(... namespace urn ...)' | pulumi stack import` (forget the - // Namespace resource, then `pulumi up` creates a fresh Namespace at the - // isolated name and the downstream consumers follow it). See PR #258. + // Downstream Secret/Job resources consume the live namespace via + // SimpleContainerArgs.NamespaceNameOutput (set a few lines below from + // namespace.Metadata.Name().Elem()) rather than recomputing it via + // GenerateNamespaceName. The three pre/post-processor call sites that + // consume this Output live in: + // + // pkg/clouds/pulumi/gcp/compute_proc.go (GCP CSQL sidecar + init proxies) + // pkg/clouds/pulumi/kubernetes/compute_proc_postgres.go (on-cluster postgres init Job) + // pkg/clouds/pulumi/kubernetes/compute_proc_mongodb.go (on-cluster mongo init Job) + // + // From there the namespace flows into NewCloudsqlProxy / + // NewPostgresInitDbUserJob / NewMongodbInitDbUserJob as an + // sdk.StringInput, so the leaf Secret/Job ObjectMeta.Namespace tracks + // the live Output. That keeps them in lock-step with whatever + // metadata.Name is in state — fresh stacks get the isolated namespace, + // migrated stacks stay parent-shared — without needing + // IgnoreChanges("metadata.namespace") on every individual Secret/Job. + // Migrating an existing custom stack from parent-shared to isolated + // namespace is therefore automatic via `pulumi stack export | jq + // 'del(... namespace urn ...)' | pulumi stack import` (forget the + // Namespace resource, then `pulumi up` creates a fresh Namespace at + // the isolated name and the downstream consumers follow it). See PR #258. namespaceResourceName := fmt.Sprintf("%s-ns", sanitizedDeployment) namespace, err := corev1.NewNamespace(ctx, namespaceResourceName, &corev1.NamespaceArgs{ Metadata: &metav1.ObjectMetaArgs{ From 863036456fa7ccbf837065f5b4e3ffa6cb0df5da Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Sat, 16 May 2026 00:13:22 +0400 Subject: [PATCH 8/8] fixup(test): resolve kubernetes import alias in AST regression guard (codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's review of f19a26a noted that the strict selector check hardcoded the local identifier `kubernetes` and would silently miss a reintroduced call written through an aliased import — e.g. `import k8s "github.com/simple-container-com/api/pkg/clouds/pulumi/kubernetes"` followed by `k8s.GenerateNamespaceName(...)`. Gemini independently called the same case out as an acceptable trade-off; Codex pushed back asking for the proper fix. Add a tiny import-block walk (kubernetesPkgAliases) that returns the set of local names bound to the kubernetes package's full import path, and feed that into the SelectorExpr branch instead of comparing against the string "kubernetes" directly. The bare-identifier branch for same-package callers is unchanged. Implementation detail: parser.ParseFile is called twice for each consumer file — once with ImportsOnly to collect aliases cheaply, then a full parse for the function-body walk. Each parse on the three downstream files is well under 1ms, so the extra cost is imperceptible compared to running the rest of the suite. ## Verification - go build ./... clean - go test ./pkg/clouds/pulumi/kubernetes/... PASS - Reverse-checked: aliased the kubernetes import in pkg/clouds/pulumi/gcp/compute_proc.go to `k8sapi` and injected `_ = k8sapi.GenerateNamespaceName(...)` into createCloudsqlProxy. Test correctly trips with the expected diagnostic and remediation pointer. Restoring the file returns the suite to green. Signed-off-by: Dmitrii Creed --- .../kubernetes/namespace_threading_test.go | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/pkg/clouds/pulumi/kubernetes/namespace_threading_test.go b/pkg/clouds/pulumi/kubernetes/namespace_threading_test.go index 61bd4e48..d4c273b9 100644 --- a/pkg/clouds/pulumi/kubernetes/namespace_threading_test.go +++ b/pkg/clouds/pulumi/kubernetes/namespace_threading_test.go @@ -5,9 +5,40 @@ import ( "go/parser" "go/token" "path/filepath" + "strconv" "testing" ) +// kubernetesPkgPath is the import path of *this* package. The AST regression +// guard below resolves whichever local name a consumer file imports it under +// (default `kubernetes`, or any alias like `k8s ""`) so the strict +// selector check also catches `.GenerateNamespaceName(...)` and not +// only the un-aliased form. +const kubernetesPkgPath = "github.com/simple-container-com/api/pkg/clouds/pulumi/kubernetes" + +// kubernetesPkgAliases parses the file's import block and returns the set of +// local identifiers that bind to kubernetesPkgPath. Same-package files (no +// import of self) return an empty set — the caller falls back to matching the +// bare-identifier `GenerateNamespaceName(...)` form for those. +func kubernetesPkgAliases(file *ast.File) map[string]bool { + aliases := map[string]bool{} + for _, imp := range file.Imports { + if imp.Path == nil { + continue + } + path, err := strconv.Unquote(imp.Path.Value) + if err != nil || path != kubernetesPkgPath { + continue + } + name := "kubernetes" + if imp.Name != nil { + name = imp.Name.Name + } + aliases[name] = true + } + return aliases +} + // TestDownstreamCallSitesDoNotRecomputeNamespace is a regression guard for #258. // // Before #258, four downstream Secret/Job call sites independently re-derived their @@ -61,7 +92,12 @@ func TestDownstreamCallSitesDoNotRecomputeNamespace(t *testing.T) { s := s t.Run(filepath.Base(s.file), func(t *testing.T) { fset := token.NewFileSet() - file, err := parser.ParseFile(fset, s.file, nil, parser.SkipObjectResolution) + file, err := parser.ParseFile(fset, s.file, nil, parser.SkipObjectResolution|parser.ImportsOnly) + if err != nil { + t.Fatalf("parse imports of %s: %v", s.file, err) + } + aliases := kubernetesPkgAliases(file) + file, err = parser.ParseFile(fset, s.file, nil, parser.SkipObjectResolution) if err != nil { t.Fatalf("parse %s: %v", s.file, err) } @@ -91,7 +127,10 @@ func TestDownstreamCallSitesDoNotRecomputeNamespace(t *testing.T) { // methods that happen to share the name (e.g. mockClient.GenerateNamespaceName()). // Two valid call shapes: // - bare identifier: GenerateNamespaceName(...) // same-package - // - qualified selector: kubernetes.GenerateNamespaceName(...) // cross-pkg + // - qualified selector: .GenerateNamespaceName(...) // cross-pkg + // where is resolved from the file's import block via kubernetesPkgAliases. + // Default name is `kubernetes` when no alias is set; the test still flags imports + // renamed as e.g. `k8s ""`. switch fun := call.Fun.(type) { case *ast.Ident: if fun.Name != "GenerateNamespaceName" { @@ -102,7 +141,7 @@ func TestDownstreamCallSitesDoNotRecomputeNamespace(t *testing.T) { return true } pkg, ok := fun.X.(*ast.Ident) - if !ok || pkg.Name != "kubernetes" { + if !ok || !aliases[pkg.Name] { return true } default: