fix(k8s): isolate custom-stack namespaces and retain shared ns on destroy#230
Open
fix(k8s): isolate custom-stack namespaces and retain shared ns on destroy#230
Conversation
2c080c1 to
30bc87f
Compare
Semgrep Scan ResultsRepository:
Scanned at 2026-05-09 18:27 UTC |
Security Scan ResultsRepository:
Scanned at 2026-05-09 18:27 UTC |
30bc87f to
a49ca64
Compare
f2f92ee to
ec5a57e
Compare
…troy
Sub-env client stacks (parentEnv: production, stackEnv: gl-pay/payhey/...)
were derivieng their k8s namespace name from stackName via deployment.go:67:
namespace := lo.If(args.Namespace == "", stackName).Else(args.Namespace)
Every sibling under the same stackName ended up pointing at the same
physical namespace (e.g. `pay-space-wallet`). Each Pulumi stack independently
created a Namespace resource for that metadata.Name, with a unique URN
suffix `<deployment>-ns`. When *any* sibling stack was destroyed, Pulumi ran
the delete operation for its tracked Namespace resource — which calls k8s
to delete the namespace by metadata.Name. Kubernetes obliged and
cascade-deleted *every* resource in that namespace, including everything
owned by the other live sibling stacks.
Real outage: a destroy of a throwaway `caddy-test` sub-env stack wiped the
production wallet/gl-pay/payhey/rulex/smart-gate Deployments and Services.
Recovery required redeploying all five plus rolling Caddy.
Two-layer fix in this PR:
1. Proper isolation — each custom stack gets its own physical namespace.
`generateNamespaceName(baseNS, stackEnv, parentEnv)` in naming.go suffixes
the namespace with `-stackEnv` for custom stacks (parentEnv != stackEnv),
mirroring the per-stackEnv suffix every other resource type
(Deployment/Service/Secret/ConfigMap/HPA/VPA/ImagePullSecret) already gets
via generateResourceName. Standard stacks (parentEnv unset, or
parentEnv == stackEnv) keep their existing stackName-based namespace, so
the parent stack itself is untouched. After this change, sibling sub-envs
no longer share a namespace and `pulumi destroy` cleanly removes only
that stack's resources.
2. RetainOnDelete safety net — both `corev1.NewNamespace` call sites
(the client-stack one in simple_container.go and the helm-operator one
in helpers.go) now pass `sdk.RetainOnDelete(true)`. Pulumi keeps the
namespace resource in state but skips the k8s delete API call on
destroy. This is critical during the per-stack migration: when a custom
stack first runs `pulumi up` with this version, Pulumi sees the namespace
metadata.Name change (`pay-space-wallet` → `pay-space-wallet-gl-pay`),
schedules a Replace, creates the new namespace, and *would* delete the
shared parent namespace if not for RetainOnDelete. After migration,
RetainOnDelete continues to defend against accidental destroy of any
namespace that ends up holding more than one stack's resources (e.g.
shared helm-operator namespaces).
Migration semantics: any deploy that already uses parentEnv != stackEnv
will Replace its namespace-scoped resources on the next `pulumi up` —
Pulumi creates them in the new namespace and deletes the old ones. The
parent stack is unaffected because its resources sit in a different
Pulumi stack with different URNs. Caddy auto-discovers services across
all namespaces (kubectl get services --all-namespaces) and the Caddyfile
upstream URL encodes namespace via the existing `\${namespace}` placeholder
in simple_container.go, so routing follows the new namespace automatically.
The empty parent namespace lingers only if the *last* sibling under one
stackName is destroyed; it must be cleaned up manually. That's the right
default — silent destructive cascade across stacks is far worse than a
leaked empty namespace.
Tests:
- TestGenerateNamespaceName covers all parentEnv/stackEnv combinations
including the regression cases.
- TestGenerateNamespaceName_SiblingsAreUnique enumerates the
pay_space_wallet outage scenario (production parent + 5 sub-envs +
caddy-test) and asserts each resolves to a distinct namespace.
Signed-off-by: Dmitrii Creed <creeed22@gmail.com>
ec5a57e to
da6bdf3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Sub-env client stacks (
parentEnv: production,stackEnv: tenant-a/tenant-b/...) were deriving their k8s namespace name fromstackNamevia pkg/clouds/pulumi/kubernetes/deployment.go:67:So every sibling under the same
stackNameended up pointing at the same physical namespace. Each Pulumi stack independently tracked its ownNamespaceresource for thatmetadata.Namewith a unique URN suffix, but they all referenced the same physical namespace. Every other resource type (Deployment, Service, Secret, ConfigMap, HPA, VPA, ImagePullSecret) was already correctly env-suffixed via generateResourceName — the namespace was the one piece left non-isolated.When any sibling stack was destroyed, Pulumi ran the delete for its tracked Namespace resource — which calls k8s to delete the namespace by
metadata.Name. K8s obliged and cascade-deleted every resource in that namespace, including everything owned by the other live sibling stacks.Real outage
Destroying a throwaway sub-env stack on a production cluster wiped every live sibling's Deployments, Services, and namespace-scoped Secrets in one shot. Recovery required redeploying all of them plus rolling Caddy.
Fix — three layers
1. Per-stackEnv namespace for custom stacks
New helper
GenerateNamespaceName(baseNS, stackEnv, parentEnv)exported fromnaming.go. For custom stacks (parentEnv != stackEnv) the namespace is suffixed with-{stackEnv}, mirroring the suffix every other resource already gets. Standard stacks (parentEnvunset, orparentEnv == stackEnv) keep the existingstackName-based namespace, so the parent stack is untouched.The result is RFC 1123-sanitized inside the helper (lowercase,
_→-, ≤63 chars with FNV-1a truncation), so direct callers can pass it straight intometadata.namespacewithout their own sanitization step. Sanitization is idempotent — pre-sanitized inputs see no change.Behavior matrix:
production<stackName>tenant-a<stackName>-tenant-atenant-b<stackName>-tenant-bpreview-test<stackName>-preview-teststaging<stackName>2. Dependency-resource processors aligned with the new namespace
The init-job and CloudSQL-proxy code paths previously hardcoded
params.input.StackParams.StackNameas the namespace — fine when the pod also lived in<stackName>, but stranded after the change above. Updated three call sites to derive the namespace viakubernetes.GenerateNamespaceName(stackName, stackEnv, parentEnv):Without these updates, custom-stack pods would fail to mount the CloudSQL proxy credential Secret (which would have been created in the now-different parent namespace).
3.
RetainOnDelete(true)on namespace resourcesBoth
corev1.NewNamespacecall sites passsdk.RetainOnDelete(true):Pulumi keeps the resource in state but skips the k8s delete API call on destroy. This is critical during migration: when an existing custom stack first runs
pulumi upwith this version, Pulumi sees the namespacemetadata.Namechange, schedules a Replace, creates the new namespace, and would delete the old shared namespace (wiping the parent stack and any siblings still on the old NS) — except thatRetainOnDeleteskips the delete. The parent's resources keep running through the migration.RetainOnDeletealso continues to defend against accidental destroy of any namespace that legitimately ends up holding multiple stacks' resources (helm operators, anyone who explicitly sets the sameNamespaceon multiple stacks). Same pattern is already used elsewhere in the codebase for shared resources (see cloudflare/registrar.go:143,320).Migration semantics
Any deploy that uses
parentEnv != stackEnvwill Replace its namespace-scoped resources on the nextpulumi up— Pulumi creates them in the new namespace and deletes the old ones. The parent stack is unaffected because its resources sit in a different Pulumi stack with different URNs.Caddy routing follows automatically:
kubectl get services --all-namespaces(caddy.go:189) to discover services with thesimple-container.com/caddyfile-entryannotation${namespace}in the upstream URLServer-Side Apply is enabled on the k8s provider (provider.go:23), so subsequent
pulumi upruns against any retained namespace patch the existing object via SSA rather than throwingAlreadyExists. Refresh, import, and replace flows are unaffected —RetainOnDeleteonly changes the destroy path.The empty parent namespace lingers only if the last stack referencing it is destroyed; manual cleanup. Right trade vs. silent cascade.
If a custom stack uses
persistentVolumes(simple_container.go:397 creates PVCs), the namespace move triggers a Pulumi Replace on each PVC. Because PVCs are namespace-scoped and not movable, Pulumi creates the new PVC and deletes the old one. If the StorageClass'sreclaimPolicyisDelete(default for dynamic volumes on GCP/AWS), the underlying PV and its data are destroyed.Mitigations for any consumer with stateful custom stacks before merging this:
persistentVolumeReclaimPolicy: Retainfirst (kubectl patch pv ... --patch ...)kubectl edit pv <name>to clearclaimRefand reattach to the new PVC after the migrationStacks that don't define
persistentVolumes(the typical case where state lives in managed services like Cloud SQL / RDS / Redis) are unaffected.Tests
TestGenerateNamespaceName— table-driven coverage of standard / self-reference / custom-stack derivation including underscore normalization and case foldingTestGenerateNamespaceName_SiblingsAreUnique— direct regression for the shared-namespace outage scenario (parent + 4 tenant sub-envs + preview-test all resolve to distinct namespaces)go test ./pkg/clouds/pulumi/kubernetes/...and./pkg/clouds/pulumi/gcp/...passgo build ./...cleanpulumi previewagainst a real custom-stack consumer to validate the migration planBreaking change scope
Any SC consumer with a deploy where
parentEnv != stackEnvwill see their custom stacks recreate-in-new-ns on nextpulumi up. Brief gap during the namespace cutover;RetainOnDeletekeeps the old namespace alive so the parent stack continues to serve regardless. Stateful custom stacks should follow the PVC caveat above.Reviews
GenerateNamespaceName.