# SandboxTemplate Reconciler

> Validation and bookkeeping done by the template controller, including how template changes ripple to claims and warm pools.

- Repository: kubernetes-sigs/agent-sandbox
- GitHub: https://github.com/kubernetes-sigs/agent-sandbox
- Human wiki: https://grok-wiki.com/public/wiki/kubernetes-sigs-agent-sandbox-c3f2597a654a
- Complete Markdown: https://grok-wiki.com/public/wiki/kubernetes-sigs-agent-sandbox-c3f2597a654a/llms-full.txt

## Source Files

- `extensions/controllers/sandboxtemplate_controller.go`
- `extensions/controllers/sandboxtemplate_controller_test.go`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [extensions/controllers/sandboxtemplate_controller.go](extensions/controllers/sandboxtemplate_controller.go)
- [extensions/controllers/sandboxtemplate_controller_test.go](extensions/controllers/sandboxtemplate_controller_test.go)
- [extensions/api/v1beta1/sandboxtemplate_types.go](extensions/api/v1beta1/sandboxtemplate_types.go)
- [extensions/controllers/utils.go](extensions/controllers/utils.go)
- [extensions/controllers/sandboxwarmpool_controller.go](extensions/controllers/sandboxwarmpool_controller.go)
- [extensions/controllers/sandboxclaim_controller.go](extensions/controllers/sandboxclaim_controller.go)
- [controllers/sandbox_controller.go](controllers/sandbox_controller.go)
</details>

# SandboxTemplate Reconciler

The `SandboxTemplateReconciler` is a focused controller-runtime reconciler whose sole job is to materialize one shared `NetworkPolicy` per `SandboxTemplate`. The `SandboxTemplate` CR itself is mostly inert spec — it carries the `PodTemplate`, `VolumeClaimTemplates`, an `EnvVarsInjectionPolicy`, an optional `NetworkPolicy` block and a `NetworkPolicyManagement` mode. The template controller owns only the lifecycle of the derived shared `NetworkPolicy`; the *rippling* of template spec changes to existing `Sandbox`, `SandboxClaim`, and `SandboxWarmPool` objects is performed by the claim and warm-pool reconcilers, which watch templates and react to events.

This page describes the reconciler's responsibilities, the secure-by-default policy it synthesizes, the management modes, the label hash that ties everything together, and how that hash is the mechanism by which template changes propagate to downstream consumers.

## Scope and responsibilities

The reconciler is intentionally narrow. Looking at the imports and `SetupWithManager`, it watches `SandboxTemplate` as its primary resource and `Owns` only `NetworkPolicy`; it does not own `Sandbox`, `SandboxClaim`, or `SandboxWarmPool` objects, and it does not write template status.

```go
// extensions/controllers/sandboxtemplate_controller.go
func (r *SandboxTemplateReconciler) SetupWithManager(mgr ctrl.Manager, concurrentWorkers int) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&extensionsv1beta1.SandboxTemplate{}).
        Owns(&networkingv1.NetworkPolicy{}).
        WithOptions(controller.Options{MaxConcurrentReconciles: concurrentWorkers}).
        Complete(r)
}
```

The reconciler struct holds the controller-runtime `Client`, the runtime `Scheme`, an `events.EventRecorder`, and an `asmetrics.Instrumenter` for span/tracing. RBAC kubebuilder markers grant verbs only on `sandboxtemplates`, `sandboxtemplates/finalizers`, `networkpolicies`, and `events` — there are no permissions for sandboxes, claims, or pools.

Sources: [extensions/controllers/sandboxtemplate_controller.go:38-50](extensions/controllers/sandboxtemplate_controller.go), [extensions/controllers/sandboxtemplate_controller.go:216-222](extensions/controllers/sandboxtemplate_controller.go)

## Reconcile loop

Each `Reconcile` call is a six-step state machine that converges the shared `NetworkPolicy` named `<template-name>-network-policy` in the template's own namespace.

| Step | Action | Source |
|------|--------|--------|
| 1 | `Get` the `SandboxTemplate`; on `NotFound` return cleanly. | [sandboxtemplate_controller.go:56-62](extensions/controllers/sandboxtemplate_controller.go) |
| 2 | Open a trace span via `Tracer.StartSpan`; bail early if `DeletionTimestamp` is set. | [sandboxtemplate_controller.go:64-69](extensions/controllers/sandboxtemplate_controller.go) |
| 3 | Resolve `npName = template.Name + "-network-policy"`, default `NetworkPolicyManagement` to `Managed` when empty. | [sandboxtemplate_controller.go:72-78](extensions/controllers/sandboxtemplate_controller.go) |
| 4 | If management is `Unmanaged`, delete the named policy (ignoring `NotFound`) and exit. | [sandboxtemplate_controller.go:81-92](extensions/controllers/sandboxtemplate_controller.go) |
| 5 | Construct the desired `NetworkPolicySpec` — either the secure default or a controller-shaped wrapper around the user spec. | [sandboxtemplate_controller.go:95-112](extensions/controllers/sandboxtemplate_controller.go) |
| 6 | Reconcile by diff: `Get` the existing policy; if `equality.Semantic.DeepEqual` matches, no-op; otherwise `Update`. If missing, `Create` with the template set as controller reference. | [sandboxtemplate_controller.go:115-153](extensions/controllers/sandboxtemplate_controller.go) |

Two implementation choices are worth noting. First, an unmanaged template will actively *delete* any pre-existing policy with the canonical name, which is how a user transitions a template from `Managed` to `Unmanaged` and trusts an external CNI such as Cilium to take over. Second, the semantic-deep-equal short-circuit (`return ctrl.Result{}, nil // Perfect match, O(1) efficiency.`) is what makes high-frequency requeues from downstream watchers cheap.

Only the create path calls `controllerutil.SetControllerReference`. Once the policy exists, subsequent updates only overwrite its `Spec`, so the owner reference is established exactly once and is what triggers `Owns(&networkingv1.NetworkPolicy{})` to requeue the template when its policy is mutated externally.

```mermaid
stateDiagram-v2
    [*] --> Fetched: Get SandboxTemplate
    Fetched --> Done: DeletionTimestamp != 0
    Fetched --> ResolveScope: alive
    ResolveScope --> DeleteNP: management == Unmanaged
    ResolveScope --> BuildDesired: management == Managed
    BuildDesired --> CheckExisting: spec built
    CheckExisting --> Done: DeepEqual match (no-op)
    CheckExisting --> UpdateNP: drift detected
    CheckExisting --> CreateNP: NotFound
    UpdateNP --> Done
    CreateNP --> Done
    DeleteNP --> Done
```

Sources: [extensions/controllers/sandboxtemplate_controller.go:52-154](extensions/controllers/sandboxtemplate_controller.go)

## Management modes

`NetworkPolicyManagement` is a typed string enum on the template spec with kubebuilder-validated values `Managed` and `Unmanaged`. The CRD defaults to `Managed` and so does the reconciler when the field is empty, giving identical behavior whether the field is omitted or explicitly set.

| Mode | `template.Spec.NetworkPolicy` | Reconciler behavior |
|------|-------------------------------|---------------------|
| `Managed` (default) | `nil` | Builds the **Secure by Default** policy via `buildDefaultNetworkPolicySpec`. |
| `Managed` | set | Builds a controller-shaped policy whose `Ingress`/`Egress` come from the user spec, but whose `PodSelector` and `PolicyTypes` are still controller-owned. |
| `Unmanaged` | ignored | Deletes the canonical policy if present and returns; the `NetworkPolicy` field is *completely ignored* in this mode. |

The "Unmanaged ignores `NetworkPolicy`" semantic is exercised explicitly by the `templateOptOut` fixture in the test file, where the template carries a non-nil `NetworkPolicy.Egress` rule but no policy is expected to be created.

Sources: [extensions/api/v1beta1/sandboxtemplate_types.go:26-128](extensions/api/v1beta1/sandboxtemplate_types.go), [extensions/controllers/sandboxtemplate_controller.go:74-92](extensions/controllers/sandboxtemplate_controller.go), [extensions/controllers/sandboxtemplate_controller_test.go:71-79](extensions/controllers/sandboxtemplate_controller_test.go)

## Desired spec construction

Two fields of the resulting `NetworkPolicySpec` are always controller-owned, regardless of whether the template provided a custom policy: `PodSelector` and `PolicyTypes`. The CRD type comment calls this out explicitly — those fields are intentionally absent from `extensionsv1beta1.NetworkPolicySpec` so they cannot be overridden.

`PodSelector` always selects on the label key `agents.x-k8s.io/sandbox-template-ref-hash` with a value computed from the template name (see [Template hash propagation](#template-hash-propagation) below). `PolicyTypes` is always `[Ingress, Egress]`, which ensures a default-deny posture for both directions even when the user supplies only one rule list.

### Secure-by-default policy

When the template omits `NetworkPolicy` under `Managed`, `buildDefaultNetworkPolicySpec` produces a strict isolation profile:

- **Ingress**: a single rule allowing traffic only from pods labelled `app: sandbox-router`.
- **Egress**: a single `IPBlock` rule allowing `0.0.0.0/0` minus `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, and `169.254.0.0/16`, plus `::/0` minus `fc00::/7` for IPv6.

The inline comments emphasize the security stance: the egress block list deliberately excludes RFC1918 ranges (cluster/VPC traffic), the link-local metadata server, and IPv6 ULA. The comment on `169.254.0.0/16` is "Block Link-Local (Metadata Server)" and a comment notes "This intentionally blocks internal cluster DNS (CoreDNS) by default to prevent agents from probing for service discovery and leaking internal service names."

That last point is why `ApplySandboxSecureDefaults` in `utils.go` injects `DNSPolicy: None` plus public resolvers (`8.8.8.8`, `1.1.1.1`) into the Pod spec *only* when the template is in secure-by-default mode (managed and no custom `NetworkPolicy`). Custom policies and unmanaged templates leave DNS alone for "air-gapped/proxy compatibility." This is the one cross-cutting touchpoint between template configuration and the per-pod spec; it is invoked by the claim and warm-pool reconcilers, not by the template reconciler itself.

```go
// extensions/controllers/utils.go
isSecureByDefault := isManaged && template.Spec.NetworkPolicy == nil
if isSecureByDefault && spec.DNSPolicy == "" {
    spec.DNSPolicy = corev1.DNSNone
    spec.DNSConfig = &corev1.PodDNSConfig{
        Nameservers: []string{"8.8.8.8", "1.1.1.1"},
    }
}
```

### Custom policy

When `NetworkPolicy` is set under `Managed`, the reconciler still owns the selector and policy types but copies the user's `Ingress` and `Egress` lists verbatim:

```go
desiredSpec = networkingv1.NetworkPolicySpec{
    PodSelector: metav1.LabelSelector{
        MatchLabels: map[string]string{
            sandboxTemplateRefHash: SandboxTemplateRefHash(template.Name),
        },
    },
    PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress},
    Ingress: template.Spec.NetworkPolicy.Ingress,
    Egress:  template.Spec.NetworkPolicy.Egress,
}
```

Because `PolicyTypes` always includes both directions, an empty `Ingress` or empty `Egress` list is a default-deny for that direction. The CRD field comment warns that this can block sidecar health checks (Istio proxy, monitoring agents) if their ports are not explicitly allowed.

Sources: [extensions/controllers/sandboxtemplate_controller.go:94-112](extensions/controllers/sandboxtemplate_controller.go), [extensions/controllers/sandboxtemplate_controller.go:156-213](extensions/controllers/sandboxtemplate_controller.go), [extensions/controllers/utils.go:23-48](extensions/controllers/utils.go), [extensions/api/v1beta1/sandboxtemplate_types.go:58-114](extensions/api/v1beta1/sandboxtemplate_types.go)

## Template hash propagation

The reconciler does not directly update sandboxes when a template changes; it does not have permissions to. Instead, propagation rides on a content-addressable label, `agents.x-k8s.io/sandbox-template-ref-hash`, declared as the package-level constant `sandboxTemplateRefHash` in `sandboxwarmpool_controller.go`. The label value is `NameHash(templateName)`, an FNV-1a 32-bit hash formatted as 8 hex characters.

```go
// controllers/sandbox_controller.go
func NameHash(objectName string) string {
    return fmt.Sprintf("%08x", GetNumericHash(objectName))
}
```

Three controllers cooperate via this single label:

```mermaid
flowchart LR
    subgraph Template["SandboxTemplate domain"]
        TPL[SandboxTemplate spec]
        TC[SandboxTemplateReconciler]
        NP["Shared NetworkPolicy\n&lt;name&gt;-network-policy\nPodSelector: ref-hash=H"]
    end
    subgraph Pool["Warm pool domain"]
        WP[SandboxWarmPool]
        WPC[SandboxWarmPoolReconciler]
        WS["Warm Sandboxes\nlabel ref-hash=H"]
    end
    subgraph Claim["Claim domain"]
        SC[SandboxClaim]
        SCC[SandboxClaimReconciler]
        SB["Claimed Sandbox\nlabel ref-hash=H"]
    end
    TPL --> TC --> NP
    TPL -.watches.-> WPC
    TPL -.watches.-> SCC
    WPC --> WS
    SCC --> SB
    NP -. CNI selects pods .-> WS
    NP -. CNI selects pods .-> SB
```

- The template reconciler stamps the hash into the `NetworkPolicy.PodSelector.MatchLabels`.
- The claim reconciler stamps the same hash onto the `Sandbox.Spec.PodTemplate.ObjectMeta.Labels` (and the merged pod metadata) when materializing or adopting a sandbox.
- The warm-pool reconciler stamps the same hash onto its warm-up sandboxes and uses it as the queue key for prewarmed candidates.

Because the policy's `PodSelector` and every downstream sandbox carry the same value, the CNI binds them at runtime — no extra plumbing required when the policy spec changes.

Sources: [extensions/controllers/sandboxwarmpool_controller.go:48-52](extensions/controllers/sandboxwarmpool_controller.go), [extensions/controllers/utils.go:50-59](extensions/controllers/utils.go), [controllers/sandbox_controller.go:454-458](controllers/sandbox_controller.go), [extensions/controllers/sandboxclaim_controller.go:358-372](extensions/controllers/sandboxclaim_controller.go), [extensions/controllers/sandboxclaim_controller.go:960-970](extensions/controllers/sandboxclaim_controller.go)

## How template changes ripple to claims and warm pools

The template reconciler only updates the `NetworkPolicy`. Spec changes that affect `PodTemplate`, `VolumeClaimTemplates`, or `EnvVarsInjectionPolicy` reach existing sandboxes through *watch-driven requeue* in the other two reconcilers.

### Claim reconciler watch wiring

The claim controller installs two watches against `SandboxTemplate` in its `SetupWithManager`:

```go
// extensions/controllers/sandboxclaim_controller.go
Watches(&extensionsv1beta1.SandboxTemplate{}, &templateEventHandler{sandboxQueue: r.WarmSandboxQueue}).
Watches(
    &extensionsv1beta1.SandboxTemplate{},
    handler.EnqueueRequestsFromMapFunc(r.mapTemplateToClaims),
    builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
).
```

- `mapTemplateToClaims` uses a `TemplateRefField` field index over `SandboxClaim` objects to enqueue every claim that references the changed template, scoped to its namespace.
- A `templateEventHandler` listens for template **deletion** only, and on delete calls `sandboxQueue.RemoveQueue(templateHash)` to flush warm-pool candidates from the in-memory queue, so claims do not pick up sandboxes destined for a vanished template.

When a claim is requeued, it re-runs its hot/cold logic; the hot path may rewrite the sandbox's merged pod metadata to match the latest template labels and annotations, including refreshing the `sandboxTemplateRefHash` label.

### Warm-pool reconciler watch wiring

The warm-pool reconciler installs an equivalent watch:

```go
// extensions/controllers/sandboxwarmpool_controller.go
Watches(
    &extensionsv1beta1.SandboxTemplate{},
    handler.EnqueueRequestsFromMapFunc(r.findWarmPoolsForTemplate),
).
```

`findWarmPoolsForTemplate` lists `SandboxWarmPool` objects via a `TemplateRefField` field index and requeues each one. Inside the reconcile loop, `isSandboxStale` then evaluates each existing warm-pool sandbox against the current template: it checks the `sandboxTemplateRefHash` label, then uses `comparePodSpecs`, which applies `ApplySandboxSecureDefaults` to a deep copy of the template spec and `equality.Semantic.DeepEqual`s it against the live sandbox spec. Stale sandboxes are recycled.

```go
// extensions/controllers/sandboxwarmpool_controller.go
if sandbox.Labels[sandboxTemplateRefHash] != SandboxTemplateRefHash(template.Name) {
    return true
}
// ... hash-cache + semantic DeepEqual on normalized pod specs.
```

### Network-policy ripple

For the `NetworkPolicy` itself the ripple is implicit: the template reconciler updates a single shared object, and the cluster CNI re-evaluates rules against every pod that currently carries the matching `sandboxTemplateRefHash` label — both warm-pool sandboxes and claimed sandboxes — without any per-sandbox write from the template controller. The CRD documentation field comment makes this explicit: "any updates to these rules will be applied to the single shared policy object. The underlying Kubernetes CNI will then dynamically enforce the updated rules across all existing and future sandboxes referencing this template."

Sources: [extensions/controllers/sandboxclaim_controller.go:1249-1298](extensions/controllers/sandboxclaim_controller.go), [extensions/controllers/sandboxclaim_controller.go:1543-1566](extensions/controllers/sandboxclaim_controller.go), [extensions/controllers/sandboxwarmpool_controller.go:533-583](extensions/controllers/sandboxwarmpool_controller.go), [extensions/controllers/sandboxwarmpool_controller.go:462-531](extensions/controllers/sandboxwarmpool_controller.go), [extensions/api/v1beta1/sandboxtemplate_types.go:99-104](extensions/api/v1beta1/sandboxtemplate_types.go)

## Test coverage and invariants

`TestSandboxTemplateReconcileNetworkPolicy` is a table-driven test covering the five user-visible behaviors of the reconciler:

| Case | Pre-state | Expected post-state |
|------|-----------|--------------------|
| `Creates Default Secure Policy (Strict Isolation) when template has none` | template with empty `NetworkPolicy`, managed | shared NP with `PolicyTypes` length 2, ingress from `app: sandbox-router`, egress `0.0.0.0/0`, selector key `agents.x-k8s.io/sandbox-template-ref-hash` |
| `Creates custom network policy when defined in template` | template with explicit ingress/egress | shared NP whose selector value equals `NameHash("test-template-custom")`, ingress preserved as `app: ingress` |
| `NetworkPolicy is not created when template is Unmanaged` | unmanaged template, no existing NP | no NP exists |
| `Existing NetworkPolicy is deleted when template updates to Unmanaged` | unmanaged template + pre-existing NP | NP deleted |
| `Existing NetworkPolicy is updated when template spec changes` | template with new spec + outdated NP | NP overwritten; stale `old-label` removed, new ingress applied |

The "update" case is the strongest behavioral invariant: outdated `PodSelector.MatchLabels["old-label"]` must disappear after one reconcile, confirming that the reconciler replaces `existingNP.Spec` wholesale rather than merging. That matches the implementation line `existingNP.Spec = desiredSpec` followed by `r.Update(ctx, existingNP)`.

Sources: [extensions/controllers/sandboxtemplate_controller_test.go:39-201](extensions/controllers/sandboxtemplate_controller_test.go), [extensions/controllers/sandboxtemplate_controller.go:118-130](extensions/controllers/sandboxtemplate_controller.go)

## Summary

The `SandboxTemplateReconciler` is deliberately small: it converges a single shared `NetworkPolicy` per `SandboxTemplate`, honors a `Managed`/`Unmanaged` mode switch, defaults to a strict secure-by-default profile that blocks RFC1918 and metadata-server egress, and idempotently exits via `equality.Semantic.DeepEqual`. It never writes downstream resources directly. Spec changes ripple to existing claims and warm pools through field-indexed watch handlers in the `SandboxClaim` and `SandboxWarmPool` controllers, while the shared `NetworkPolicy` itself ripples via CNI enforcement against the controller-owned `sandboxTemplateRefHash` pod-selector label that every downstream sandbox carries.
