Skip to content

operator: per-Site image registry override for site-node workloads - #581

Closed
Philip Lombardi (plombardi89) wants to merge 5 commits into
mainfrom
feature/per-site-image-registry
Closed

operator: per-Site image registry override for site-node workloads#581
Philip Lombardi (plombardi89) wants to merge 5 commits into
mainfrom
feature/per-site-image-registry

Conversation

@plombardi89

@plombardi89 Philip Lombardi (plombardi89) commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

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-managed
component 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.imageRegistry to the Site CR: a full image-repository prefix (host
plus any org/namespace path), identical in meaning to the operator-wide
UNBOUNDED_IMAGE_REGISTRY. When set, the operator-managed workloads that run on
that Site's nodes resolve their images from that prefix instead of the operator
default. Empty = operator default (byte-for-byte unchanged from today).

apiVersion: unbounded-cloud.io/v1alpha3
kind: Site
metadata:
  name: isolated-rack
spec:
  nodeCidrs: ["10.20.0.0/16"]
  imageRegistry: registry.corp.internal/unbounded

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 the
unbounded-storage-supervisor. Control-plane components (net controller, machina)
are unaffected.

  • metalman and unbounded-storage-supervisor already run per-Site; they now
    resolve their image through component.ConfigForSite.
  • unbounded-net-node and gantry were single cluster-wide DaemonSets. Each is
    split 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 existing nodeCidrs overlap guards keep a
node 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:

  • Canonical-authoritative site affinity. SiteNodeAffinity now matches the
    canonical unbounded-cloud.io/site label, falling back to the deprecated label
    only when the canonical one is absent. A node briefly carrying conflicting
    values (canonical=A, deprecated=B) can no longer match two Sites and
    double-schedule two privileged agents.
  • Per-Site net config that tracks shared unless customized. Each
    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.
  • Fail-safe fleet cutover. 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 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 per-Site config preserved on opt-out. A temporary
    gantry.enabled=false deletes only the DaemonSet; the Site-owned config
    (upstream_registries, credentials) is kept and GC'd only when the Site is
    deleted.
  • Legacy reaper gate. The migration reaper now requires every Site's per-Site
    unbounded-net-node-<site> DaemonSet to be present and current before it reaps
    the legacy blanket net-node, mirroring the storage gate.

Caveats / known limitations

  • The referenced registry must already host copies of the operator's component
    images at the operator's version; the operator only repoints references, it does
    not mirror images.
  • Pulls are anonymous (per-Site image pull secrets are a follow-up).
  • gantry's third-party busybox init image (mcr.microsoft.com/...) is not
    repointed.
  • unbounded-net-node uses imagePullPolicy: Always, so the Site registry must
    be reachable on every node (re)start.

Follow-ups

  • Per-Site gantry mesh scoping (membership + DHT) and workload image-naming
    convention - hack/discussion/gantry-per-site.md.
  • Per-Site image pull secrets.
  • Optional paced (batched) cutover if very large single-Site fleets need the
    new-pod-networking gap bounded to a rolling subset of nodes.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ImageRegistry and component.ConfigForSite to resolve component images per Site with fallback to the operator-wide registry.
  • Splits unbounded-net-node and gantry into 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.

Comment thread internal/operator/components/net/net.go Outdated
Comment thread internal/operator/components/net/net.go Outdated
Comment thread internal/operator/components/gantry/gantry.go
- 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.
Copilot AI review requested due to automatic review settings August 6, 2026 02:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

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.
Copilot AI review requested due to automatic review settings August 6, 2026 15:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 markers ManagedConfigPredicate expects) 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 SetupWatches uses ManagedConfigPredicate for 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 shared gantry-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 a DeepCopy() 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 NestedSlice errors/ok and then type-asserts/indexes directly, which will panic (and obscure the real failure) if mutation doesn’t produce the expected structure. Prefer checking err, ok, and len(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’s ok/err and indexing/type-asserting can cause panics that hide why the test failed. It’s more robust to assert err == nil, ok == true, len(containers) > 0, and that the element is a map[string]any before reading image.
	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.
Copilot AI review requested due to automatic review settings August 6, 2026 20:23
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/...).
@plombardi89 Philip Lombardi (plombardi89) changed the title operator: add per-Site image registry override operator: per-Site image registry override for site-node workloads Aug 6, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@plombardi89

Copy link
Copy Markdown
Collaborator Author

Abandoned after team discussion about alternatives.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants