Scope a synced selector field to its tenant
| Enterprise | ||||
|---|---|---|---|---|
| Available in these plans | Free | Dev | Prod | Scale |
| Custom Resource Syncing | ||||
Free, Dev, Prod, and Scale are vCluster Platform license plans. Open source does not need a license or a Platform connection. See Compare open source and free tiers.
Some custom resources decide what they act on through a selector held as an opaque string rather than a structured LabelSelector. vClustervClusterAn open-source software product that creates and manages tenant clusters within Kubernetes infrastructure. vCluster provides tenant isolation capabilities while reducing infrastructure costs. can't parse that string, so it syncs the field verbatim. In the default single-namespace sync mode, every tenant namespace maps to the vCluster namespace on the control plane clusterControl plane clusterThe Kubernetes cluster that hosts the virtualized control planes for tenant clusters. The control plane cluster is operated by the platform provider and is completely invisible to tenants. There are no shared control plane nodes, no in-cluster agent pods, and no lateral path between tenant environments. With shared nodes, this cluster also runs tenant workloads alongside the control plane pods — the same node pool is used for both.. A copied selector can therefore match workloads the policy was never written for.
This guide walks through scoping such a field with an expression patch, using a Calico NetworkPolicy as the example. The same approach applies to any resource an extension API serverAPI ServerThe core component of Kubernetes that exposes the Kubernetes API. It is the front-end for the Kubernetes control plane and handles all REST operations, validating and configuring data for API objects. serves with a selector-like string field. For the config reference, see Custom resources to the control plane cluster. For lifecycle and troubleshooting guidance, see Manage custom resources.
Why the selector needs a scope term​
vCluster labels every synced object with vcluster.loft.sh/namespace, which records the tenant namespace the object came from. See Sync to the control plane cluster. An extension API server doesn't consult that label on its own. The selector has to name it.
Calico NetworkPolicy has a top-level selector that chooses the protected endpoints. Its ingress and egress rules can also have positive source and destination selectors. Because Calico normally scopes those rule selectors to the policy's namespace, this guide patches all five locations. An empty-path patch scopes the top-level selector, whether the tenant wrote one or omitted it.
Prerequisites​
- Calico installed on the control planeControl PlaneThe container orchestration layer that exposes the API and interfaces to define, deploy, and manage the lifecycle of containers. In vCluster, each tenant cluster has its own control plane components. cluster, with its
projectcalico.org/v3API server enabled. - No CustomResourceDefinition for
projectcalico.orgon the control plane cluster. That absence is what makes this an aggregated API resource. vCluster generates a schemaless CRD in the tenant cluster instead of copying one. See Aggregated API resources. - Single-namespace sync mode, with
sync.toHost.namespaces.enabled: false, which is the default. With namespace syncing, each tenant namespace maps to a separate namespace on the control plane cluster, so this guide's selector and namespace assumptions don't apply. - Exactly one tenant cluster per control plane cluster namespace, which is the default and only supported layout.
Some deployments running v0.24 or earlier could force more than one tenant cluster into the same control plane cluster namespace. That option was deprecated on introduction and removed in v0.25. If you're still running such a deployment, a namespace-scoped selector doesn't separate those tenants, and no patch on spec.selector makes it safe.
Set up cluster variables​
Set the control plane and tenant cluster contexts and the namespace where vCluster runs.
export HOST_CTX="your-host-context"
export VCLUSTER_CTX="vcluster-ctx"
export HOST_NAMESPACE="vcluster-my-vcluster"
You can find your contexts by running kubectl config get-contexts
Configure the tenant cluster​
sync:
toHost:
customResources:
networkpolicies.projectcalico.org/v3:
enabled: true
patches:
# Scopes the top-level selector, including one the tenant omitted, and removes the
# composed fields on the way back so they don't reach the tenant. See below.
- path: ""
expression: |
(value => {
const selector = context.virtualObject.spec && context.virtualObject.spec.selector;
const scope = `vcluster.loft.sh/namespace == '${context.virtualObject.metadata.namespace}'`;
if (!value.spec) value.spec = {};
value.spec.selector = selector ? `(${selector}) && ${scope}` : scope;
return value;
})(value)
reverseExpression: |
(value => {
if (value.spec) {
delete value.spec.selector;
delete value.spec.ingress;
delete value.spec.egress;
}
return value;
})(value)
# Scopes the positive rule-level selectors the same way.
- path: spec.ingress[*].source.selector
expression: |
value ? `(${value}) && vcluster.loft.sh/namespace == '${context.virtualObject.metadata.namespace}'` : value
- path: spec.ingress[*].destination.selector
expression: |
value ? `(${value}) && vcluster.loft.sh/namespace == '${context.virtualObject.metadata.namespace}'` : value
- path: spec.egress[*].source.selector
expression: |
value ? `(${value}) && vcluster.loft.sh/namespace == '${context.virtualObject.metadata.namespace}'` : value
- path: spec.egress[*].destination.selector
expression: |
value ? `(${value}) && vcluster.loft.sh/namespace == '${context.virtualObject.metadata.namespace}'` : value
rbac:
role:
extraRules:
# Calico's API server checks tier access before it accepts a policy write.
- apiGroups: ["projectcalico.org"]
resources: ["tier.networkpolicies"]
resourceNames: ["default.*"]
verbs: ["create", "delete", "patch", "update", "get", "list", "watch"]
clusterRole:
extraRules:
- apiGroups: ["projectcalico.org"]
resources: ["tiers"]
resourceNames: ["default"]
verbs: ["get"]
This configuration:
- Restricts the policy's target endpoints and positive rule endpoint selectors to the tenant namespace where the policy was created. The empty-path patch sets
spec.selectorfor both a selector the tenant wrote and one it omitted. A path-specific patch can't supply a field that isn't there. The fourspec.ingress/spec.egressentries scope the rule-levelsourceanddestinationselectors the same way. The brackets aroundvaluekeep the tenant's own selector intact, and are required. See Limitations. - Removes the composed fields from control plane cluster changes before they reach the tenant. The empty-path patch's
reverseExpressiondeletesspec.selector,spec.ingress, andspec.egressfrom the change set, so a composed selector never lands back in the tenant object. The four rule-selector entries omitreverseExpressionthemselves, which is safe here only because the empty-path patch already removes their parent field first. - Grants the permissions Calico's API server needs for the
defaulttier. It authorizes a policy write against relatedtierresources that you never configure for sync. To use another tier, replace bothdefaultvalues with that tier's name and setspec.tieron the policy. See Authorization on the control plane cluster.
Sync a policy and check its scope​
Tenant Cluster Create two namespaces. Each namespace has a web server and a client with identical labels:
Create the workloadskubectl --context="${VCLUSTER_CTX}" create namespace demokubectl --context="${VCLUSTER_CTX}" create namespace demo2kubectl --context="${VCLUSTER_CTX}" -n demo run web --image=nginx --labels=app=webkubectl --context="${VCLUSTER_CTX}" -n demo run probe --image=busybox:1.36 \--labels=role=client -- sleep 3600kubectl --context="${VCLUSTER_CTX}" -n demo2 run web --image=nginx --labels=app=webkubectl --context="${VCLUSTER_CTX}" -n demo2 run probe --image=busybox:1.36 \--labels=role=client -- sleep 3600kubectl --context="${VCLUSTER_CTX}" wait --for=condition=Ready pod --all -n demo --timeout=2mkubectl --context="${VCLUSTER_CTX}" wait --for=condition=Ready pod --all -n demo2 --timeout=2mTenant Cluster Apply a policy in
demoonly:deny-web-ingress.yamlapiVersion: projectcalico.org/v3kind: NetworkPolicymetadata:name: deny-web-ingressnamespace: demospec:selector: app == 'web'types:- Ingressingress:- action: Allowsource:selector: role == 'client'Apply the policykubectl --context="${VCLUSTER_CTX}" create -f deny-web-ingress.yamlControl Plane Cluster vCluster rewrites object names, so list the policies instead of looking one up by name:
Read the synced selectorskubectl --context="${HOST_CTX}" -n "${HOST_NAMESPACE}" \get networkpolicies.projectcalico.org \-o custom-columns='NAME:.metadata.name,TARGET:.spec.selector,SOURCE:.spec.ingress[0].source.selector'The synced copy carries the scope term:
Composed selectorNAME TARGET SOURCEv1mn70meafc7je (app == 'web') && vcluster.loft.sh/namespace == 'demo' (role == 'client') && vcluster.loft.sh/namespace == 'demo'Tenant Cluster The client in
democan reach the selected web server in the same namespace:Allow the client in demoDEMO_WEB_IP=$(kubectl --context="${VCLUSTER_CTX}" -n demo get pod web -o jsonpath='{.status.podIP}')kubectl --context="${VCLUSTER_CTX}" -n demo exec probe -- \wget -q -T 5 -O- "http://${DEMO_WEB_IP}:80/"Tenant Cluster The identically labeled client in
demo2can't reach the protected web server indemo:Calico programs policy asynchronously. If this check reports unexpected access immediately after you create the policy, wait a few seconds and run it again.
Block the client in demo2DEMO_WEB_IP=$(kubectl --context="${VCLUSTER_CTX}" -n demo get pod web -o jsonpath='{.status.podIP}')if kubectl --context="${VCLUSTER_CTX}" -n demo2 exec probe -- \wget -q -T 5 -O- "http://${DEMO_WEB_IP}:80/"; thenecho "unexpectedly reached the protected workload"elseecho "blocked as expected"fiTenant Cluster The web server in
demo2keeps serving traffic because its namespace has no policy:Reach the unprotected workload in demo2DEMO2_WEB_IP=$(kubectl --context="${VCLUSTER_CTX}" -n demo2 get pod web -o jsonpath='{.status.podIP}')kubectl --context="${VCLUSTER_CTX}" -n demo2 exec probe -- \wget -q -T 5 -O- "http://${DEMO2_WEB_IP}:80/"Tenant Cluster The tenant object still reads the selectors it was created with, not the composed values from the control plane cluster:
Read the tenant selectorskubectl --context="${VCLUSTER_CTX}" -n demo get networkpolicies.projectcalico.org \deny-web-ingress \-o jsonpath='{.spec.selector}{"\n"}{.spec.ingress[0].source.selector}{"\n"}'Tenant selectorsapp == 'web'role == 'client'
Apply a policy in one namespace​
Check the selector on the control plane cluster​
Confirm the policy works within its namespace​
Confirm the rule selector doesn't cross namespaces​
Confirm the other namespace is unaffected​
Confirm the tenant object is unchanged​
Limitations​
- Keep the brackets around
value. Calico gives||the lowest precedence, soapp == 'web' || all()composed without brackets becomesapp == 'web' || (all() && <scope term>), and the first half escapes the scope term. Selector grammar belongs to the extension API server, so check its precedence rules. - Don't patch
notSelectorby appending the scope term. Calico negates the wholenotSelector, which turns the result into "not X, or outside this namespace" and admits other namespaces' workloads as peers. This guide doesn't translatenotSelectorsemantics. - This configuration doesn't translate
namespaceSelector. It selects over namespace labels, but tenant namespace objects don't exist separately on the control plane cluster in single-namespace mode. Don't usenamespaceSelectorin policies synced with this configuration. - This configuration doesn't translate
serviceAccountSelector, rule-levelserviceAccounts, orservicesreferences. Don't use those fields unless you add resource-specific translations and verify the resulting policy on the control plane cluster. - The positive rule-selector patches limit matches to synced workload endpoints that carry
vcluster.loft.sh/namespace. They don't preserve selectors intended to match a Calico NetworkSet or HostEndpoint. - A rule with no
sourceordestinationselector at all isn't scoped either. Calico matches an empty entity rule against every endpoint in scope. For a namespaced policy, that scope is its own namespace, so an unscoped rule admits the same cross-tenant match this guide exists to prevent. A path-specific patch can't add a selector to a rule that doesn't have one, so avoid rules that rely on an implicit match-all peer. - Roll out or restart the vCluster control plane after adding these patches. The empty-path patch runs unconditionally, so reconciliation retrofits every top-level
spec.selector, including an omitted selector. Existing rule selectors are patched only when that rule changes; update or recreate those policies after the rollout.
Apply this to other resources​
Any aggregated API resource that decides its scope through an opaque string field needs the same treatment, using the terms and grammar of that resource's own selector language. See Patching synced resources for the full expression syntax.