diff --git a/pkg/clouds/pulumi/gcp/cloudsql_proxy.go b/pkg/clouds/pulumi/gcp/cloudsql_proxy.go index 90649414..3971908b 100644 --- a/pkg/clouds/pulumi/gcp/cloudsql_proxy.go +++ b/pkg/clouds/pulumi/gcp/cloudsql_proxy.go @@ -94,7 +94,12 @@ 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 + // 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, 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 3c53e037..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 { @@ -56,7 +61,7 @@ func NewInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUserJobArgs // 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{ @@ -76,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";'; @@ -121,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"), }, 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 7bb960d4..e47dcfd2 100644 --- a/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go +++ b/pkg/clouds/pulumi/kubernetes/init_mongo_user_job.go @@ -23,7 +23,7 @@ func NewMongodbInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUser // 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{ @@ -41,10 +41,40 @@ 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). + // + // 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 "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 dbName = process.env.DB_NAME; + var dbUser = process.env.DB_USER; + var dbPwd = process.env.DB_PASSWORD; + var roles = [ + {db: dbName, role: "dbAdmin"}, + {db: dbName, role: "readWrite"}, + {db: "local", role: "read"} + ]; + if (db.getUser(dbUser) === null) { + db.createUser({user: dbUser, pwd: dbPwd, roles: roles}); + } else { + db.updateUser(dbUser, {pwd: dbPwd, roles: roles}); + } +' ` // Job Container creation jobContainer := corev1.ContainerArgs{ @@ -65,13 +95,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"), }, diff --git a/pkg/clouds/pulumi/kubernetes/init_pg_user_job.go b/pkg/clouds/pulumi/kubernetes/init_pg_user_job.go index 19aabda4..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 { @@ -45,7 +49,7 @@ func NewPostgresInitDbUserJob(ctx *sdk.Context, stackName string, args InitDbUse // 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{ @@ -82,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"), }, 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..d4c273b9 --- /dev/null +++ b/pkg/clouds/pulumi/kubernetes/namespace_threading_test.go @@ -0,0 +1,176 @@ +package kubernetes + +import ( + "go/ast" + "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 +// 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|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) + } + + 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 + } + // 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: .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" { + return true + } + case *ast.SelectorExpr: + if fun.Sel.Name != "GenerateNamespaceName" { + return true + } + pkg, ok := fun.X.(*ast.Ident) + if !ok || !aliases[pkg.Name] { + 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 + }) + } + + 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 e5d503a8..fc3805e5 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 { @@ -219,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 @@ -252,12 +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. // - // 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 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{ @@ -270,6 +298,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 {