Skip to content

feat: Support sync.toHost.namespaces via pro hook overrides - #2

Open
jleeh wants to merge 6 commits into
feat/node-name-rewriting-v0.37from
feat/synced-namespaces
Open

jleeh wants to merge 6 commits into
feat/node-name-rewriting-v0.37from
feat/synced-namespaces

Conversation

@jleeh

@jleeh jleeh commented Sep 15, 2026 •

Copy link
Copy Markdown

What

Upstream gates sync.toHost.namespaces behind pro.GetNamespaceMapper / pro.GetWithSyncedNamespacesTranslator,
which return a license error in the OSS binary. vCluster Pro is not a plugin either: it is a separate binary that
overrides those hook variables. This does the same, so core stays almost untouched.

  • pkg/linkpool/namespaces registers the overrides in init():
    • translator: mapped virtual namespaces sync to their own host namespace with raw object names; unmapped ones
      fall back to single-namespace rewriting in the control-plane namespace (documented Pro default).
    • Namespace mapper: resolves through the configured mappings (exact, single-wildcard patterns, ${name}); returns
      the control-plane namespace for unmapped virtual namespaces so lookups such as the kube-dns service keep working;
      treats matching host namespaces as managed so they are imported, unless another vCluster's marker label owns
      them. Deterministic, so not wrapped in the store recorder. mappingsOnly is not enforced and logs a warning.
  • cmd/vcluster-linkpool/main.go is the upstream main with that package imported. The Dockerfile takes MAIN as a
    build arg (default unchanged); build with --build-arg MAIN=cmd/vcluster-linkpool/main.go.

Core changes, all small:

  • namespace syncer implements ObjectExcluder: virtual namespaces that resolve to the vCluster's own host namespace
    are never synced as namespaces, and the host namespace itself is never touched.
  • namespace syncer marks a host namespace it did not create as imported when adopting it, so deleting the virtual
    namespace never deletes a pre-existing host namespace.
  • the Service create filter waits up to 10s for a host namespace the namespace syncer is still creating, instead of
    failing a kubectl apply that creates a namespace and a service in one go (seen on vcluster-test).

Not included: the Pro Importer (bulk import of pre-existing workloads inside a mapped host namespace) is still the
no-op stub. Only the Namespace object itself is imported.

Testing

go build ./... and go test ./pkg/... pass. Unit tests cover the mapping helpers, translator and mapper; the
mapper test goes through resources.CreateNamespacesMapper so the hook override is exercised.
Live test on vcluster-test via linkpoolio/infra-argocd-deployments (team-* mapping); the node-proxy plugin must be
built against this branch too (linkpoolio/vcluster-node-proxy#2) because the SDK re-runs config init in the plugin.

🤖 Generated with Claude Code

@jleeh
jleeh force-pushed the feat/synced-namespaces branch from 73f547f to 7cdd1be Compare September 15, 2026 09:09
@jleeh jleeh changed the title feat: Support sync.toHost.namespaces without a Pro license feat: Support sync.toHost.namespaces via pro hook overrides Sep 15, 2026
@jleeh
jleeh force-pushed the feat/synced-namespaces branch from 7cdd1be to 8f91c85 Compare September 15, 2026 09:10
Upstream gates sync.toHost.namespaces behind pro.GetNamespaceMapper and
pro.GetWithSyncedNamespacesTranslator, which return a license error in the
OSS binary. Following the same model as vCluster Pro, this links replacement
implementations in from a separate package instead of editing core:

- pkg/linkpool/namespaces registers the overrides in init(): a translator
  that syncs mapped virtual namespaces to their own host namespace with raw
  object names and falls back to single-namespace rewriting in the control
  plane namespace for unmapped ones, and a Namespace mapper that resolves
  through the configured mappings (exact, single-wildcard patterns, ${name}),
  returns the control plane namespace for unmapped virtual namespaces so
  lookups such as the kube-dns service keep working, and treats matching
  host namespaces as managed so they are imported unless another vCluster's
  marker label owns them. mappingsOnly is not enforced and logs a warning.
- cmd/vcluster-linkpool is the upstream main with that package imported; the
  Dockerfile takes MAIN as a build arg (default unchanged) to build it.

Core changes, all small:
- namespace syncer implements ObjectExcluder so virtual namespaces that
  resolve to the vCluster's own host namespace are never synced as
  namespaces, and the host namespace itself is never touched
- namespace syncer marks a host namespace it did not create as imported when
  adopting it, so deleting the virtual namespace never deletes it
- the Service create filter waits up to 10s for a host namespace the
  namespace syncer is still creating, instead of failing a kubectl apply
  that creates a namespace and a service together

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jleeh
jleeh force-pushed the feat/synced-namespaces branch from 8f91c85 to 8303b72 Compare September 15, 2026 09:45
@jleeh

jleeh commented Sep 15, 2026

Copy link
Copy Markdown
Author

Live test on vcluster-test (image feat-synced-namespaces-8303b725b, plugin 98f77f2, mapping "team-*": "${name}-team-*"):

  • Virtual team-a -> host vcluster-test-team-a with marker labels; configmap, service, service accounts, pod and SA token secret all land there under raw names. Pod runs, exec/logs through the vcluster work, configmap env and volume mount resolve. Nothing leaks into the control-plane namespace and the control-plane namespace itself is not modified.
  • kubectl apply of namespace + service in one file now succeeds (Service filter wait).
  • Deleting the virtual namespace removes the host namespace within ~15s.
  • Pre-created host vcluster-test-team-imported is imported as virtual team-imported within ~5s and survives deletion of the virtual namespace. Pre-existing objects inside it are not imported (Pro Importer stub, as documented).
  • Existing smoke / kube-system workloads untouched. Syncer memory ~630-850Mi per replica (was ~450Mi) with the cluster-wide cache.

Known gap outside this repo: pods in mapped host namespaces sit outside the tenant CiliumNetworkPolicy (keyed on the control-plane namespace), so in-pod DNS to the vcluster coredns times out and they get default-pool IPs. Needs a chart follow-up (policy keyed on vcluster.loft.sh/managed-by, IPAM annotation on synced namespaces).

jleeh and others added 2 commits September 15, 2026 12:12
With mappingsOnly, virtual namespaces outside the mappings get no host
namespace and their objects get no host name, so nothing of theirs is synced.
The translator hook does not receive the flag, so the mapper, which sees the
full config and is built before any controller starts, passes it to the
translator. Each rejected namespace is logged once.

kube-system must be mapped when mappingsOnly is set, otherwise the kube-dns
lookup fails for every pod.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…Only

With mappingsOnly no tenant object belongs in the control plane namespace,
so the translator stops reporting it as targeted. The mapping store verifies
every mapping against IsTargetedNamespace when it loads at leader start, so
mappings recorded before namespace sync was enabled (kube-root-ca.crt,
coredns, services pinned to the control plane namespace) are dropped and the
objects re-translate into their mapped host namespace. This is what makes an
in-place switch to namespace sync possible without resetting etcd; the old
host copies are left untouched for manual cleanup.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jleeh

jleeh commented Sep 15, 2026

Copy link
Copy Markdown
Author

Two more commits from the in-place switch of vcluster-test to workloadIsolation (static mappings, mappingsOnly: true):

  • 73d992826 enforces mappingsOnly: unmapped virtual namespaces get no host namespace and their objects no host name; logged once per namespace. kube-system must be mapped.
  • 3e25392e0 stops treating the control-plane namespace as a sync target under mappingsOnly. The mapping store verifies mappings against IsTargetedNamespace when it loads, so mappings recorded before namespace sync was enabled (kube-root-ca.crt, coredns, services pinned to the control-plane namespace) are dropped at leader start and the objects re-translate into their mapped namespace. Without this the recreated coredns pod referenced configmaps that only existed in the control-plane namespace. Old host copies are left for manual cleanup.

Images: feat-synced-namespaces-3e25392e0, plugin a7a8e09 (linkpoolio/vcluster-node-proxy#2). Rolling out via linkpoolio/infra-argocd-deployments#13576.

The mapping store is created and loaded inside initControllerContext and
verifies each stored mapping against translate.Default at that point. The
namespace mapper, which used to hand mappingsOnly to the translator, is only
built afterwards, so stale control plane mappings still passed verification.
pro.LicenseInit runs right after the translator is built and receives the
full config, so set the flag there; the mapper keeps setting it as well for
the plugin process.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jleeh

jleeh commented Sep 17, 2026

Copy link
Copy Markdown
Author

05badd1fd: 3e25392e0 did not take effect on vcluster-test because the mapping store is created and loaded (with verification) inside initControllerContext, before the namespace mapper that set mappingsOnly on the translator existed. The flag is now set from a pro.LicenseInit wrapper, which runs right after the translator is built and before the controller context; the mapper still sets it for the plugin process. Unit test added for the hook. Images: feat-synced-namespaces-05badd1fd, plugin c90751a.

The store verified only the top-level mapping when loading from the
backend; references recorded under it (e.g. a pod's configmaps) were added
to the name maps unverified. References into a host namespace that is no
longer a sync target kept pinning those objects to stale host names after
namespace sync was enabled. Filter references with the same verify function
on load and on backend updates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jleeh

jleeh commented Sep 17, 2026

Copy link
Copy Markdown
Author

81542a1e7 (core, pkg/mappings/store): the store verified only top-level mappings on load; references recorded under them (a pod's configmaps) were added to the name maps unverified, so kube-system/coredns and kube-root-ca.crt stayed pinned to the control-plane namespace even after 05badd1fd, until their owner mappings were garbage-collected. References are now filtered with the same verify function on load and on backend updates. On vcluster-test the remaining objects were unpinned by GC plus pod recreation; coredns now runs in vcluster-test-ns-kube-system with a tenant-pool IP.

Upstream gates sync.toHost.*.patches / sync.fromHost.*.patches behind
pro.ApplyPatchesHostObject / pro.ApplyPatchesVirtualObject. This adds
pkg/linkpool/patches, registered from init() like the namespaces package:

- path: JSONPath subset over the object's unstructured form: dotted and
  quoted fields, [N], [*], [?(@.field=='x')] / != / existence filters. A
  missing path is skipped; a missing final field in an existing map counts
  as a match with an undefined value so expressions can add fields.
- expression / reverseExpression: JavaScript via goja with `value` and
  `context` {vObject, hostObject, path}; undefined or null removes the
  field. Host objects get the forward expression, virtual objects the
  reverse one, swapped when the syncer passes reverseExpressions (fromHost
  resources).
- reference: rewrites the referenced name (and namespace via namespacePath)
  through the registered mapper for the kind, falling back to the
  translator's short host name and recording the mapping; the reverse
  direction uses the mapper or the mapping store.
- labels: translates a plain label map or a metav1.LabelSelector the same
  way pod selectors are translated.

go.mod: add github.com/dop251/goja. Upstream also requires
github.com/loft-sh/e2e-framework, whose repository is gone, which made any
module graph change impossible; it is now replaced by hack/e2e-framework, a
copy of the already vendored packages (e2e tests only).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jleeh

jleeh commented Sep 21, 2026

Copy link
Copy Markdown
Author

b3c874bda adds translate patches (sync.toHost.*.patches / sync.fromHost.*.patches), the second Pro feature done through the same hook overrides, in pkg/linkpool/patches:

  • path: JSONPath subset over the object's unstructured form: dotted/quoted fields, [N], [*], [?(@.field=='x')] (also != and existence). Missing path → skipped; a missing final field in an existing map is a match with undefined so expressions can add fields.
  • expression / reverseExpression: JavaScript via goja, value plus context.{vObject,hostObject,path}; undefined/null removes the field. Host objects get the forward expression, virtual objects the reverse one, swapped when the syncer passes reverseExpressions (fromHost resources).
  • reference: name (and namespace via namespacePath) rewritten through the registered mapper for the kind, else the translator's short host name with the mapping recorded; reverse via mapper or mapping store.
  • labels: plain label maps and metav1.LabelSelector translated like pod selectors.

Dependencies: github.com/dop251/goja vendored. Upstream's go.mod still requires github.com/loft-sh/e2e-framework, whose repository no longer exists, which blocked any go mod vendor; it is now replaced by hack/e2e-framework, a copy of the packages upstream had already vendored (used by e2e tests only). Unit tests cover path resolution, expressions both directions, add/remove, references and labels.

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.

1 participant