resourceapplier

package
v0.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 30, 2026 License: Apache-2.0 Imports: 28 Imported by: 0

README

resourceapplier

Generic, leader-only applier for template-declared Kubernetes resources.

Purpose

Templates declare desired Kubernetes resources via the spec.k8sResources map on HAProxyTemplateConfig. Each entry's template renders to one or more YAML documents (----separated) describing full Kubernetes resources. The renderer parses each document, validates required fields (apiVersion, kind, metadata.{name,namespace}), and seals the resulting set into a render occurrence. This component reads the authenticated snapshot, reconciles it through Server-Side Apply (SSA), and prunes resources removed from later renders with exact UID and resourceVersion preconditions.

For full-ownership resources in the controller's own namespace the applier injects a controller OwnerReference (controller=true, blockOwnerDeletion=true) pointing at the HAProxyTemplateConfig CR, so cascade-delete (e.g. helm uninstall) GCs them automatically.

Mirrors statusapplier exactly except it operates on full resources rather than .status sub-paths.

Resource-agnostic by design

The controller never names "Service" or "Gateway" or any specific Kubernetes resource type. Templates emit; the applier reconciles whatever they produced. Same architectural principle as statusPatch(), templateSnippets, and watchedResources — generic plumbing, chart templates own the domain knowledge.

Safety contract: dry-runs and webhook validations

This is the contract every contributor should understand before touching this package or any caller.

Subscribed events (the only inputs that lead to API calls):

  • ReconciliationCompletedEvent — authenticates RenderOccurrence(), applies the resources from its sealed cycle, then publishes ResourcesAppliedEvent with that same occurrence
  • RenderGateCompletedEvent — authenticates the same occurrence before holding, releasing, or reverting resources
  • BecameLeaderEvent — rebuilds the owned set from cluster state
  • LostLeadershipEvent — pauses applies

Publishers of those events:

  • pkg/controller/reconciler.Coordinator — the leader-only Stage 5 component that drives the production render → validate → publish pipeline. This is the only publisher.

What does NOT publish those events:

  • pkg/controller/dryrunvalidator — admission webhook handler. Calls pkg/controller/proposalvalidator.ValidateSync directly; renders templates, checks assertions, returns a verdict. Never touches the reconciliation event types this applier subscribes to.
  • pkg/controller/proposalvalidator — only emits ProposalValidationCompletedEvent / ProposalValidationFailedEvent.
  • pkg/controller/testrunner — pure component, no event coordination. Used by the validate CLI subcommand and the dryrunvalidator.
  • pkg/webhook — synchronous HTTP path; no event publishing.
  • cmd/haptic/benchmark_render.go — local-only, no bus involved.

What that means in practice:

When a webhook admission request arrives, the dryrunvalidator renders the proposed config in an overlay store. Each spec.k8sResources template runs and its YAML output is parsed into a per-render *RenderedResourceCollector (same lifecycle as *StatusPatchCollector for statusPatch() calls). Both collectors are constructed by pkg/controller/rendercontext.Builder.Build() and live in the rendering context for that single call. After the render finishes, the testrunner / dryrunvalidator inspects the rendered HAProxy config and aux files, but does not read the collectors back — see pkg/controller/testrunner/rendering.go:

renderCtx := builder.Build().Context

The BuildResult also carries .StatusPatchCollector and .RenderedResourceCollector; the testrunner reads only .Context, so both collectors fall out of scope. The collectors fall out of scope, GC eats them, and no API call ever happens.

The structural property to preserve: any future code that wires a non-production caller into a TemplateRenderedEvent must also flow its renders through the same path the production Coordinator uses — which means it would be a new production path, and therefore the apply behaviour is intended. A wire-up that publishes TemplateRenderedEvent from a webhook handler with rendered resources attached would be a regression in this contract; reviewers should reject it.

Exact convergence

Every authenticated cycle reaches the API server. A local payload digest can't prove that an unwatched target wasn't mutated or recreated after the previous apply. The SSA response supplies the exact UID and resourceVersion later used as delete preconditions. Missing or mismatched lineage fails closed; a replacement resource with the same name is never deleted as an orphan.

After handling a reconciliation occurrence, the applier publishes ResourcesProcessedEvent, including when application failed or the gate held the cycle. The coordinator waits for that exact acknowledgement before rendering again, so API latency limits production rather than growing the applier's queue. Only successful convergence publishes ResourcesAppliedEvent; the acknowledgement never authorizes status publication. Gate processing and live API checks are unchanged. See ADR-0024.

Render-gate state is keyed by the opaque occurrence, not by public CycleSnapshot, RenderProof, or plan fields. A verdict for another occurrence, including one with identical output, can't release or revert the held cycle.

Orphan pruning

Full and partial ownership

Full ownership is the default. To contribute fields to an object also managed by Helm or another controller, render the annotation haproxy-haptic.org/ownership: partial. The applier strips this internal annotation, omits its managed-by label and owner reference, and never tracks that object for orphan deletion. Changing from full to partial ownership also releases deletion tracking.

To remove an SSA-owned list entry from a shared object, keep rendering the object with the reduced field set. Omitting the whole partial object from later renders doesn't issue a delete or an apply to release its fields.

Each successful pass populates an in-memory lastAppliedKeys map for full-ownership resources keyed by namespace/name/gvr. The next pass computes the new desired set; any key in lastAppliedKeys not in the new set is deleted via the dynamic client. This handles the common case where a Gateway is deleted and the per-Gateway Service should disappear with it.

Startup-orphan recovery

If the controller crashes (or is upgraded) between applying a resource and observing the user's deletion of its driver, the orphan would otherwise persist indefinitely — the new controller process starts with an empty lastAppliedKeys and has no way to know what the previous incarnation applied.

To close this gap, handleBecameLeader runs a discovery pass on every leader-acquire:

  1. discoveryClient.ServerPreferredNamespacedResources() enumerates every namespace-scoped API resource type the cluster supports.
  2. For each type that supports both list and delete verbs, the applier issues dynamicClient.Resource(gvr).Namespace(ownNs).List(opts) with a label selector pinning the managed-by label (haproxy-haptic.org/managed-by=<controller-name>).
  3. When an owner UID is configured, only objects with a matching owner reference enter lastAppliedKeys. Production supplies the config object's UID; a managed-by label alone doesn't authorize deletion of another controller's child objects.

Errors are silent: types we don't have RBAC for return 403 Forbidden, unsupported list operations return 405 MethodNotSupported, CRDs that disappeared between discovery and list return 404 NotFound. None of these abort recovery — the applier discovers what it can, not what it must.

The discovery loop has a recover() fence around List calls so a panicking dynamic-client implementation (e.g. a test fake with a mis-registered scheme) skips the offending type rather than blowing up the whole recovery.

After recovery completes, the next reconciliation's applyAndPrune sees the orphans in lastAppliedKeys and (since the new render doesn't include them) deletes them. Single-reconciliation convergence, fully resource-agnostic — no hardcoded GVR list, no extra persistence layer.

The discovery cost is one round-trip per cluster API resource type (typically ~50–100 calls), incurred once per leader-acquire. Routine operation is unaffected.

Manual sweep (still useful)

The managed-by label is also useful for operator audits:

kubectl get services,configmaps,secrets -A -l haproxy-haptic.org/managed-by=haptic-controller

Lists everything the controller currently owns. Useful for diagnosing unexpected behaviour or for migration scenarios.

Namespace restriction

Config.RestrictToOwnNamespace=true refuses resources whose namespace is empty or differs from OwnNamespace. The production wiring in reconciliation.go sets it to false, allowing templates to target other namespaces where the controller's RBAC permits writes. This setting grants no API permissions: the ServiceAccount still needs the appropriate verbs for every target resource and namespace.

Field manager

Resource applies use field manager haptic; status applies use their own phase-scoped managers. With Force=true, the resource applier takes ownership of any field it sets even if another manager previously claimed it.

Wiring into the controller

Constructed in pkg/controller/reconciliation.go alongside statusapplier, registered as an all-replica subscriber, leader-only applier:

resourceApplierComponent := resourceapplier.New(&resourceapplier.Config{
    EventBus:               bus,
    DynamicClient:          k8sClient.DynamicClient(),
    DiscoveryClient:        k8sClient.Clientset().Discovery(),
    GVRResolver:            statusapplier.NewRestMapperResolver(gvrMapper),
    Logger:                 logger,
    OwnNamespace:           ownNamespace,
    RestrictToOwnNamespace: false,
    OwnerRef:               ownerRef,
})
reg.Register(resourceApplierComponent, false) // all-replica subscriber; applies are gated on the internal leader flag

OwnNamespace is read from the POD_NAMESPACE env var (set by the chart via the downward API), falling back to the in-cluster client's namespace.

Tests

component_test.go covers:

  • New() initialization and field defaults
  • Leader / non-leader gating (non-leader does not apply)
  • Every cycle verifies live state through SSA
  • Authenticated occurrence propagation, shadow poisoning, and A-B-A cycles
  • Exact-UID/resourceVersion orphan deletion and replacement protection
  • Namespace restriction (foreign + cluster-scoped resources are refused)
  • BecameLeaderEvent rebuilds deletion lineage and re-applies
  • LostLeadershipEvent pauses applies
  • Label injection preserves caller's labels and is non-mutating
  • Start() returns cleanly on context cancellation
  • ReconciliationCompletedEvent authenticates the resources it applies

Total coverage: 79.3% of statements.

Documentation

Overview

Package resourceapplier reconciles template-declared Kubernetes resources via Server-Side Apply (SSA).

It consumes resources from the authenticated render-cycle snapshot, applies them with Server-Side Apply, and prunes previously owned resources by exact UID and resourceVersion. Resource kinds remain template-defined.

Index

Constants

View Source
const (
	// ComponentName is the unique identifier for this component.
	ComponentName = "resource-applier"

	// EventBufferSize is the size of the event subscription buffer.
	// High volume: reconciliation.completed fires on every reconcile. Use a
	// Publishing-tier buffer so churn bursts don't drop these (coalescible)
	// events before this applier drains them.
	EventBufferSize = busevents.PublishingSubscriberBuffer

	// LabelManagedBy is injected onto every applied resource so operators
	// can locate everything the controller owns with a single
	// `kubectl get … -l haproxy-haptic.org/managed-by=<name>` selector.
	LabelManagedBy = "haproxy-haptic.org/managed-by"

	// DefaultManagedByValue is the value injected into LabelManagedBy when
	// the chart doesn't override Config.ManagedByValue. Distinct deployments
	// in the same namespace should set their own values.
	DefaultManagedByValue = "haptic-controller"

	// AnnotationOwnership lets templates flag a rendered resource as
	// jointly owned with another field manager (helm / argocd / kubectl).
	// When set to OwnershipPartial, the applier:
	//   - does NOT inject the managed-by label (the resource isn't
	//     ours to claim end-to-end);
	//   - does NOT track the resource for orphan-delete (vanishing from
	//     the rendered set must release SSA-owned fields, never delete
	//     the whole object — that would clobber the chart's static
	//     spec);
	//   - always strips the annotation from the payload before SSA so
	//     it remains a controller-internal flag.
	// SSA's per-list-map-entry ownership (e.g. Service.spec.ports keyed
	// by (port, protocol)) handles the actual field-level merge with
	// the other field manager.
	AnnotationOwnership = "haproxy-haptic.org/ownership"

	// OwnershipPartial is the AnnotationOwnership value that activates
	// partial-ownership mode. Any other value (including absence) means
	// full ownership: existing behaviour, unchanged.
	OwnershipPartial = "partial"
)

Variables

This section is empty.

Functions

This section is empty.

Types

type Component

type Component struct {
	*component.Base
	// contains filtered or unexported fields
}

Component reconciles template-declared resources to the cluster.

All-replica subscriber, leader-only applier — same shape as statusapplier.Component. State lives only on the active leader; replicas in standby just observe events.

func New

func New(cfg *Config) *Component

New constructs an applier and subscribes to the events it needs. Subscription happens in the constructor so events buffered before EventBus.Start() are delivered after — the same all-replica pattern every haptic controller component follows.

func (*Component) CoalescesOn

func (c *Component) CoalescesOn() []string

CoalescesOn opts this applier into component.Base's mailbox coalescing: under churn only the LATEST reconciliation.completed matters (it carries the latest rendered resources, superseding earlier ones), so runs of them collapse in the mailbox and the bus can never overflow this subscriber.

func (*Component) HandleEvent

func (c *Component) HandleEvent(event busevents.Event)

HandleEvent implements component.EventHandler: it fans out by event type, tracking processing time for the health check. Mirror of statusapplier's HandleEvent shape — different events because resource lifecycle is "rendered → deployed (= apply)" rather than the four-phase status patches use.

func (*Component) HealthCheck

func (c *Component) HealthCheck() error

HealthCheck returns nil if the component is healthy.

func (*Component) Start

func (c *Component) Start(ctx context.Context) error

Start captures the loop context for handlers and runs the embedded component.Base event loop until the context is cancelled.

type Config

type Config struct {
	EventBus      *busevents.EventBus
	DynamicClient dynamic.Interface

	// DiscoveryClient is used on leader-acquire to enumerate every
	// namespace-scoped API resource type the cluster supports, so the
	// applier can rebuild its in-memory `lastAppliedKeys` from cluster
	// state via the managed-by label selector. Without this, resources
	// the controller applied before a crash but whose desired state was
	// removed while the controller was down (e.g. the user deleted the
	// parent resource during a controller upgrade) would leak as orphans
	// until manually swept. Optional: when nil, startup-orphan recovery is
	// skipped and operators must rely on the
	// `kubectl get … -l haproxy-haptic.org/managed-by=<name>` mitigation.
	DiscoveryClient discovery.DiscoveryInterface

	GVRResolver GVRResolver

	// ReapplyInterval skips the SSA pass for a cycle whose rendered resources
	// are byte-identical to the last successfully applied cycle, for at most
	// this long. The expiry re-apply is the periodic authoritative live-state
	// verification (wire the drift-prevention interval); zero keeps the
	// verify-every-cycle behavior.
	ReapplyInterval time.Duration
	Logger          *slog.Logger

	// OwnNamespace is the namespace the controller pod runs in. Required
	// when RestrictToOwnNamespace is true.
	OwnNamespace string

	// RestrictToOwnNamespace refuses cluster-scoped and foreign-namespace resources when enabled.
	RestrictToOwnNamespace bool

	// ManagedByValue is the label value injected as
	// `haproxy-haptic.org/managed-by`. Defaults to the controller name
	// ("haptic-controller") so multiple haptic deployments in the same
	// cluster don't clobber each other's managed sets.
	ManagedByValue string

	// OwnerRef identifies the HAProxyTemplateConfig CR that owns the
	// applied resources. The applier injects an `ownerReferences`
	// entry pointing at this object on every full-ownership SSA
	// payload, with `controller: true` and `blockOwnerDeletion: true`
	// so Kubernetes garbage collection cascade-deletes the rendered
	// resources when the CR is removed (e.g. `helm uninstall`).
	//
	// Optional: when zero (UID empty), no OwnerReference is injected.
	// Partial-ownership entries never get an OwnerReference regardless
	// (the chart-static or other field manager already owns the
	// resource end-to-end).
	OwnerRef OwnerReference
}

Config bundles the dependencies a New caller must provide.

type GVRResolver

type GVRResolver = statusapplier.GVRResolver

GVRResolver resolves apiVersion + kind to a GroupVersionResource. Reused from the statusapplier package to avoid duplicate logic.

type OwnerReference

type OwnerReference struct {
	APIVersion string
	Kind       string
	Name       string
	UID        string
}

OwnerReference is the minimal identity of the HAProxyTemplateConfig CR — duplicated here so this package doesn't depend on the apis/ types just to read four strings.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL