rendercontext

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: 21 Imported by: 0

README

pkg/controller/rendercontext

Builds the template rendering context shared by every code path that renders HAProxy config.

Overview

Four call sites need to render templates with the same context shape: production reconciliation (renderer), validation tests (test runner), benchmarks, and the webhook dry-run validator. This package consolidates that construction so the four can't drift — the context map you get back is identical regardless of who built it.

The builder also produces a *FileRegistry (templates can register dynamically generated auxiliary files via this) and supports a StoreWrapper adapter that gives Scriggo templates the List / Fetch / GetSingle methods on top of plain types.Store instances.

Quick Start

import "gitlab.com/haproxy-haptic/haptic/pkg/controller/rendercontext"

builder := rendercontext.NewBuilder(
    cfg,
    pathResolver,
    logger,
    rendercontext.WithStores(storeMap),
    rendercontext.WithHAProxyPodStore(haproxyPodStore),
    rendercontext.WithHTTPFetcher(httpWrapper),
    rendercontext.WithCurrentConfig(parsedCurrent),
)

res := builder.Build()
// res.Context is map[string]any ready to pass to engine.Render
// res.FileRegistry, res.StatusPatchCollector, res.RenderedResourceCollector are also available

cfg, pathResolver, and logger are required positional arguments; everything else is supplied through functional options. Omitting an option just leaves the corresponding context key unset (templates that try to read it will see nil).

Context Keys

The context map produced by Build() carries the keys templates rely on:

Key Type Source
resources map[string]ResourceStore (wrapped) WithStores
controller map[string]ResourceStore containing haproxy_pods WithHAProxyPodStore
templateSnippets []string (sorted) cfg.TemplateSnippets keys
fileRegistry *FileRegistry always present
statusPatchCollector *templating.StatusPatchCollector always present (collects statusPatch() calls from filters_status.go; also returned as Build()'s third value)
pathResolver *templating.PathResolver required
dataplane config.DataplaneConfig from cfg.Dataplane
shared *templating.SharedContext always present (per-render cache)
runtimeEnvironment *templating.RuntimeEnvironment always present (GOMAXPROCS and friends)
currentConfig *parserconfig.StructuredConfig WithCurrentConfig (optional; omitted when nil to dodge a Scriggo nil-pointer-initializer panic)
http templating.HTTPFetcher WithHTTPFetcher (optional)
extraContext map[string]any cfg.TemplatingSettings.ExtraContext (always set, possibly empty; top-level keys are also merged into the root context via MergeExtraContextInto)

Adding a new context key means updating Build() plus the pkg/templating/globals.go declarations (so Scriggo knows the type at compile time).

See Also

License

Apache-2.0 — see root LICENSE.

Documentation

Overview

Package rendercontext provides a centralized builder for template rendering contexts.

This package consolidates the previously duplicated context creation logic from renderer, testrunner, benchmark, and dryrunvalidator into a single, reusable builder.

Usage:

builder := rendercontext.NewBuilder(
    cfg,
    pathResolver,
    logger,
    rendercontext.WithStores(stores),
)
res := builder.Build()
ctx := res.Context

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func BuildResourcesValue

func BuildResourcesValue(
	resourceStores map[string]stores.Store,
	typedTypes map[string]reflect.Type,
	watchedNames []string,
	indexByFor func(name string) []string,
	lazyFor func(name string) bool,
	apiVersionFor func(name string) string,
	logger *slog.Logger,
) any

BuildResourcesValue constructs the typed-struct `resources` runtime value the engine binds at template-run time. The shape matches typebootstrap.BuildEngineDeclarations exactly:

*struct{ <PascalCased-name> *innerStore; … }

with one field per watched resource. The inner store's List / Fetch / GetSingle closures return typed pointers when a generated type is available, untyped any otherwise — the OUTER struct shape stays the same either way, so the runtime value keeps binding cleanly against the engine declaration regardless of whether typebootstrap produced types for any given resource.

Production is fail-closed on typebootstrap failures (Bootstrap aborts iteration startup; helpers.BuildAdditionalDeclarations panics on nil Result), so every production engine declares the typed struct and this function always produces a typed value. There is no map fallback — callers that bypass the typed-engine path (a unit test constructing templating.New(...) directly without BuildAdditionalDeclarations) must build their own map[string]templating.ResourceStore aligned with the engine's default declaration in registerScriggoRuntimeVars; do not call BuildResourcesValue from that path.

Inputs:

  • resourceStores: per-name stores.Store for the resources the local controller has live watchers for. Looked up by name; a watched-resource name with no entry gets a struct field whose closures collapse to empty results. Names in this map that are NOT in watchedNames are silently ignored (e.g. the auto- injected haproxy_pods store, which belongs in controller["haproxy_pods"], not `resources`).
  • typedTypes: per-name generated reflect.Type from typebootstrap. Looked up by name; an unset entry yields an untyped-closure field for that resource. Names not in watchedNames are ignored.
  • watchedNames: every watched-resource name from the config — SOLE iteration source. Must mirror what typebootstrap.BuildEngineDeclarations iterated when it built the engine-side declaration; any field-list drift trips Scriggo's "must have type assignable to struct {...}" bind- time panic.
  • indexByFor: returns the per-resource IndexBy slice the watcher used to build the underlying store; forwarded to the StoreWrapper so per-render snapshot indices align with the live store state.
  • lazyFor: returns whether the per-resource wrapper should use LazySnapshot mode. True when the WatchedResource config has `store: on-demand` (CachedStore-backed) — see StoreWrapper's LazySnapshot field documentation for the semantics. A nil callback or one that always returns false keeps the historical eager-snapshot behaviour for every resource.

func CapabilitiesToMap

func CapabilitiesToMap(caps *dataplane.Capabilities) map[string]any

CapabilitiesToMap converts a Capabilities struct to a template-friendly map. The map uses snake_case keys matching the Capabilities struct field names (e.g., "supports_waf" for SupportsWAF) for consistency with template conventions.

func MergeAuxiliaryFiles

func MergeAuxiliaryFiles(static, dynamic *dataplane.AuxiliaryFiles) *dataplane.AuxiliaryFiles

MergeAuxiliaryFiles merges two AuxiliaryFiles structures, with dynamic files appended to static files.

This is used to combine:

  • Pre-declared templates (maps, files, certs from config)
  • Dynamically registered files (via FileRegistry during rendering).

func MergeExtraContextInto

func MergeExtraContextInto(renderCtx map[string]any, cfg *config.Config)

MergeExtraContextInto merges the extraContext variables from the config into the provided template context.

This allows templates to access custom variables directly (e.g., {{ debug.enabled }}) instead of wrapping them in a "config" object.

The extraContext key is always populated (with an empty map if nil) to prevent nil pointer dereferences in templates that use extraContext | dig("key") | fallback(default).

func PathResolverFromValidationPaths

func PathResolverFromValidationPaths(vp *dataplane.ValidationPaths) *templating.PathResolver

PathResolverFromValidationPaths creates a PathResolver from ValidationPaths.

This is a convenience function for creating PathResolvers in contexts where ValidationPaths is available (testrunner, dryrunvalidator, benchmark). The TempDir from ValidationPaths becomes the BaseDir used with "default-path origin" in HAProxy's global section to resolve relative paths.

func SeparateHAProxyPodStore

func SeparateHAProxyPodStore(storeMap map[string]stores.Store) (resourceStores map[string]stores.Store, haproxyPodStore stores.Store)

SeparateHAProxyPodStore separates the haproxy-pods store from resource stores.

This is needed because haproxy-pods goes in the controller namespace (controller.haproxy_pods), not in the resources namespace. This function extracts haproxy-pods and returns both the filtered resource stores and the haproxy pod store separately.

Returns:

  • resourceStores: All stores except haproxy-pods
  • haproxyPodStore: The haproxy-pods store, or nil if not present

func SortSnippetNames

func SortSnippetNames(snippets map[string]config.TemplateSnippet) []string

SortSnippetNames sorts template snippet names alphabetically. Returns a slice of snippet names in sorted order.

Snippet ordering is controlled by encoding priority in the snippet name (e.g., "features-050-ssl" for priority 50). This is required because render_glob sorts templates alphabetically.

Types

type BuildResult

type BuildResult struct {
	Context                   map[string]any
	FileRegistry              *FileRegistry
	StatusPatchCollector      *templating.StatusPatchCollector
	RenderedResourceCollector *templating.RenderedResourceCollector
}

BuildResult is the bundle returned from Build(). Callers that only need a subset (e.g. just the context map for a benchmark or test fixture) read the relevant field; the unused collectors are then garbage-collected with the result struct.

type Builder

type Builder struct {
	// contains filtered or unexported fields
}

Builder constructs template rendering contexts with consistent structure. Use NewBuilder() to create a builder and functional options to configure it.

func NewBuilder

func NewBuilder(cfg *config.Config, pathResolver *templating.PathResolver, logger *slog.Logger, opts ...Option) *Builder

NewBuilder creates a new context builder with required dependencies.

Parameters:

  • cfg: Controller configuration (required)
  • pathResolver: Path resolver for file paths (required)
  • logger: Structured logger (required)
  • opts: Optional configuration via functional options

func (*Builder) Build

func (b *Builder) Build() *BuildResult

Build creates the template rendering context, file registry, status patch collector, and rendered resource collector.

The context structure is:

{
  "resources": map of StoreWrappers,
  "controller": {"haproxy_pods": StoreWrapper},
  "templateSnippets": []string,
  "fileRegistry": FileRegistry,
  "statusPatchCollector": StatusPatchCollector,
  "renderedResourceCollector": RenderedResourceCollector,
  "pathResolver": PathResolver,
  "dataplane": Config.Dataplane,
  "capabilities": map[string]any (HAProxy version capabilities),
  "currentConfig": *StructuredConfig (nil on first deployment),
  "shared": map[string]any,
  "runtimeEnvironment": RuntimeEnvironment,
  "http": HTTPFetcher (if set),
  "extraContext": map from config,
}

type FileRegistry

type FileRegistry struct {
	// contains filtered or unexported fields
}

FileRegistry allows templates to dynamically register auxiliary files (certs, maps, general files) during rendering. This is used for cases where file content comes from dynamic sources (e.g., certificates from secrets) rather than pre-declared templates.

Usage in templates:

{% set ca_content = secret.data["ca.crt"] | b64decode %}
{% set ca_path = file_registry.Register("cert", "my-backend-ca.pem", ca_content) %}
server backend:443 ssl ca-file {{ ca_path }} verify required

The Registry method is called on the FileRegistry object in the template rendering context, not as a standalone filter.

func NewFileRegistry

func NewFileRegistry(pathResolver *templating.PathResolver) *FileRegistry

NewFileRegistry creates a new FileRegistry with the given path resolver. The path resolver is used to compute full paths for registered files, ensuring they match the paths used by pathResolver.GetPath() method.

func (*FileRegistry) GetFiles

func (r *FileRegistry) GetFiles() *dataplane.AuxiliaryFiles

GetFiles converts all registered files to dataplane AuxiliaryFiles structure. This is called by the renderer after template rendering completes to merge dynamic files with pre-declared auxiliary files.

func (*FileRegistry) Register

func (r *FileRegistry) Register(args ...any) (string, error)

Register registers a new auxiliary file to be created and returns its predicted path. This method is called from templates as file_registry.Register(type, filename, content).

Parameters:

  • fileType: "cert", "map", "file", or "crt-list"
  • filename: Base filename (e.g., "ca.pem", "domains.map", "certificate-list.txt")
  • content: File content as a string

Returns:

  • Predicted absolute path where the file will be located
  • Error if validation fails or content conflict detected

Conflict Detection:

  • If the same filename is registered multiple times with different content, returns error
  • If the same filename is registered with identical content, no error (idempotent)

type Option

type Option func(*Builder)

Option configures a Builder.

func WithCapabilities

func WithCapabilities(caps dataplane.Capabilities) Option

WithCapabilities sets the HAProxy version capabilities exposed to templates under the top-level "capabilities" key. The production renderer always injects this key, so validation and benchmark contexts must too — otherwise a template branching on `capabilities.supports_crt_list` (and similar) behaves differently between `controller validate` and production, defeating the purpose of pre-flight validation. When unset, Build() still populates "capabilities" with an all-false map (zero-value Capabilities), matching a no-capability HAProxy rather than omitting the key.

func WithCurrentConfig

func WithCurrentConfig(cfg *parserconfig.StructuredConfig) Option

WithCurrentConfig sets the current deployed HAProxy config for templates. This enables slot-aware server assignment and other config-aware features. The config is parsed from the HAProxyCfg CRD's spec.content field. If nil, templates receive nil currentConfig (first deployment case).

func WithHAProxyPodStore

func WithHAProxyPodStore(store stores.Store) Option

WithHAProxyPodStore sets the HAProxy pod store for controller.haproxy_pods. This enables templates to access HAProxy pod count for calculations.

func WithHTTPFetcher

func WithHTTPFetcher(fetcher templating.HTTPFetcher) Option

WithHTTPFetcher sets the HTTP fetcher for http.Fetch() calls in templates. Pass nil to disable HTTP fetching capability.

func WithStores

func WithStores(storeMap map[string]stores.Store) Option

WithStores sets the resource stores for the template context. Each store is wrapped in a StoreWrapper to provide template-friendly methods.

func WithTypedResources

func WithTypedResources(types map[string]reflect.Type) Option

WithTypedResources supplies the per-resource generated Go types produced by typebootstrap (pkg/controller/typebootstrap). When set, Build emits one *additional* top-level context entry per supplied type: the resource's name maps to a *[]*<generated-struct> value populated by wrapping each item of the matching store's snapshot through typegen.WrapInto.

The typed entries coexist with the existing map-keyed resources["<name>"] access — chart templates can adopt the typed shape per snippet without breaking templates that still use the untyped path. The two access paths may load their snapshots a few microseconds apart; templates are expected to use ONE shape or the OTHER for a given resource within a single render (mixing wouldn't compile anyway — the untyped variable is `any`).

Resources whose names appear in `types` but for which no store is registered (via WithStores) are silently skipped. That keeps the option safe to use even when typebootstrap successfully generated a type for a resource the local controller doesn't happen to watch — a common case in tests.

type StoreWrapper

type StoreWrapper struct {
	Store        stores.Store
	ResourceType string
	Logger       *slog.Logger

	// IndexBy mirrors the JSONPath expressions the underlying store uses
	// to index resources. Required for snapshot-served Fetch/GetSingle.
	IndexBy []string

	// LazySnapshot defers the eager Store.List() until List() is
	// actually called. Set when the underlying store is a CachedStore
	// (WatchedResources[name].Store == "on-demand"). See type doc.
	LazySnapshot bool
	// contains filtered or unexported fields
}

StoreWrapper wraps a stores.Store to provide template-friendly methods (no error returns; errors are logged) AND pins a single per-render snapshot of the underlying store so every read in one render — List(), Fetch(), or GetSingle() — observes the same state.

Why per-render pinning matters: the live informer-backed store mutates during admission validation (a parallel test's Ingress lands in the store between two snippet executions), so two raw List() calls in one render can return different snapshots, and a List() vs Fetch() pair on the same resource type can disagree about what's in the store. The chart's auth pattern hit exactly the first variant — global-top emits userlists from one List() snapshot, backend-directives emits http_auth(...) refs from a later (mutated) List() snapshot, leaving the rendered config with an http_auth pointing at a userlist that no snippet emitted. HAProxy then rejects the config at admission time.

On first access we call Store.List() once and build an in-memory composite-key index using the configured IndexBy JSONPath expressions. List() returns the snapshot; Fetch()/GetSingle() resolve against the in-memory index, with the same exact-match-vs-prefix-scan semantics MemoryStore.Get(...) offers. The wrapper is constructed fresh per Render() in rendercontext.Builder, so the cache lifetime is naturally one render.

LazySnapshot mode (CachedStore-backed resources, typically Secrets): the eager Store.List() defeats the whole point of CachedStore — listing a CachedStore fans out into one API fetch per cached reference (see pkg/k8s/store/cached.go's "Listing cached store causes individual API lookups" WARN). For wrappers whose WatchedResources[name].Store == "on-demand", set LazySnapshot=true:

  1. Snapshot is primed at first access from the underlying store's CachedList() (only the LRU's warm entries — no API fetches). If the store doesn't expose CachedList(), the snapshot starts empty.
  2. Fetch/GetSingle look up the snapshot index first. On miss they call Store.Get(stringKeys...) for that single key, add the result back into the snapshot + index, and return it. The snapshot grows as the render touches keys; a key looked up twice in the same render costs at most one API fetch (LRU warm thereafter).
  3. List() returns the snapshot as-is — the partial set the render has assembled. No surprise full-cluster fetch, no warning. Operators who set `store: on-demand` are opting out of full-cluster iteration; templates that need to scan every instance of a kind should use the default `store: full`.

Per-render consistency: a key looked up via Fetch/GetSingle and then iterated via List() returns the same value (both served from the snapshot). The narrow weakening vs eager mode is that List() doesn't include uncached items the render never asked for — for the canonical "many Secrets, only a few touched" use case (Secrets is the whole reason `store: on-demand` exists), that's the contract, not a bug.

If IndexBy is empty (e.g., a wrapper constructed for a store whose indexing config wasn't passed through), we still snapshot for List() but fall back to Store.Get(...) for keyed lookups — and warn, because that path can't honor the cross-method coherence guarantee.

Resources in stores are already converted (floats to ints) at storage time, so StoreWrapper passes data through without additional processing.

func (*StoreWrapper) Fetch

func (w *StoreWrapper) Fetch(keys ...any) []any

Fetch performs O(1) indexed lookup over the per-render snapshot using the provided keys.

This method enables efficient lookups in templates and supports non-unique index keys by returning all resources matching the provided keys:

{% for endpoint_slice in resources.endpoints.Fetch(service_name) %}
  {{ endpoint_slice.metadata.name }}
{% endfor %}

The keys must match the index configuration for the resource type. For example, if EndpointSlices are indexed by service name:

index_by: ["metadata.labels['kubernetes.io/service-name']"]

Then you can look them up with:

resources.endpoints.Fetch("my-service")

This will return ALL EndpointSlices for that service (typically multiple).

Accepts any arguments for template compatibility.

If an error occurs during snapshot loading, it's logged and an empty slice is returned.

func (*StoreWrapper) GetSingle

func (w *StoreWrapper) GetSingle(keys ...any) any

GetSingle performs O(1) indexed lookup over the per-render snapshot and expects exactly one matching resource.

This method is useful when you know the index keys uniquely identify a resource:

{% set ingress = resources.ingresses.GetSingle("default", "my-ingress") %}
{% if ingress %}
  {{ ingress.metadata.name }}
{% endif %}

{# Cross-namespace reference #}
{% set ref = "namespace/name".split("/") %}
{% set secret = resources.secrets.GetSingle(ref[0], ref[1]) %}

Accepts any arguments for template compatibility.

Returns:

  • nil if no resources match (this is NOT an error - allows templates to check existence)
  • The single matching resource if exactly one matches
  • nil + logs error if multiple resources match (ambiguous lookup)

If an error occurs during snapshot loading, it's logged and nil is returned.

func (*StoreWrapper) List

func (w *StoreWrapper) List() []any

List returns all resources from the per-render snapshot.

First call lazily loads the snapshot. In eager mode the snapshot covers everything in the underlying store. In lazy mode it covers (initial: LRU-warm entries) + (incrementally: any keys touched via Fetch/GetSingle during this render). Subsequent calls return the same slice — every read in one render observes the same view, and keys looked up after List() will GROW the snapshot but won't change what earlier List() snippets already saw (sliced references are stable; new entries land past the original length).

Jump to

Keyboard shortcuts

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