operator: per-Site image registry override for site-node workloads - #581
operator: per-Site image registry override for site-node workloads#581Philip Lombardi (plombardi89) wants to merge 5 commits into
Conversation
Some sites sit on networks that cannot reach the default container registry the operator pulls its component images from. Add spec.imageRegistry to the Site CR: a full image-repository prefix (identical semantics to the operator-wide UNBOUNDED_IMAGE_REGISTRY) that the four operator-managed workloads running on a site's nodes (unbounded-net-node, gantry, metalman, unbounded-storage-supervisor) resolve their images from. Empty uses the operator-wide registry. metalman and storage already run per-Site, so they just resolve images through the new component.ConfigForSite helper. net-node and gantry were single cluster-wide DaemonSets; split each into a "base" DaemonSet scoped to un-Sited nodes (via UnsitedNodeAffinity, DoesNotExist on both site labels) plus a per-Site DaemonSet (<component>-<site>) node-affined to the site, owner-referenced to the Site for GC, and mounting a per-Site config ConfigMap seeded from the shared default so existing tuning carries over. Node affinity partitions scheduling so every node runs exactly one copy; the net controller and machina (control-plane only) are unaffected. Pulls remain anonymous (per-Site pull secrets are a follow-up), the gantry busybox init image is not repointed, and the site registry must already host the images at the operator's version.
There was a problem hiding this comment.
Pull request overview
Adds a per-Site container image registry override (spec.imageRegistry) so node-scoped operator-managed workloads can be pulled from a site-local/mirrored registry when the default registry is unreachable.
Changes:
- Introduces
SiteSpec.ImageRegistryandcomponent.ConfigForSiteto resolve component images per Site with fallback to the operator-wide registry. - Splits
unbounded-net-nodeandgantryinto a base DaemonSet for un-Sited nodes plus per-Site DaemonSets and per-Site seeded ConfigMaps. - Updates docs and the Site CRD to document/define
spec.imageRegistry.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/operator/components/storage/storage.go | Resolves storage supervisor images via per-Site config. |
| internal/operator/components/storage/storage_test.go | Adds coverage for Site-level registry override behavior in storage mutation. |
| internal/operator/components/net/net.go | Splits net node DaemonSet into base + per-Site, adds per-Site config seeding/ownership, and applies un-Sited affinity to base. |
| internal/operator/components/net/net_test.go | Adds tests for base un-Sited affinity, per-Site DaemonSet/config behavior, and reconcile fan-out. |
| internal/operator/components/metalman/metalman.go | Resolves metalman image via per-Site config. |
| internal/operator/components/metalman/metalman_test.go | Adds coverage for Site-level registry override behavior in metalman deployment. |
| internal/operator/components/gantry/gantry.go | Splits gantry DaemonSet into base + per-Site, adds per-Site config seeding/cleanup, and applies un-Sited affinity to base. |
| internal/operator/components/gantry/gantry_test.go | Adds tests for base un-Sited affinity, per-Site DaemonSet/config behavior, and opted-out cleanup. |
| internal/operator/component/env.go | Adds ConfigForSite and UnsitedNodeAffinity helpers. |
| internal/operator/component/env_test.go | Adds tests for ConfigForSite and UnsitedNodeAffinity. |
| docs/net/custom-resources.md | Documents spec.imageRegistry semantics, behavior, and caveats. |
| docs/content/reference/networking/custom-resources.md | Adds spec.imageRegistry reference entry. |
| deploy/machina/crd/unbounded-cloud.io_sites.yaml | Regenerates/updates the Site CRD schema to include imageRegistry. |
| api/machina/v1alpha3/site_types.go | Adds ImageRegistry field to the Site API type with detailed docs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- seedSiteConfig (net + gantry): deep-copy the shared ConfigMap payload into the per-Site ConfigMap instead of aliasing its Data/BinaryData maps, so a later edit of either object cannot mutate the other. - repointConfigEnv (net): walk both initContainers and containers so the per-Site config repoint matches its "every container" contract and is robust if an init container ever gains a configMapKeyRef. - Add a repointConfigEnv test covering init and main containers.
Add a discussion doc for the Gantry developer covering two things the operator's per-Site image registry work depends on: - The cross-Site mesh problem: with per-Site local registries, Gantry's single cluster-wide membership pool and /gantry DHT can HRW-select an origin puller in another Site that cannot reach this Site's registry. Describes the three levers (per-Site membership selector, per-Site DHT protocol prefix, per-Site upstream_registries) and what needs a Gantry code change. - How users should name workload images so per-Site redirection works (canonical name + node-level containerd mirror + per-Site Gantry config), instead of baking a Site host into the image reference. Includes an SVG diagram of the two-Site failure and code pointers.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (5)
internal/operator/components/net/net.go:533
- The per-Site net ConfigMap created in code has no labels/annotations (only name/namespace/ownerRefs). If
ManagedConfigPredicate(...)relies on management labels/annotations (common for operator self-heal/watch filtering), changes to these per-Site ConfigMaps may not trigger reconciles. Consider copying the shared ConfigMap’s management metadata (e.g., labels/annotations) onto the per-Site ConfigMap (or explicitly setting whatever managed markersManagedConfigPredicateexpects) when seeding/creating.
cm := &corev1.ConfigMap{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"},
ObjectMeta: metav1.ObjectMeta{Name: SiteConfigName(site.Name), Namespace: env.Namespace, OwnerReferences: []metav1.OwnerReference{component.SiteOwnerReference(site)}},
Data: payload.Data,
BinaryData: payload.BinaryData,
}
internal/operator/components/gantry/gantry.go:563
- Same issue as net: per-Site gantry ConfigMaps are created without any management labels/annotations. Since
SetupWatchesusesManagedConfigPredicatefor per-Site configs, ensure the generated ConfigMaps carry the metadata required for them to be treated as managed/watched (e.g., copy labels/annotations from the sharedgantry-config, or set explicit managed markers).
cm := &corev1.ConfigMap{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"},
ObjectMeta: metav1.ObjectMeta{Name: SiteConfigName(site.Name), Namespace: env.Namespace, OwnerReferences: []metav1.OwnerReference{component.SiteOwnerReference(site)}},
Data: payload.Data,
BinaryData: payload.BinaryData,
}
internal/operator/components/net/net.go:252
- This reads and decodes the embedded YAML on every reconcile. Since the base DaemonSet is static, consider caching the decoded base object (e.g., package-level var +
sync.Once) and returning aDeepCopy()from the cached value. This reduces allocations/CPU in frequently-triggered reconciles (especially now that reconciles also fan out per Site).
func baseNodeDaemonSet() (*unstructured.Unstructured, error) {
data, err := fs.ReadFile(netmanifests.Manifests, nodeDaemonSetManifest)
if err != nil {
return nil, fmt.Errorf("read base net node manifest: %w", err)
}
decoder := utilyaml.NewYAMLOrJSONDecoder(bytes.NewReader(data), 4096)
for {
obj := &unstructured.Unstructured{}
if err := decoder.Decode(obj); err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, fmt.Errorf("decode base net node manifest: %w", err)
}
if obj.GetKind() == "DaemonSet" && obj.GetName() == nodeName {
return obj, nil
}
}
return nil, fmt.Errorf("base net node DaemonSet %q not found in manifest", nodeName)
}
internal/operator/components/storage/storage_test.go:149
- This test ignores
NestedSliceerrors/ok and then type-asserts/indexes directly, which will panic (and obscure the real failure) if mutation doesn’t produce the expected structure. Prefer checkingerr,ok, andlen(containers)and failing the test with a clear message before indexing/type asserting.
containers, _, _ := unstructured.NestedSlice(obj.Object, "spec", "template", "spec", "containers")
if got := containers[0].(map[string]any)["image"]; got != "registry.corp.internal/unbounded/unbounded-storage-supervisor:v1.2.3" {
t.Fatalf("image = %q, want site-registry storage supervisor", got)
}
internal/operator/components/net/net_test.go:471
- Similar to the storage test: ignoring
NestedSlice’sok/errand indexing/type-asserting can cause panics that hide why the test failed. It’s more robust to asserterr == nil,ok == true,len(containers) > 0, and that the element is amap[string]anybefore readingimage.
containers, _, _ := unstructured.NestedSlice(perSite.Object, "spec", "template", "spec", "containers")
if got := containers[0].(map[string]any)["image"]; got != "registry.corp.internal/unbounded/unbounded-net-node:v1.2.3" {
t.Fatalf("per-site node image = %q, want site registry", got)
}
Address the code-review findings on the base + per-Site DaemonSet split: - SiteNodeAffinity is now canonical-authoritative: it matches the canonical unbounded-cloud.io/site label, falling back to the deprecated label only when the canonical label is absent. A Node that briefly carries conflicting values (canonical=A, deprecated=B) no longer matches two Sites and double-schedules two privileged agents. - Per-Site net config tracks the shared config until a Site edits it. Each per-Site unbounded-net-config-<site> records the shared payload hash it was seeded from; while it still matches, a later shared-config change is re-seeded into it, and once a Site edits it the copy is preserved. This closes the window where a per-Site config seeded from an embedded default before the intended shared config lands would stay stale forever. - Fail-safe cutover ordering: net and gantry apply their per-Site DaemonSets before narrowing the base to un-Sited nodes, so a per-Site apply failure leaves the blanket base covering the fleet instead of stranding Sited nodes. net-node is hostNetwork/single-owner, so the handoff is break-before-make; it does not tear down its dataplane on shutdown, so established traffic survives the swap. - Gantry no longer deletes the per-Site config on opt-out. The config is Site-owned (upstream_registries, credentials) and is preserved across a temporary gantry.enabled=false, GC'd only when the Site is deleted. - The legacy reaper's net gate now requires every Site's per-Site unbounded-net-node-<site> DaemonSet to be present and current before the legacy blanket net-node is reaped, mirroring the storage gate. Gantry per-Site mesh scoping (cross-Site membership/DHT) is intentionally left to the gantry follow-up tracked in hack/discussion/gantry-per-site.md.
The net reaper gate now requires every Site's per-Site unbounded-net-node-<site> DaemonSet to be present and carrying the migrated config hash before the legacy blanket net-node is reaped. Stage those per-Site node DaemonSets (cluster/edge/kind-repair) alongside the base and stamp them with the migrated hash in updateTargetConfigHashes, mirroring the existing per-Site storage staging. Without this the migration reaper would block forever on the new gate. Verified end-to-end against kind (go test -tags=e2e ./e2e/operator/...).
|
Abandoned after team discussion about alternatives. |
Problem
Some Sites sit on networks that cannot reach the operator's default container
registry (e.g.
ghcr.io/azure). Today every Site pulls the operator-managedcomponent images from that one registry, so a network-isolated Site cannot start
the workloads that run on its nodes. We need a way, per Site, to pull those images
from a registry the Site's network can reach.
Solution
Add
spec.imageRegistryto theSiteCR: a full image-repository prefix (hostplus any org/namespace path), identical in meaning to the operator-wide
UNBOUNDED_IMAGE_REGISTRY. When set, the operator-managed workloads that run onthat Site's nodes resolve their images from that prefix instead of the operator
default. Empty = operator default (byte-for-byte unchanged from today).
Architecture: base + per-Site DaemonSet split
The override applies to the four operator-managed workloads that land on a Site's
nodes:
unbounded-net-node,gantry,metalman, and theunbounded-storage-supervisor. Control-plane components (net controller, machina)are unaffected.
metalmanandunbounded-storage-supervisoralready run per-Site; they nowresolve their image through
component.ConfigForSite.unbounded-net-nodeandgantrywere single cluster-wide DaemonSets. Each issplit into a base DaemonSet that runs only on un-Sited nodes (control
plane, etc.) using the operator-wide registry, plus a per-Site DaemonSet
(
<component>-<site>) node-affined to the Site, owner-referenced to the Site,using the Site's registry.
Node affinity partitions scheduling so every node runs exactly one copy. Nodes are
matched to a Site by the existing net-controller labeling
(
unbounded-cloud.io/site), and the existingnodeCidrsoverlap guards keep anode in at most one Site, so registry selection is unambiguous.
Safety / correctness hardening
This split of host-network, single-owner dataplane agents surfaced several
correctness concerns (raised in review); the bulk of the PR addresses them:
SiteNodeAffinitynow matches thecanonical
unbounded-cloud.io/sitelabel, falling back to the deprecated labelonly when the canonical one is absent. A node briefly carrying conflicting
values (
canonical=A,deprecated=B) can no longer match two Sites anddouble-schedule two privileged agents.
unbounded-net-config-<site>records the shared-config hash it was seeded from;while unchanged it re-seeds when the shared config changes, and once a Site edits
it the copy is preserved. This closes the window where a per-Site config seeded
from an embedded default before the intended (or migrated) shared config lands
would stay stale forever.
before narrowing the base to un-Sited nodes, so a per-Site apply failure
leaves the blanket base covering the fleet rather than stranding Sited nodes.
net-node is host-network / single-owner, so the handoff is break-before-make;
because net-node does not tear down its dataplane on shutdown, established
traffic survives the swap and only new-pod networking briefly stalls (equivalent
to a normal net-node rollout, which already runs at
maxUnavailable: 100%).gantry.enabled=falsedeletes only the DaemonSet; the Site-owned config(
upstream_registries, credentials) is kept and GC'd only when the Site isdeleted.
unbounded-net-node-<site>DaemonSet to be present and current before it reapsthe legacy blanket net-node, mirroring the storage gate.
Caveats / known limitations
images at the operator's version; the operator only repoints references, it does
not mirror images.
mcr.microsoft.com/...) is notrepointed.
unbounded-net-nodeusesimagePullPolicy: Always, so the Site registry mustbe reachable on every node (re)start.
Follow-ups
convention -
hack/discussion/gantry-per-site.md.new-pod-networking gap bounded to a rolling subset of nodes.