feat(stacks): karpenter node isolation per organization/stack - #494
Conversation
Provision dedicated Karpenter node pools per organization (shared
"<org>" pool) or per stack ("<org>-<stack>" pool), driven by
karpenter.* settings and the formance.com/organization + /customer
Stack labels. EC2NodeClass/NodePool are cloned from named cluster-scoped
reference objects (unstructured + injected tags/labels/taints) so EC2
instances carry customer/organization/stack tags for cost attribution.
Stack workloads are pinned onto the dedicated nodes via a nodeSelector +
tolerations mutator in the applications deployment path.
Integrated into the existing stacks reconciler (no new CRD, no second
controller). Cleanup is label-driven (app.kubernetes.io/managed-by=
formance-operator) because the module cleanup path namespace-filters and
cannot see cluster-scoped objects; org pools use additive non-controller
owner refs so they survive until the last owning stack is gone.
Constraint: cluster-scoped dependent may only reference a cluster-scoped
owner (the Stack CR is cluster-scoped, so owner refs are valid)
Constraint: Karpenter CRDs are optional; feature no-ops when absent and a
CustomResourceDefinition watch re-enables it without an operator restart
Rejected: WithOwn-based cleanup | removeAllModulesOwnedObjects
namespace-filters and never sees cluster-scoped NodePool/EC2NodeClass
Rejected: typed Karpenter Go deps | avoids v1beta1->v1 churn; feature
only materializes a configured spec, so unstructured suffices
Directive: taints/tolerations/nodeSelector key on pool identity
(organization[/stack]) only, never customer -- a shared org pool may see
differing customer values which would break scheduling; customer stays a
tag/node-label for cost attribution only
Confidence: medium
Scope-risk: moderate
Not-tested: envtest integration against real Karpenter CRDs; migration
Jobs are not pinned (taints only repel, so they run on shared nodes)
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
gfyrag
left a comment
There was a problem hiding this comment.
The underlying need needs to be stated more precisely before this becomes an operational contract. If the goal is cost attribution for long-lived Deployments, this is a useful direction. If the goal is node isolation for a customer/stack, the current implementation is incomplete: migration/reindex Jobs explicitly remain on shared nodes, and “dedicated” is therefore not an isolation boundary. Please choose and document one of those contracts rather than using both interchangeably.
I found four blocking convergence/ownership issues:
- An organization pool is shared, but its full spec and customer tag are recomputed from each stack’s labels and stack-scoped Settings. Two stacks in the same organization can continuously overwrite the same objects with different customer/reference configurations (inline).
CreateOrUpdateadopts and rewrites any existing cluster-scoped Karpenter object whose name happens to equal the organization/pool name, even if it was not created by this operator (inline).- The CRD and Stack-label watches enqueue only the Stack controller. They do not reconcile the module Deployments where
withNodeIsolationactually changes selectors/tolerations, so installing/removing Karpenter or relabeling a Stack leaves existing workloads on stale placement until an unrelated module event occurs (inline). - Cloning a reference NodePool replaces its complete taint list instead of injecting only the Formance-managed taints (inline).
There is also a current CI blocker: just pre-commit succeeds but the dirty-tree check reports three generated files missing from the branch:
docs/09-Configuration reference/02-Custom Resource Definitions.mddocs/09-Configuration reference/settings.catalog.jsonhelm/crds/templates/crds/apiextensions.k8s.io_v1_customresourcedefinition_settings.formance.com.yaml
The focused Go tests pass locally and all Kubernetes E2E jobs in the recorded workflow passed; the workflow failed only on that generated drift.
Additional need question: if per-organization pools are truly shared, where is their single authoritative configuration supposed to live? Per-stack Settings cannot safely own a shared object unless equality is enforced across all participating stacks.
| obj.SetName(cfg.PoolName) | ||
|
|
||
| _, err := controllerutil.CreateOrUpdate(ctx, ctx.GetClient(), obj, func() error { | ||
| obj.Object["spec"] = runtime.DeepCopyJSON(spec) |
There was a problem hiding this comment.
Blocking — a shared organization pool cannot be reconciled from stack-local desired state. cfg.PoolName is only the organization in organization mode, but spec, the reference names, and formance.com/customer all come from the currently reconciling stack. The PR explicitly allows stacks in one organization to have different customer values; the second reconcile therefore overwrites the first stack’s EC2 tag/node label, and differing per-stack references make the entire spec last-writer-wins. This can flap indefinitely as either Stack reconciles. Please define one organization-scoped source of truth, enforce that every participating Stack resolves an identical configuration, or include all differing identity/config dimensions in the pool identity.
| obj.SetGroupVersionKind(gvk) | ||
| obj.SetName(cfg.PoolName) | ||
|
|
||
| _, err := controllerutil.CreateOrUpdate(ctx, ctx.GetClient(), obj, func() error { |
There was a problem hiding this comment.
Blocking — do not adopt an arbitrary cluster-scoped object by name. In organization mode the generated name is the raw organization label. If an EC2NodeClass/NodePool with that name already exists but lacks the Formance managed label/owners, CreateOrUpdate still replaces its complete spec, stamps managed-by=formance-operator, and adds an owner reference. A naming collision can therefore destroy a platform-owned Karpenter configuration. Use a collision-resistant operator prefix and/or reject existing objects unless they already carry an explicit ownership marker belonging to this feature.
| // node isolation keys off the formance.com/organization and /customer labels | ||
| // (metadata, no generation bump). Watch label changes so relabeling a stack | ||
| // re-reconciles its pools and workload placement. | ||
| b.Watches(&v1beta1.Stack{}, handler.EnqueueRequestsFromMapFunc( |
There was a problem hiding this comment.
Blocking — this watch does not reconcile workload placement. It enqueues only the Stack reconciler, which creates/cleans Karpenter objects, while selectors and tolerations are applied inside each module’s Deployment reconciliation. A Stack organization relabel therefore creates the new pool and removes the old one but leaves existing Deployments selecting the old labels. The CRD watch above has the same gap on post-start installation/removal. Please fan these events out to the affected modules/Deployments (and test the end-to-end transition), or move placement reconciliation under a controller that actually owns those workloads.
| } | ||
|
|
||
| // Taints repel non-dedicated workloads. | ||
| if err := unstructured.SetNestedSlice(refSpec, taintsToUnstructured(cfg.Taints), "template", "spec", "taints"); err != nil { |
There was a problem hiding this comment.
Blocking — preserve the reference NodePool’s unrelated taints. The feature is described as cloning a reference and injecting isolation constraints, but SetNestedSlice replaces spec.template.spec.taints wholesale. Baseline taints used for architecture, capacity class, or other scheduling policy disappear from every clone. Read the existing taints and upsert/remove only the keys in ManagedSchedulingKeys, matching the idempotent behavior used for Deployment tolerations.
Context
Formance runs many customer stacks in a shared Kubernetes cluster. Today there's no way to attribute compute cost to a specific customer/organization/stack — all pods land on shared nodes. This adds the operator's ability to provision dedicated Karpenter node pools per organization (shared
<org>pool) or per stack (<org>-<stack>pool), so EC2 instances carryformance.com/{customer,organization,stack}tags for cost allocation and a stack's workloads run only on its dedicated nodes.What it does
Driven by
karpenter.*Settings and theformance.com/organization+formance.com/customerStack labels:karpenter.isolation: organization | stack.karpenter.ec2-node-class.reference) and injectingspec.tags.karpenter.node-pool.reference), pointing it at the cloned EC2NodeClass, and injecting node labels + taints.Settings
karpenter.enabled*)karpenter.isolationorganization(default) orstackkarpenter.ec2-node-class.referencekarpenter.node-pool.referencekarpenter.api.{ec2-node-class,node-pool}.group-versionkarpenter.k8s.aws/v1,karpenter.sh/v1)Design notes
stacksreconciler (a secondFor(&Stack{})would collide on the auto-derived controller name).v1beta1→v1churn; the feature only materializes a configured spec.app.kubernetes.io/managed-by=formance-operator) —removeAllModulesOwnedObjectsnamespace-filters and can't see cluster-scoped objects, soWithOwncleanup would silently orphan pools.CustomResourceDefinitionwatch re-enables the feature without an operator restart.Review
Two independent Codex review passes were incorporated (plan-level and implementation-level): label-driven cleanup for cluster-scoped objects,
watchRBAC verb for the CRD watch, mutator idempotency + availability gating, optimistic-concurrency cleanup, and identity-only scheduling keys.Testing
go build ./...,go vet,gofmtcleanresolve_test.go(modes/defaults/pending),karpenter_test.go(clone injection, owner refs, GC on mode change / disable of cluster-scoped objects, org pool surviving a second owner), mutator idempotency test inapplicationsOut of scope (follow-ups)
🤖 Generated with Claude Code