resourceapplier

package
v0.2.0-alpha.1 Latest Latest
Warning

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

Go to latest
Published: Jul 5, 2026 License: Apache-2.0 Imports: 25 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 hands the resulting set to this component, which reconciles it to the cluster via Server-Side Apply (SSA), with checksum dedup so unchanged resources don't hammer the API server, and orphan pruning so resources removed from later renders are deleted.

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 — applies the resources carried ON the event (stateless: no side-channel cache), then publishes ResourcesAppliedEvent forwarding the cycle's status patches so the StatusApplier writes the rendered variant only after the resources exist
  • BecameLeaderEvent — clears checksum cache, rebuilds 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/controller/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.

Checksum dedup

Each applied resource's full payload (after the managed-by label injection) is hashed with SHA-256, and the hash is cached per namespace/name/gvr key. The next reconciliation hashes the new payload and compares against the cache; if they match, no SSA call goes out — the resource counts as skipped in the per-pass log line.

A render where the chart re-emits 100 unchanged Services costs:

  • 100 json.Marshal + sha256.Sum256 calls (in-memory)
  • 0 API calls

A render where 1 Service changed costs:

  • 100 hashes
  • 1 SSA call

The cache is cleared on BecameLeaderEvent because a previous leader's checksums aren't trustworthy for the new one — the API server's last-applied managed fields reflect their applies, not ours. First reconciliation after a leader change re-applies everything once; subsequent reconciliations dedupe normally.

Orphan pruning

Each successful pass populates an in-memory lastAppliedKeys map 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. Each returned resource is added to lastAppliedKeys.

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 (default for the chart) refuses to apply any resource whose Namespace is empty (cluster-scoped) or differs from OwnNamespace. Refusals are logged with a clear hint about how to opt in.

This is defense in depth on top of the chart's RBAC. The chart binds the controller's ServiceAccount to a namespace-scoped Role, not a ClusterRole, so the API server will reject foreign-namespace applies regardless of what this component sends. The applier-level guard catches the misbehaviour earlier, surfaces it in logs at WARN level (so a misbehaving template is visible), and prevents the failed API call from cluttering audit logs.

To opt into cluster-scoped or cross-namespace provisioning, set RestrictToOwnNamespace=false in Config and grant the appropriate ClusterRole RBAC. Doing only one without the other will break the apply: RBAC denies the request before the in-process guard fires.

Field manager

All applies use field manager haptic (same as statusapplier — both subsystems are part of the same controller and a single field-manager identity is the simplest audit story). With Force=true, the 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(),
    Logger:                 logger,
    OwnNamespace:           ownNamespace,
    RestrictToOwnNamespace: false,
    OwnerRef:               ownerRef,
})
registry.Build().AllReplica(..., resourceApplierComponent).LeaderOnly(...).Done()

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)
  • Checksum dedup (unchanged resources skip the API call)
  • Re-apply on payload change (different port → new checksum → new SSA)
  • Orphan deletion (resource removed from rendered set is deleted)
  • Namespace restriction (foreign + cluster-scoped resources are refused)
  • BecameLeaderEvent clears cache and re-applies
  • LostLeadershipEvent pauses applies
  • Label injection preserves caller's labels and is non-mutating
  • Start() returns cleanly on context cancellation
  • ReconciliationCompletedEvent carries the resources it applies (stateless)

Total coverage: 79.3% of statements.

Documentation

Overview

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

Mirrors statusapplier's leader-only / checksum-cached / event-driven shape exactly — it just operates on full resources rather than status sub-paths. Templates declare desired resources under spec.k8sResources; the renderer renders each and surfaces them on ReconciliationCompletedEvent.RenderedResources; this component applies them on the cluster after the render+validate pipeline succeeds.

The applier is stateless on the success path. RenderedResources travel with the ReconciliationCompletedEvent that triggers the apply — there is no side-channel cache. Patches/resources on a completion event are tautologically the ones for the configuration that completion describes, so no LATEST-vs-completed race is possible (the same contract that statusapplier's CLAUDE.md spells out for StatusPatches).

Resource-agnostic by design: the controller never names a specific resource kind — it just applies whatever the template emits. Templates decide what to emit; the controller is the generic vehicle.

API-traffic safety:

  • SHA-256 checksum cache per (namespace, name, gvr) skips the SSA round- trip when the payload matches the last-applied value.
  • Cache is cleared on BecameLeaderEvent (the previous leader's checksums aren't trustworthy for the new one), forcing a single re-apply burst on leadership transitions but no hammering on steady-state renders.
  • Default RestrictToOwnNamespace=true refuses cross-namespace and cluster-scoped applies; opt-in via config for templates that need to spawn cluster-scoped resources (corresponding ClusterRole RBAC must also be granted).

Orphan pruning: resources that disappear from the rendered set between reconciliations are detected via the in-memory checksum cache (key in cache but not in new render) and deleted. Startup orphans (resources the previous incarnation created and never cleaned up before crashing) require an offline kubectl sweep using the managed-by label this component injects.

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 (cachedResources, checksum cache) 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
	Logger      *slog.Logger

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

	// RestrictToOwnNamespace, when true (default for the chart), refuses
	// to apply any rendered resource whose namespace is empty (cluster-
	// scoped) or differs from OwnNamespace. Combined with the chart's
	// namespace-scoped Role, this gives belt-and-suspenders safety: even
	// a misbehaving template can't escalate beyond the controller's
	// namespace.
	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