Managed Resources and Server-Side Apply

Resource ownership, server-side apply, protection, cleanup policies, and status.

Capsule uses a shared resource manager to apply Kubernetes objects, track their ownership, and prune them when they leave scope. TenantResource, GlobalTenantResource, ResourcePermitTemplate, and GlobalResourcePermitTemplate share spec.resources[].policy. Each block controls its generated resources, allowing namespaces within a tenant to receive different configuration and lifecycle behavior.

Apply and ownership model

Capsule uses Kubernetes server-side apply (SSA) with a stable field manager for each managing parent. A ResourcePermit manager is derived from the request UID:

projectcapsule.dev/resource/resourcepermit/<request-uid>

SSA allows Capsule and another manager to own different fields on the same object. When both managers claim the same field, Kubernetes reports an apply conflict unless Capsule is configured to force ownership.

For Resource Permits, Capsule renders every target before approval and stores the concrete manifests and their policies in .status.request.resources. That snapshot—not the mutable source template—is the source of truth for review, apply, and cleanup.

Resource policy

One policy applies to every target in a resource group:

resources:
  - policy:
      creation: Owner
      protect: true
      force: false
      deletion: Remove
    targets:
      - apiVersion: v1
        kind: ConfigMap
        metadata:
          name: temporary-settings
FieldValuesDefaultBehavior
creationOwner, MergeOwnerWhether an existing object may be adopted
protecttrue, falsetrueWhether admission blocks direct changes while managed
forcetrue, falsefalseWhether SSA may take conflicting field ownership
deletionRemove, OrphanRemoveWhat happens when the parent stops managing the object
conditionCEL boolean expressionOmittedWhether to apply rendered content to each destination

Owner requires Capsule to have created the target. If an object with the same identity already exists and was not created for the managing API, reconciliation fails. Merge adopts an existing object when possible and creates it otherwise. Use Merge only when the workflow intentionally manages fields on pre-existing objects.

force: true resolves SSA conflicts by taking ownership of the rendered fields. It does not bypass Kubernetes RBAC, admission, or schema validation, and can displace another controller’s ownership.

With protect: true, Capsule marks the object as protected. The generic Capsule admission webhook rejects direct updates and deletion while the parent manages it, except for the controller and the resolved execution ServiceAccount. Disable protection only when another actor must modify the object during its managed lifetime.

With deletion: Remove, when multiple Capsule resource managers share an adopted target, removing one manager preserves the shared tracking and protection metadata while another Capsule resource manager remains. The final departing manager removes that metadata without deleting fields owned by external managers.

Cleanup depends on both the deletion policy and whether Capsule created or adopted the object:

PolicyObject created by CapsuleExisting object adopted with Merge
RemoveDelete the objectRelinquish managed fields and tracking
OrphanKeep the object and remove tracking/protectionKeep the object and applied fields, then remove tracking/protection

Orphan can leave privileges or configuration behind after the parent expires, so it should be an explicit design choice.

Apply conditions

Set spec.resources[].policy.condition on TenantResource, GlobalTenantResource, ResourcePermitTemplate, or GlobalResourcePermitTemplate. One expression applies independently to every destination produced by that resource block; separate blocks can use different conditions. The expression must return a boolean and may contain up to 4096 characters. Omit the field to keep unconditional apply; an empty or whitespace-only expression is not a substitute for omitting it.

InputMeaning
objectThe existing destination resource, read using the execution identity; null if it does not exist. It is not the rendered candidate.
nowThe UTC timestamp at evaluation time. Use timestamp(...) and duration(...) for time comparisons.

Apply conditions use CEL directly. There is no self, .self, Go-template expansion, or access to template parameters/context in this expression. Load context and render the desired resource through the existing template API. The condition then decides whether that rendered resource may be applied.

For example, create a target only when it is absent:

policy:
  condition: "object == null"

To initialize a target and then allow a refresh after five minutes:

policy:
  condition: |
    object == null ||
    !has(object.metadata.annotations) ||
    !('keys.example.org/rotated-at' in object.metadata.annotations) ||
    now >= timestamp(object.metadata.annotations['keys.example.org/rotated-at']) + duration('5m')    

The generator must write the keys.example.org/rotated-at annotation on each successful refresh. Guard missing objects and fields before accessing them; use bracket notation for annotation keys containing dots or slashes. A missing timestamp initializes this example, while a malformed timestamp causes an evaluation error. See the complete rotation example for the template, retention behavior, and verification steps.

OutcomeApply behavior
Condition omittedExisting unconditional apply behavior.
trueApply using the block’s creation, force, and protection policies; normal authorization and ownership checks still apply.
falseSkip rendered content and preserve its last-apply timestamp. Reconcile protection and retain the current policy for already managed targets. A never-applied target is not adopted, protected, or modified.
Invalid syntax or non-boolean result typeAdmission rejects the expression at spec.resources[i].policy.condition.
Evaluation or target-read errorThe target is not written; the failure is reported through existing status. A forbidden read is not treated as an absent object.

There is no configurable action for a false result. Conditions do not provide an all-or-nothing transaction across targets: each target is evaluated separately. They also do not change SSA retention. To preserve old fields owned by Capsule, include them in subsequent rendered resources, as the age-key example does.

For example, changing protect: true to protect: false removes protection on an already managed target even while its condition is false, provided no other Capsule resource manager still shares the target. Protection is also reconciled when the condition becomes true or is removed; protection established during a skipped apply does not persist after a sole manager disables it. Changing deletion: Remove to deletion: Orphan takes effect for subsequent cleanup. creation and force govern the next permitted content apply; changing them does not override a false condition. Rendered labels, annotations, and data remain unchanged, including key material and rotation timestamps. Capsule may patch its own protection metadata, so the target’s resource version can change. When that metadata already matches, a skipped reconciliation performs no target write. Policy changes that fail to reconcile are reported through existing status.

Evaluation lifecycle

ResourceEvaluation lifecycleSuitable use
TenantResource / GlobalTenantResourceRender and evaluate during reconciliation, including periodic resync.Recurring namespace configuration and key rotation.
ResourcePermitTemplate / GlobalResourcePermitTemplateRender a request snapshot, then evaluate against live targets during preflight, activation, and explicit failure retries. Active permits do not periodically reevaluate.Conditional provisioning during an approved permit’s activation.

Permit approval conditions (spec.approvals.conditions) control approval eligibility. Apply conditions control target writes after those approval rules are satisfied. In particular, an Active permit may contain skipped targets; use approval conditions when the entire permit must be gated. The permit guide provides namespaced/global examples, required permissions, and status diagnostics.

Scope, tracking, and cleanup

For namespaced targets, Capsule normally defaults an omitted namespace to the parent workflow’s namespace. An explicitly rendered namespace is preserved when the workflow is trusted to operate across namespaces. Cluster-scoped GVKs are applied without a namespace.

A namespaced parent cannot own a cluster-scoped object through a Kubernetes owner reference. APIs such as ResourcePermit therefore record every processed target and use a finalizer for explicit cascading cleanup. Successful targets remain recorded even if a later target fails, allowing retries and finalization to handle partial application.

Managed objects may carry labels and annotations such as:

metadata:
  labels:
    projectcapsule.dev/created-by: resource-permit
    projectcapsule.dev/managed-by: resource-permit
    projectcapsule.dev/protected-by: resource-permit
  annotations:
    projectcapsule.dev/active-until: "2026-08-31T11:00:00Z"
    projectcapsule.dev/resourcepermit-service-account: system:serviceaccount:capsule-system:resource-permit-runner

The exact metadata depends on the parent API, policy, and lifecycle stage. Capsule sets tracking metadata independently of the rendered manifest, so a template cannot forge whether an object was created or adopted.

Status and failures

Managed APIs use a shared processed-resource status shape. A ResourcePermit example is:

status:
  size: 2
  processedItems:
    - group: rbac.authorization.k8s.io
      version: v1
      kind: Role
      namespace: solar-uat
      name: incident-1042-editor
      status:
        clusterScoped: false
        created: true
        lastApply: "2026-08-31T10:02:13Z"
        type: Ready
        status: "True"
    - group: rbac.authorization.k8s.io
      version: v1
      kind: ClusterRole
      name: incident-1042-cluster-reader
      status:
        clusterScoped: true
        created: true
        lastApply: "2026-08-31T10:02:14Z"
        type: Ready
        status: "True"

Each item identifies its GVK, name, namespace and scope, whether it was created or adopted, its last apply time, and its latest condition or error. Skipped targets report ConditionNotMet: apply skipped. A previously applied target keeps its last successful apply time; a never-applied target has none. The retained policy records the last successful policy reconciliation and determines subsequent cleanup. Ownership violations, SSA conflicts, RBAC denials, unavailable GVKs, invalid manifests, and admission failures are reported through this status and the parent conditions.

kubectl get resourcepermit REQUEST -n NAMESPACE -o json \
  | jq '.status | {size, processedItems, conditions}'

Do not edit controller-owned rendered manifests or processed-item status to recover from a failure. Correct the underlying object, permissions, template, or policy and let reconciliation retry.