⚠️ Work in Progress
You are viewing development documentation built from the latest commit on main. APIs and features are subject to change.

Architecture

Kure Architecture Documentation

Executive Summary

Kure is a GitOps domain model and layout engine on a thin Kubernetes foundation. The domain model (pkg/stack) says what a cluster contains; the layout engine (pkg/stack/layout, pkg/stack/fluxcd) writes that out as a reconcilable directory tree of plain YAML; the Kubernetes foundation (pkg/kubernetes) supplies the scheme, one generic constructor, the generated per-kind wrappers over it, and a small admissible set of sugar helpers.

The foundation is thin on purpose. The upstream Go struct is the construction API — kure sets identity and stops — so the library’s surface stays small enough to keep stable while the domain model above it carries the opinions.

Key Architectural Achievements:

  • Domain-Driven Design: Hierarchical cluster model with clear boundaries
  • Interface Segregation: Split monolithic workflow interfaces into focused components
  • Thin Kubernetes foundation: Identity-only constructors, generated from the registered scheme, under a contract that is asserted by tests rather than described by convention (Kubernetes Builders is normative)
  • Derived, not hand-kept: Which kinds exist, their scope, and which of their fields are feature-gated or deprecated are all read out of the pinned upstream modules
  • GitOps Agnostic: Support for multiple GitOps tools through pluggable workflows
  • Declarative Patching: JSONPath-based patching system with structure preservation (moved to go-kure/launcher)

The architecture supports complex Kubernetes cluster configurations while maintaining simplicity and extensibility through clean separation of concerns and well-defined interfaces.


Table of Contents

  1. Architecture Overview
  2. Domain Model Architecture
  3. Workflow Architecture
  4. Error Handling Architecture
  5. Kubernetes Foundation
  6. Patch System Architecture
  7. Layout and Packaging
  8. Naming Conventions
  9. Developer Guidelines
  10. Performance Characteristics
  11. Security Model
  12. Testing Architecture
  13. Appendices

Architecture Overview

System Boundaries

Kure operates within the Kubernetes ecosystem as a library for programmatic resource generation:

graph TB
    subgraph "Kure Library"
        DM[Domain Model]
        WF[Workflow Engines]
        RB[Kubernetes Foundation]
        LO[Layout Engine]
    end
    
    subgraph "GitOps Tools"
        FLUX[Flux]
        ARGO[ArgoCD]
    end
    
    subgraph "Kubernetes"
        K8S[Core Resources]
        CRD[Custom Resources]
    end
    
    USER[User Code] --> DM
    DM --> WF
    WF --> RB
    RB --> K8S
    RB --> CRD
    WF --> LO
    LO --> FLUX
    LO --> ARGO
    PS --> K8S

Core Components

The system is organized around four primary architectural layers:

  1. Domain Model (pkg/stack/): Hierarchical abstractions for cluster configuration
  2. Workflow Engines (pkg/stack/workflow.go, pkg/stack/fluxcd/, pkg/stack/argocd/): GitOps-specific implementations
  3. Kubernetes Foundation (pkg/kubernetes/ and its per-CRD subpackages): the registered scheme, the generic Create[T] constructor and its generated per-kind wrappers, the admissible sugar helpers, and the generated kinds/scope/maturity tables
  4. Support Systems: Error handling, layout, and I/O utilities

What kure does NOT provide

kure is an unopinionated foundation. It provides building blocks — it does not compose them into named application patterns or OAM abstractions.

Not in kureReason
Application-level components (webservice, worker, helmrelease)Downstream consumers have different opinions on what each means
Trait logic (ingress, certificate, external-secret)Trait implementation depends on platform capabilities
OAM model (Application, Component, Trait, Policy)This belongs in the OAM runtime layer (launcher)
Policy enforcementEnforcement rules are organizational — not a library concern
GitOps delivery layout decisionsEach consumer defines its own OCI artifact hierarchy

Why this matters for library design. If kure defined a WebserviceConfig, it would need to decide: does it include a ServiceAccount? Topology spread constraints? Sidecars? Each downstream consumer has different answers. Putting the composed abstraction in the library couples all consumers to kure’s version of that answer. kure avoids this by providing composable primitives and leaving composition to consumers.

Key Design Principles

1. Composition Over Inheritance

  • Domain objects compose behavior through interfaces
  • Workflow engines compose specialized generators
  • Resources built through functional composition

2. Interface Segregation

  • Small, focused interfaces for specific concerns
  • Workflow interfaces split by responsibility
  • Clear separation between resource generation and layout

3. Immutable Constructs

  • Builder pattern creates immutable objects
  • Patching creates new instances rather than modifying
  • Functional approach to resource transformation

4. Type Safety

  • Strong typing for all Kubernetes resources
  • Compile-time validation of resource construction
  • Custom error types with contextual information

Relationship to Launcher

launcher is an OAM-native package manager built on kure. The dependency is strictly one-directional: launcher imports kure; kure has no dependency on launcher.

downstream consumers
        │
        ▼
   launcher (OAM runtime)
        │
        ▼
    kure (library)

kure provides the building blocks — ApplicationConfig interface, K8s resource builders, FluxCD workflow primitives. Launcher uses these to implement an OAM-to-manifest pipeline with component handlers, trait handlers, and a Policy extension point for downstream enforcement.

Downstream consumers that need capabilities beyond launcher’s built-in handlers can register additional handlers and implement the launcher.Policy interface, while still using kure’s resource builders directly.

For the full layering model see kure-launcher-architecture .


Domain Model Architecture

Hierarchical Structure

The domain model follows a four-tier hierarchy designed to mirror real-world Kubernetes cluster organization:

Cluster
└── Node (Infrastructure/Applications)
    └── Bundle (Logical grouping)
        └── Application (Individual workloads)

Cluster (pkg/stack/cluster.go)

The root abstraction representing a complete Kubernetes cluster configuration:

type Cluster struct {
    Name   string        `yaml:"name"`
    Node   *Node         `yaml:"node,omitempty"`
    GitOps *GitOpsConfig `yaml:"gitops,omitempty"`
}

Design Decisions:

  • Single root node simplifies tree traversal
  • GitOps configuration at cluster level for global policies
  • Name field provides unique identification across environments

Node (pkg/stack/cluster.go)

Hierarchical containers for organizing related bundles:

type Node struct {
    Name       string                    `yaml:"name"`
    ParentPath string                    `yaml:"parentPath,omitempty"`
    Children   []*Node                   `yaml:"children,omitempty"`
    PackageRef *schema.GroupVersionKind  `yaml:"packageref,omitempty"`
    Bundle     *Bundle                   `yaml:"bundle,omitempty"`
    
    // Runtime fields (not serialized)
    parent   *Node            `yaml:"-"`
    pathMap  map[string]*Node `yaml:"-"`
}

Anti-Circular Reference Design:

  • ParentPath string instead of direct parent pointer in serialized form
  • Runtime parent field populated during InitializePathMap()
  • Enables serialization while maintaining navigation efficiency

Bundle (pkg/stack/bundle.go)

Deployment units typically corresponding to single GitOps resources:

type Bundle struct {
    Name              string
    KustomizationName string
    DirName           string
    ParentPath        string
    DependsOn         []*Bundle
    NamedDependsOn    []string
    Children          []*Bundle
    Interval          string
    SourceRef         *SourceRef
    Applications      []*Application
    // ... labels, annotations and the Flux reconciliation settings
}

Application (pkg/stack/application.go)

Individual Kubernetes workloads or resource collections. An application holds no resources of its own: its Config generates them when the tree is walked.

type Application struct {
    Name      string
    Namespace string
    Config    ApplicationConfig
}

type ApplicationConfig interface {
    Generate(*Application) ([]*client.Object, error)
}

Hierarchy Navigation

The domain model implements efficient tree traversal through a dual approach:

1. Path-Based Navigation

func (n *Node) GetPath() string {
    if n.ParentPath == "" {
        return n.Name
    }
    return n.ParentPath + "/" + n.Name
}

2. Runtime Parent References

func (n *Node) InitializePathMap() {
    pathMap := make(map[string]*Node)
    n.buildPathMap(pathMap, "")
    n.setPathMapRecursive(pathMap)
}

This design enables:

  • Efficient serialization without circular references
  • Fast runtime navigation through cached parent pointers
  • Path-based lookups for configuration references

Workflow Architecture

Interface Segregation Pattern

The workflow architecture applies the Interface Segregation Principle: the abstraction every engine implements is one small interface, and each engine is composed of focused components (see FluxCD Implementation ). pkg/stack cannot import pkg/stack/layout, which imports it, so the layout types cross the interface as two narrow interfaces:

// pkg/stack/workflow.go

type LayoutRulesProvider interface { // implemented by layout.LayoutRules
    Validate() error
}

type ManifestLayoutResult interface { // implemented by *layout.ManifestLayout
    WriteToDisk(basePath string) error
    FullRepoPath() string
}

type Workflow interface {
    GenerateFromCluster(*Cluster, LayoutRulesProvider) ([]client.Object, error)
    CreateLayoutWithResources(*Cluster, LayoutRulesProvider) (ManifestLayoutResult, error)
    GenerateBootstrap(*BootstrapConfig, *Node, LayoutRulesProvider) ([]client.Object, error)
}

FluxCD Implementation

The FluxCD workflow engine demonstrates the composition pattern:

// pkg/stack/fluxcd/workflow_engine.go

type WorkflowEngine struct {
    ResourceGen  *ResourceGenerator   // Pure resource generation
    LayoutInteg  *LayoutIntegrator   // Layout integration
    BootstrapGen *BootstrapGenerator // Bootstrap concerns
}

func NewWorkflowEngine() *WorkflowEngine {
    resourceGen := NewResourceGenerator()
    layoutInteg := NewLayoutIntegrator(resourceGen)
    bootstrapGen := NewBootstrapGenerator()
    
    return &WorkflowEngine{
        ResourceGen:  resourceGen,
        LayoutInteg:  layoutInteg,
        BootstrapGen: bootstrapGen,
    }
}

Component Responsibilities

ResourceGenerator (pkg/stack/fluxcd/resource_generator.go)

  • Pure resource generation from domain objects
  • Kustomization creation with proper source references
  • Dependency management between bundles
  • No layout or file system concerns

LayoutIntegrator (pkg/stack/fluxcd/layout_integrator.go)

  • Integration with manifest layout system
  • Directory structure generation
  • File placement policies
  • GitOps-specific layout requirements

BootstrapGenerator (pkg/stack/fluxcd/bootstrap_generator.go)

  • Bootstrap resource generation
  • GitOps system initialization
  • Mode-specific configurations (gitops-toolkit vs flux-operator)

Extensibility Pattern

Adding new GitOps workflows follows a clear pattern:

  1. Implement stack.Workflow (pkg/stack/workflow.go): GenerateFromCluster, CreateLayoutWithResources and GenerateBootstrap. The Flux engine composes its implementation from the ResourceGenerator, LayoutIntegrator and BootstrapGenerator structs above; those are fluxcd types, not interfaces a new workflow implements.
  2. Register it: the package’s init registers a factory so that stack.NewWorkflow can return the workflow, as pkg/stack/fluxcd does with stack.RegisterFluxWorkflow and pkg/stack/argocd with stack.RegisterArgoWorkflow. NewWorkflow resolves only the flux and argocd providers, so a new provider name also needs its own case and register function there.
  3. Register with Layout: Add layout rules for the new workflow
  4. Provide Public API: Create user-facing convenience functions

Error Handling Architecture

KureError System

Kure implements a sophisticated error handling system based on typed errors with contextual information:

// pkg/errors/errors.go

type KureError interface {
    error
    Type() ErrorType
    Suggestion() string
    Context() map[string]any
}

type ErrorType string

const (
    ErrorTypeValidation    ErrorType = "validation"
    ErrorTypeResource      ErrorType = "resource"
    ErrorTypePatch         ErrorType = "patch"
    ErrorTypeParse         ErrorType = "parse"
    ErrorTypeFile          ErrorType = "file"
    ErrorTypeConfiguration ErrorType = "configuration"
    ErrorTypeInternal      ErrorType = "internal"
    ErrorTypePSA           ErrorType = "psa"
)

Error Type Architecture

All four are declared in pkg/errors/errors.go.

ValidationError (errors.ValidationError)

  • Field-level validation failures
  • Provides valid value suggestions
  • Component context for debugging

ResourceError (errors.ResourceError)

  • Resource-specific errors (not found, validation failed)
  • Includes resource type, name, and namespace
  • Lists available alternatives when applicable

PatchError (errors.PatchError)

  • Patch operation failures
  • Path and operation context
  • Graceful degradation suggestions

ParseError (errors.ParseError)

  • File parsing errors with location information
  • Line and column numbers
  • Format-specific help suggestions

Error Wrapping Strategy

Kure follows Go’s error wrapping conventions while adding structured context. The method below illustrates the convention; the Flux engine’s own GenerateFromCluster checks that the rules are a layout.LayoutRules value and delegates to its ResourceGen, whose errors it returns unchanged:

func (we *WorkflowEngine) GenerateFromCluster(c *stack.Cluster, rules stack.LayoutRulesProvider) ([]client.Object, error) {
    if c == nil {
        return nil, errors.ResourceValidationError("Cluster", "", "cluster", 
                                                   "cluster cannot be nil", nil)
    }
    layoutRules, ok := rules.(layout.LayoutRules)
    if !ok {
        return nil, errors.New("rules must be of type layout.LayoutRules")
    }
    
    resources, err := we.ResourceGen.GenerateFromCluster(c, layoutRules)
    if err != nil {
        return nil, errors.Wrapf(err, "failed to generate resources for cluster %s", c.Name)
    }
    
    return resources, nil
}

Kubernetes Foundation

The Kubernetes Builders page is the normative text of the builder contract (ADR-038, “thin core + admissible sugar”). This section describes how that contract sits in the architecture; where the two differ, that page wins.

The canonical path

For every registered kind the upstream Go struct is the construction API. kure allocates the object, stamps TypeMeta from the registered scheme and writes metadata.name (and metadata.namespace for a namespaced kind). Everything else is a field on the upstream type, assigned by the caller:

d := kubernetes.CreateDeployment("web", "default")
d.Spec.Replicas = ptr.To[int32](3)
d.Spec.Template.Spec.ServiceAccountName = "web"
fmt.Println(d.Kind, *d.Spec.Replicas, d.Spec.Template.Spec.ServiceAccountName)

That block is ExampleCreateDeployment in pkg/kubernetes, generated from it, and go test runs it. So are the blocks of Security Model (Example_architectureSecrets, Example_architectureSecretReference and Example_architectureCertificate in pkg/kubernetes/certmanager, Example_architectureRBAC in pkg/kubernetes) and the ValidateCluster block under Input Validation (Example_architectureValidate in pkg/stack); each adds a fmt.Println of what it built. Every other Go block in this document is marked as an excerpt — a declaration or source excerpt, a signature list, a test template, or a sketch of code that does not exist yet — and is not compiled.

A helper whose entire body assigns one argument to one value-typed field is that assignment written twice, and the admission test rejects it. A pointer-typed field is the pointer/nil-init class below, decided on the field rather than on how deep in the spec it sits: Spec itself is *api.Rule on both Cilium policy kinds, so SetCiliumNetworkPolicySpec and SetCiliumClusterwideNetworkPolicySpec (pkg/kubernetes/cilium/update.go) are admissible whole-spec setters and ship. They are the only two in the tree — a value-typed Spec is the caller’s own obj.Spec = spec.

Implementation Structure

The foundation is one package plus one package per CRD family, all under pkg/kubernetes/:

pkg/kubernetes/
├── doc.go                     # Package documentation
├── create.go                  # Create[T]: the single constructor implementation
├── scheme.go                  # The registered scheme every lookup goes through
├── zz_generated_create.go     # Per-kind Create<Kind> wrappers (generated)
├── zz_generated_tables.go     # Kinds, scope and field maturity (generated)
├── podspec.go                 # Admissible sugar for corev1.PodSpec
├── admission_test.go          # go/ast check that every exported helper is admissible
├── certmanager/ cilium/ cnpg/ …   # one subpackage per CRD family, same shape
└── internal/                  # kinds, markers, upstream, crds, maturity, gen, admission

The internal/ tree is where derivation lives: it reads the pinned upstream module sources and their CustomResourceDefinition manifests, and the generator writes both the wrappers and the tables from what it finds. Nothing under internal/ is importable by a consumer.

Three classes of sugar, and one test that enforces them

A helper survives only if the write it performs falls into one of three classes. The classes are about the shape of the write, not about whether a caller could have written it by hand — a pointer field the caller already holds a pointer to is a one-line assignment, and a helper for it is still admitted:

ClassWhat it doesExample
AppenderAppends to a slice field, or inserts into a map field, creating either if nilAddPodSpecContainer, AddConfigMapData
Pointer / nil-initAssigns a pointer-typed field, or initialises a nil pointer intermediate before writing through itSetDeploymentReplicas
CompositeWrites several fields that belong together, and names the opinionSetHPAMinMaxReplicas, AddHPACPUMetric

pkg/kubernetes/admission_test.go parses every exported Set*/Add* in the tree with go/ast and fails on one that fits none of the three, with one exception that is four names rather than a rule: SetLabels, AddLabel, SetAnnotations and AddAnnotation write through the metav1.Object interface rather than into a field, so they match no write shape and the classifier admits them by name as a fourth outcome (Exempt). That is what lets one metadata helper set cover every kind, including kinds kure does not register; the normative statement is Kubernetes Builders §5. It is a fixed set, not an exclusion list — it never grows.

Constructors are outside the test’s remit — Create* is not sugar, and the generator is what keeps those honest. The exclusion list the test once carried is empty and stays empty, so the classes are enforced rather than merely documented.

Type Safety Guarantees

  1. Strong Return Types: All constructors return specific Kubernetes types
  2. Void Helpers: Sugar helpers return nothing and panic on a nil receiver — a nil object is a programming error, not a runtime condition, so there is no error to thread through a caller
  3. Purity: A helper writes the field its name states and no other. A helper that also clears a sibling field either says so in its name or does not exist

Example implementation:

// pkg/kubernetes/zz_generated_create.go (generated from the scheme)

// CreateDeployment returns an apps/v1 Deployment carrying TypeMeta and identity only.
func CreateDeployment(name, namespace string) *appsv1.Deployment {
    return Create[appsv1.Deployment](name, namespace)
}

// pkg/kubernetes/podspec.go (admissible sugar: slice append)

func AddPodSpecContainer(spec *corev1.PodSpec, container *corev1.Container) {
    if spec == nil {
        panic("AddPodSpecContainer: spec must not be nil")
    }
    if container == nil {
        panic("AddPodSpecContainer: container must not be nil")
    }
    spec.Containers = append(spec.Containers, *container)
}

There is one helper per pod-template field, on corev1.PodSpec, not one per workload kind: a Deployment’s containers are appended with AddPodSpecContainer(&dep.Spec.Template.Spec, c).

Constructors emit identity only (apiVersion, kind, metadata.name, metadata.namespace); labels, selectors and every other value are written by the caller on the upstream struct. The builder contract on the Kubernetes Builders page is normative.

Where scope and maturity come from

Which kinds exist, whether each is namespaced or cluster-scoped, and which of their fields are feature-gated or deprecated are not maintained by hand. pkg/kubernetes/internal reads the pinned upstream module sources — the +kubebuilder:resource markers a CRD family ships, the CustomResourceDefinition manifests in the module, and the doc comments on the API types — and the generator writes zz_generated_tables.go and docs/api-tables.{json,md} from what it found. Every row names the module and version it was read from.

Two consequences worth stating. A dependency bump changes the tables, so scripts/gen-builders.sh check fails in CI until the regenerated files are committed; Renovate runs the generator itself after a Go module bump. And a kind whose scope no source can state is an error at generation time, not a namespaced default — a wrong scope is silent in YAML and loud in a cluster.

Cross-Resource Consistency

All builders maintain consistency through:

  • Void Returns: Sugar helpers return nothing and panic on nil, uniformly across every package
  • Standard Patterns: Uniform function naming across resource types
  • Two admission tests: pkg/kubernetes/admission_test.go classifies every Set*/Add* body under pkg/kubernetes/..., and pkg/kubernetes/admission_params_test.go fails on any exported function there that takes a struct or interface kure defines itself where the upstream type is the API — so consistency is a test result rather than a review habit

One-of Constraints

Some upstream CRDs encode an exactly-one-of constraint as a struct with multiple optional pointer fields — the user is expected to set exactly one. Examples: cert-manager IssuerConfig (ACME / CA / Vault / SelfSigned / Venafi); VolSync ReplicationSourceSpec (Restic / Rsync / RsyncTLS / Rclone / Syncthing / External). Go’s type system cannot statically express “set exactly one of these fields”, so the constraint is a CRD-level (apply-time) check.

Kure passes that struct through as it is. The upstream one-of is the API: set the arm you want as a pointer (issuer.Spec.Vault = &certv1.VaultIssuer{...}, rs.Spec.Restic = &volsyncv1alpha1.ReplicationSourceResticSpec{...}), and leave the others nil. A pointer setter exists where the contract admits one (SetIssuerACME, SetClusterIssuerCA); every arm is reachable either way.

Until release 2 of the builder contract, pkg/kubernetes/volsync and pkg/kubernetes/certmanager wrapped these one-ofs in a sealed-interface sum type — an interface closed by an unexported marker method, a per-variant Config type per arm, and a type switch in a Kind(&Config) constructor — so that setting two arms was a compile error. The wall cut both ways: a caller could not add a Vault issuer or an Azure DNS solver even by hand, because no type outside the package could satisfy the marker, and the sums only ever covered the arms kure had chosen to mirror (2 of 5 issuer arms, 3 of 9 DNS-01 providers). The sums went with the layer; the release 2 migration notes map each former variant to its upstream arm.


Patch System Architecture

Note (2026-05-15): pkg/patch moved to go-kure/launcher as part of the launcher extraction (ADR-018). See the launcher repository for current patch system documentation.


Layout and Packaging

Layout Architecture

The layout system manages directory structure and manifest organization:

// pkg/stack/layout/manifest.go (abridged: PackageRef, UmbrellaChild and
// DependsOn omitted)
type ManifestLayout struct {
    Name                string
    Namespace           string
    FilePer             FileExportMode
    ApplicationFileMode ApplicationFileMode
    Mode                KustomizationMode
    FluxPlacement       FluxPlacement
    FileNaming          FileNamingMode
    Resources           []client.Object
    Children            []*ManifestLayout
    ExtraFiles          []ExtraFile
    ConfigMapGenerators []ConfigMapGeneratorSpec
}

// pkg/stack/layout/types.go
type LayoutRules struct {
    NodeGrouping        GroupingMode
    BundleGrouping      GroupingMode
    ApplicationGrouping GroupingMode
    FilePer             FileExportMode
    ClusterName         string
    FluxPlacement       FluxPlacement
    FileNaming          FileNamingMode
    FlattenSingleTier   bool
}

A ManifestLayout is one directory (or, in AppFileSingle mode, one file in its Namespace, normally its parent’s directory): its own resources and files, and its child directories. The walkers build the tree from a stack.Cluster according to LayoutRules.

Grouping Strategies

Each of the three grouping axes (NodeGrouping, BundleGrouping, ApplicationGrouping) decides, independently, whether its level gets a directory. GroupByName gives every child node, bundle or application a directory inside its parent’s; GroupFlat renders the level into the layout above it (its resources, child layouts and origins). Umbrella child bundles and augmenter applications always get a directory. For a child node platform with bundle web holding apps api and ui:

Node=ByName, Bundle=ByName, App=ByName   Node=ByName, Bundle=Flat, App=Flat (default)
platform/                                 platform/
└── web/                                  └── (api and ui resources)
    ├── api/
    └── ui/

The root node is the one exception: its directory renders no bundle, so with a flat BundleGrouping its bundle keeps a directory inside it, named after the bundle, by its DirName when it sets one (go-kure/kure#979; “The root node’s bundles” in pkg/stack/layout/README.md).

GitOps Integration

Layout integrates with GitOps tools through specialized placement:

Flux Placement (pkg/stack/layout/config.go)

  • Kustomization resources placed in flux-system namespace
  • Source references use relative paths (./clusters/prod/...)
  • Automatic kustomization.yaml generation

ArgoCD Placement

  • Application resources in argocd namespace
  • Source paths without ./ prefix
  • Manual kustomization.yaml required

Directory Structure Generation

// pkg/stack/layout/walker.go

// One ManifestLayout tree mirroring the node, bundle and application hierarchy.
func WalkCluster(c *stack.Cluster, rules LayoutRules) (*ManifestLayout, error)

// One ManifestLayout tree per PackageRef, keyed by the package's GVK.
func WalkClusterByPackage(c *stack.Cluster, rules LayoutRules) (map[string]*ManifestLayout, error)

Both validate the rules they are given (LayoutRules.Validate: an unknown option value or a ClusterName with a .. path segment is an error) and then the cluster (stack.ValidateCluster), before they apply the grouping axes described above. No entry point that walks validates the rules on its own.


Naming Conventions

Function Naming Standards

Kure follows strict naming conventions based on function purpose:

Constructor Functions

// Go type constructors use New* prefix
func NewCluster(name string, tree *Node) *Cluster
func NewBundle(name string, resources []*Application, labels map[string]string) (*Bundle, error)

// Kubernetes resource factories use descriptive names
func CreateDeployment(name, namespace string) *appsv1.Deployment
func CreateService(name, namespace string) *corev1.Service

Helper Functions

// Adders for collection modifications
func AddPodSpecContainer(spec *corev1.PodSpec, container *corev1.Container)
func AddServicePort(service *corev1.Service, port corev1.ServicePort)

// Setters for field assignments
func SetDeploymentReplicas(deployment *appsv1.Deployment, replicas int32)

A helper is named for the type it mutates, not for the workload kind that embeds it: the pod-template helpers live on PodSpec and are reached through &<workload>.Spec.Template.Spec.

Workflow Functions

// Engine constructors follow New* pattern
func NewWorkflowEngine() *WorkflowEngine
func NewResourceGenerator() *ResourceGenerator

// Public APIs use descriptive names
func Engine() *WorkflowEngine // Default engine

Package Organization Standards

pkg/                          # Public APIs and interfaces
├── stack/                   # Domain model (public)
│   ├── fluxcd/             # FluxCD workflow implementation  
│   ├── argocd/             # ArgoCD workflow implementation
│   └── layout/             # Layout generation utilities
├── stack/workflow.go       # Workflow interfaces (public)
├── errors/                 # Error handling utilities (public)
└── patch/                  # Patch system (public) — moved to go-kure/launcher (ADR-018)

There is no top-level internal/ tree: the kind-specific builders live under pkg/kubernetes/, and the last private builder package, internal/kubernetes, was removed once nothing imported it (issue go-kure/kure#756).

File Naming Patterns

  • Implementation files: {resource_type}.go (e.g., deployment.go, service.go)
  • Test files: {resource_type}_test.go (e.g., deployment_test.go)
  • Documentation: doc.go for package documentation
  • Design documents: DESIGN.md, README.md in relevant packages

Developer Guidelines

Adding Support for a New Kind

You do not write a constructor. Register the kind’s scheme and the constructor is generated.

1. Register the type’s scheme

Add the module’s AddToScheme to the list in pkg/kubernetes/scheme.go. For a kind whose family kure already covers, that is the whole step.

A family kure does not yet cover needs two more things, because the generator has to know which subpackage the wrapper belongs in:

  • the subpackage itself (pkg/kubernetes/<family>/), following the shape of an existing one;
  • a row in packageRoutes (pkg/kubernetes/internal/kinds/kinds.go) mapping the upstream module’s import-path prefix to that subpackage name. An unrouted import path is not a silent miss — the classifier refuses the whole run with no kure package routes import path <path> — but nothing routes a new vendor prefix by default, so the row is part of adding the family rather than a later fix.

2. Regenerate

make gen-builders          # or: ./scripts/gen-builders.sh generate

This writes the Create<Kind> wrapper into zz_generated_create.go, adds the kind to zz_generated_tables.go and to docs/api-tables.{json,md}, and records the scope it derived and what stated it. Commit the generated files; ./scripts/gen-builders.sh check fails CI otherwise.

If the generator reports that Create<Kind> would be generated twice in one package, the scheme now holds two versions of one kind. A wrapper is named after its kind alone, so one of the two goes without: name it, with the reason, in skippedWrappers (pkg/kubernetes/internal/kinds/skips.go) and regenerate. The skipped version stays registered — its manifests parse to their typed object and it keeps its row in the generated tables — and callers build it from the upstream type. An entry that does not resolve such a clash is refused, so the table is not a way to drop a constructor. Moving the wrapper from one version to the other changes the constructor’s return type, which is a breaking change for callers: say so in the PR, and move the family’s Set*/Add* helpers that take the object with it. MetalLB’s BGPPeer is the worked case: metallb.io/v1beta2 has the wrapper, the deprecated metallb.io/v1beta1 is the skipped entry.

If the generator reports that it cannot determine a kind’s scope, that is the intended failure: add the +kubebuilder:resource marker upstream, or ship the CustomResourceDefinition in the module. Do not default it. The built-in table is not a third option here: builtinClusterScoped (pkg/kubernetes/internal/kinds/scope.go) is consulted only for the three modules in builtinModules — k8s.io/api, k8s.io/apiextensions-apiserver and k8s.io/kube-aggregator, whose types carry no markers because the API server defines their scope — so an entry added there for a CRD family’s kind is never read, and the same error comes back. That table is only the right place when the kind you are adding is itself a Kubernetes built-in.

A built-in gets no scope error to remind you: the table’s default for a built-in is namespaced, so a cluster-scoped one that is missing from it is generated with a namespace argument. The only check that moves by itself is the baseline count, which says the number changed and not that the scope is wrong. Registering a group version of k8s.io/api therefore comes with five edits, all in the same change. Check each kind’s scope against the +genclient:nonNamespaced tag on its upstream type, then:

  • add every cluster-scoped kind to builtinClusterScoped;
  • add the same kinds to the frozen fixture in pkg/kubernetes/internal/kinds/scope_test.go, which must agree with the table in both directions;
  • move the baseline counts in pkg/kubernetes/internal/kinds/derived_test.go;
  • add each kind with its scope to the cases of TestRegistered_KnownKindsAndScopes (pkg/kubernetes/internal/kinds/kinds_test.go), the one place that states a scope independently of the table;
  • drop the kind from clusterScopedUnregisteredKinds in pkg/manifest if it was listed there — a test fails until you do.

A built-in whose Go type lives in another k8s.io module (APIService, in k8s.io/kube-aggregator) needs four more, because nothing about that module is known yet:

  • add the module to builtinModules, so builtinClusterScoped is consulted for it and the kind is recorded as a built-in;
  • add its import-path prefix to packageRoutes (pkg/kubernetes/internal/kinds/kinds.go) with an empty family, which routes the constructor to pkg/kubernetes; the generator stops with “no kure package routes import path” until it is there;
  • add the module to the table in internal/kuretest/sources.go as an uncovered built-in; TestEveryKindHasOneSource fails on a module that is not in the table;
  • pin it in the replace block of go.mod at the release of the other k8s.io modules, and import its API package only.

3. Add sugar only in one of the three admitted classes

A helper must be an appender, a pointer/nil-init setter, or a named composite (see Kubernetes Foundation ). Anything else is a bare assignment and admission_test.go will reject it — the four generic metadata helpers it admits by name are a fixed set and not a precedent to argue from:

// pkg/kubernetes/<family>/<kind>.go — appender: append is not a plain assignment
func AddNewKindRule(obj *v1.NewKind, rule v1.Rule) {
    if obj == nil {
        panic("AddNewKindRule: obj must not be nil")
    }
    obj.Spec.Rules = append(obj.Spec.Rules, rule)
}

There is deliberately no SetNewKindProperty(obj, v): the caller writes obj.Spec.Property = v.

4. Test the object, not the setter

Compare the whole object, not a few of its fields. “Identity and nothing else” is a claim about everything the constructor did not write, and a field-by-field check cannot make it: an injected label, a defaulted status or a populated selector all pass a test that only looks at Name, TypeMeta and Spec. Build the object you expect and DeepEqual against it.

// pkg/kubernetes/<family>/<kind>_test.go

func TestCreateNewKind(t *testing.T) {
    got := CreateNewKind("test", "default")

    want := &v1.NewKind{}
    want.GetObjectKind().SetGroupVersionKind(
        schema.GroupVersionKind{Group: "example.com", Version: "v1", Kind: "NewKind"})
    want.SetName("test")
    want.SetNamespace("default")

    if !reflect.DeepEqual(got, want) {
        t.Errorf("constructor emitted more than identity:\n got %#v\nwant %#v", got, want)
    }
}

This form works whatever the kind’s shape: it never names Spec, so it compiles for the built-ins that have no such field, and the generator writes the wrapper’s signature from the scope it derived — so a cluster-scoped kind drops the namespace argument and the matching want.SetNamespace line, and nothing else changes.

pkg/kubernetes/identity_test.go:64-103 already asserts exactly this for every generated wrapper, by constructing want reflectively from the scheme. The per-kind test is a local echo of that, worth writing because it fails with the kind’s name in it rather than as one subtest among hundreds.

The scope is covered by the same comparison rather than by a separate assertion: want carries a namespace only when the kind is namespaced, so a namespace on a cluster-scoped object fails here. That is the failure the whole scope-derivation chain exists to prevent, and it is otherwise invisible until the API server rejects the manifest.

A constructor that starts setting spec values is the regression this contract exists to prevent, and the whole-object comparison is what catches it — along with every other field it might start writing.

5. Map the package, if the family is new

A new pkg/kubernetes/<family>/ is a new public package, and check-doc-sync requires every public package to be mapped exactly once with a README that exists. Skipping this leaves docs-build red after generation has already succeeded, which reads as a generator failure and is not one. So:

  • write pkg/kubernetes/<family>/README.md, following an existing family’s — the kind coverage belongs in the generated table, not in prose;
  • add the package to site/docs-map.yaml, which is the single source of the code-to-docs mapping. The AGENTS reverse-map table and the site nav are generated from it by bash site/scripts/gen-docs-tables.sh; run that and commit its output rather than editing either by hand.

Adding a kind to a family kure already covers needs no new mapping — the package is already mapped, and step 2’s regenerated tables carry the new kind — but it is not free of documentation work. Regeneration inserts a constructor into that package’s zz_generated_create.go, and a newly inserted generated line is never treated as trivial, so the generated kinds table being current does not settle it.

Two different bars apply here and they are not the same height. doc-gate passes once any of the package’s mapped docs changes — the family README, or the guide the map names for it. The repository’s rule is the README and every guide the reverse mapping lists, and for every pkg/kubernetes/<family> that list is [guides/library-usage]. So satisfying the gate with the README alone leaves a required update undone, with CI green. Say what the new kind is for in the family README, and carry it into guides/library-usage wherever that guide enumerates the family’s coverage.

bash site/scripts/gen-docs-tables.sh
bash scripts/check-doc-api-refs.sh
mise run site:check

Extending Domain Model

When extending the core domain model:

1. Maintain Hierarchy Consistency

// Add new domain types following existing patterns
type NewDomainType struct {
    Name       string                `yaml:"name"`
    ParentPath string                `yaml:"parentPath,omitempty"`
    
    // Domain-specific fields
    
    // Runtime navigation (not serialized)
    parent  *ParentType          `yaml:"-"`
    pathMap map[string]*NewType  `yaml:"-"`
}

2. Implement Navigation Methods

func (n *NewDomainType) SetParent(parent *ParentType) {
    n.parent = parent
    if parent == nil {
        n.ParentPath = ""
    } else {
        n.ParentPath = parent.GetPath()
    }
}

func (n *NewDomainType) GetPath() string {
    if n.ParentPath == "" {
        return n.Name
    }
    return n.ParentPath + "/" + n.Name
}

3. Update Workflow Implementations

Ensure all workflow engines handle the new domain type appropriately.

Implementing New GitOps Workflows

To add support for new GitOps tools, implement stack.Workflow and register it (see Extensibility Pattern ). The skeleton below splits the implementation the way pkg/stack/fluxcd does; only the composed engine has to satisfy stack.Workflow.

1. Write a Resource Generator

// pkg/stack/newtool/resource_generator.go
type ResourceGenerator struct {
    // Tool-specific configuration
}

func (rg *ResourceGenerator) GenerateFromCluster(c *stack.Cluster, rules layout.LayoutRules) ([]client.Object, error) {
    // Tool-specific resource generation, from a walk of c with rules
}

// Implement other ResourceGenerator methods

2. Create Layout Integration

// pkg/stack/newtool/layout_integrator.go  
type LayoutIntegrator struct {
    ResourceGen *ResourceGenerator
    // Tool-specific layout configuration
}

func (li *LayoutIntegrator) IntegrateWithLayout(ml *layout.ManifestLayout, c *stack.Cluster, rules layout.LayoutRules) error {
    // Tool-specific layout integration
}

3. Compose Workflow Engine

// pkg/stack/newtool/workflow_engine.go
type WorkflowEngine struct {
    ResourceGen  *ResourceGenerator
    LayoutInteg  *LayoutIntegrator  
    BootstrapGen *BootstrapGenerator
}

func NewWorkflowEngine() *WorkflowEngine {
    // Compose components
}

// Implement stack.Workflow: GenerateFromCluster, CreateLayoutWithResources, GenerateBootstrap

4. Add Public API

// pkg/stack/newtool/newtool.go
func Engine() *WorkflowEngine {
    return NewWorkflowEngine()
}

Testing Patterns

Kure maintains comprehensive test coverage through consistent patterns:

Unit Testing

func TestServiceConstruction(t *testing.T) {
    svc := kubernetes.CreateService("web", "default")

    // Identity and TypeMeta are set; nothing in the spec is.
    // Verify the helpers the caller would reach for next.
    kubernetes.AddServicePort(svc, corev1.ServicePort{Port: 80})
}

func TestClusterValidation(t *testing.T) {
    // Structural rules live in the domain model, not the constructors:
    // exercise stack.ValidateCluster and assert the error it returns.
}

Integration Testing

func TestWorkflowGeneration(t *testing.T) {
    // Create domain model
    cluster := stack.NewCluster("test", rootNode)
    
    // Generate with workflow
    engine := fluxcd.Engine()
    resources, err := engine.GenerateFromCluster(cluster, layout.DefaultLayoutRules())
    
    // Validate generated resources
    // Test layout integration
}

Performance Characteristics

Resource Generation Performance

Kure is optimized for batch resource generation rather than individual operations:

Benchmarks (typical 100-node cluster):

  • Domain model creation: ~1ms
  • Resource generation: ~10ms
  • Layout generation: ~5ms
  • YAML serialization: ~15ms

Memory Usage:

  • Domain model: ~100KB per 100 resources
  • Generated resources: ~1MB per 1000 resources
  • Layout structures: ~50KB per cluster

Optimization Strategies

1. Lazy Initialization

func (n *Node) InitializePathMap() {
    // Only build path map when needed
    if n.pathMap == nil {
        pathMap := make(map[string]*Node)
        n.buildPathMap(pathMap, "")
        n.setPathMapRecursive(pathMap)
    }
}

2. Batch Operations

func (we *WorkflowEngine) GenerateFromCluster(c *stack.Cluster, rules stack.LayoutRulesProvider) ([]client.Object, error) {
    // Generate all resources in single pass
    // Minimize allocation overhead
}

Bottlenecks and Mitigations

Known Bottlenecks:

  1. YAML serialization (mitigated by streaming output)
  2. Path resolution in complex hierarchies (mitigated by path caching)
  3. Resource generation overhead (mitigated by void builder functions)

Scaling Characteristics:

  • Linear scaling with number of resources
  • Logarithmic scaling with hierarchy depth
  • Constant memory overhead per resource type

Security Model

Secret Management

Kure follows Kubernetes security best practices for secret handling:

1. No Hardcoded Secrets

// NEVER do this - a literal secret in the program that generates the manifests
// is a literal secret in the Git repository those manifests are committed to.
secret := kubernetes.CreateSecret("db-credentials", "default")
secret.Data = map[string][]byte{"password": []byte("hunter2")} // WRONG

// CORRECT approach - name the secret, let the cluster hold the value
cert := certmanager.CreateCertificate("tls-cert", "default")
cert.Spec.SecretName = "tls-cert-secret" // where cert-manager writes the key
fmt.Println(len(secret.Data), cert.Spec.SecretName)

2. Secret Reference Pattern

// Standard pattern for secret references
key := cmmeta.SecretKeySelector{
    LocalObjectReference: cmmeta.LocalObjectReference{Name: "secret-name"},
    Key:                  "key-name",
}

// The reference is a field on the upstream struct; there is no kure setter for it
issuer := certmanager.CreateIssuer("vault", "default")
issuer.Spec.Vault = &certv1.VaultIssuer{
    Server: "https://vault.example.com:8200",
    Path:   "pki/sign/example",
    Auth:   certv1.VaultAuth{TokenSecretRef: &key},
}
fmt.Println(issuer.Spec.Vault.Auth.TokenSecretRef.Name, issuer.Spec.Vault.Auth.TokenSecretRef.Key)

RBAC Integration

Resource builders provide granular RBAC control:

// Create minimal privilege roles
role := kubernetes.CreateRole("app-reader", "default")
kubernetes.AddRoleRule(role, rbacv1.PolicyRule{
    APIGroups: []string{""},
    Resources: []string{"pods"},
    Verbs:     []string{"get", "list"},
})

// Bind to specific accounts. RoleRef is a single struct field, so it is written
// directly - a helper for it would be the assignment with more words.
binding := kubernetes.CreateRoleBinding("app-reader", "default")
binding.RoleRef = rbacv1.RoleRef{
    APIGroup: rbacv1.GroupName,
    Kind:     "Role",
    Name:     "app-reader",
}
kubernetes.AddRoleBindingSubject(binding, rbacv1.Subject{
    Kind: "ServiceAccount",
    Name: "app-sa",
})
fmt.Println(role.Rules[0].Verbs, binding.RoleRef.Kind, binding.Subjects[0].Name)

Certificate Management

cert-manager integration provides secure TLS:

// ACME challenge configuration. SetClusterIssuerACME assigns a pointer-typed
// field, which is what makes it admissible sugar — it takes the *ACMEIssuer,
// built here as an upstream struct literal, and writes it straight to the spec.
issuer := certmanager.CreateClusterIssuer("letsencrypt")
certmanager.SetClusterIssuerACME(issuer, &cmacme.ACMEIssuer{
    Server: "https://acme-v02.api.letsencrypt.org/directory",
    Email:  "admin@example.com",
    Solvers: []cmacme.ACMEChallengeSolver{{
        DNS01: &cmacme.ACMEChallengeSolverDNS01{
            Cloudflare: &cmacme.ACMEIssuerDNS01ProviderCloudflare{
                APIToken: &cmmeta.SecretKeySelector{
                    LocalObjectReference: cmmeta.LocalObjectReference{Name: "cloudflare-api-token"},
                    Key:                  "token",
                },
            },
        },
    }},
})

// Certificate with DNS validation. IssuerRef is a plain field; DNSNames is a
// slice, so it keeps an appender.
cert := certmanager.CreateCertificate("api-tls", "default")
cert.Spec.IssuerRef = cmmeta.IssuerReference{
    Name: "letsencrypt",
    Kind: "ClusterIssuer",
}
certmanager.AddCertificateDNSName(cert, "api.example.com")
fmt.Println(issuer.Spec.ACME.Solvers[0].DNS01.Cloudflare.APIToken.Name, cert.Spec.IssuerRef.Kind, cert.Spec.DNSNames)

Input Validation

Validation is layered, and the constructor layer deliberately does none of it. A Create<Kind> call returns an object, never an error: it writes the name it was given, because a library that second- guesses a name it was handed cannot be composed with a caller that generates names.

LayerWhat it checksWhere
ConstructorsNothing. An unregistered type panics — a programming error, not inputpkg/kubernetes/create.go
Domain modelBundle rules: name present, a DNS-1123 subdomain and one path segment; a KustomizationName, when set, a DNS-1123 subdomain; no nil application or DependsOn entry, no cycle or duplicate name among umbrella Bundle.Children, and no bundle owned by two umbrellas or by both an umbrella and a Node. Node rules: no cycle in the Node tree, and every node name one path segment, the root alone may be unnamedstack.ValidateCluster, Bundle.Validate
Layout rulesKnown option values, and a ClusterName without a .. path segment; run by both walks on the rules as given, and by the Flux IntegrateWithLayoutlayout.LayoutRules.Validate
Explicit validatorsOpt-in checks a caller runs when it wants themkubernetes.ValidatePodSpecPSA, gvk.ValidateGVK, io.ValidateOutputFormat
The clusterSchema, admission, CRD structural rulesapply time

The domain-model row is about bundles, plus one shape rule and one name rule for nodes. ValidateCluster walks Node.Children once to find the attached bundles and to scan for a PackageRef, and rejects a Node graph containing a cycle, naming the node where it closes. The same walk checks every node name. A node name is a segment of the node’s path in the model (Node.GetPath joins the names with /) and of its directory when the layout gives it one, so the check does not depend on the layout rules: it must be one path segment (not empty, not . or .., no /, \ or NUL byte), and only the root may be unnamed, since it adds no segment. A bundle name is checked as a directory name too, and as a DNS-1123 subdomain, which is what every delivery engine needs for the object it names after the bundle. A KustomizationName names that object instead, so it is checked as a DNS-1123 subdomain as well, and not as a directory name. The 63-character limit of a Flux Kustomization name, the limit of the label value Flux writes the name into, is not part of this layer: the Flux workflow checks it where it builds a Kustomization from a bundle, on the name that Kustomization gets (Bundle.UnitName), so a longer name stays valid for the ArgoCD workflow. A refused name is reported with its node or bundle path and is never shortened; pkg/stack/README.md has the rules in full. An application name is not part of this layer: whether it becomes a directory depends on the layout rules, so the layout walk checks it. ValidateCluster checks nothing else about the nodes themselves: a ParentPath may resolve to nothing, and a node reachable from two parents is not rejected, although it is not a supported shape either. A caller that builds a Node tree by hand is responsible for that part of its shape. The check runs where ValidateCluster does; generation from an already walked layout (GenerateFromLayout, IntegrateWithLayout) does not run it again. A node reachable from two parents is walked twice, and layout.IndexOrigins refuses the result before any Flux or ArgoCD path is derived from it.

c := stack.NewCluster("prod", &stack.Node{
    Name:   "apps",
    Bundle: &stack.Bundle{Name: "web", Applications: []*stack.Application{nil}},
})

check := func(c *stack.Cluster) error {
    // Validation is a call the caller makes, not a side effect of construction.
    if err := stack.ValidateCluster(c); err != nil {
        return errors.Wrap(err, "cluster is not layoutable")
    }
    return nil
}
fmt.Println(check(c))

Testing Architecture

Test Organization

Tests live beside the code they cover, under pkg/:

pkg/
├── kubernetes/
│   ├── deployment_test.go
│   ├── admission_test.go        # the contract: every helper is class-admissible
│   ├── identity_test.go         # the contract: constructors emit identity only
│   ├── zz_generated_kinds_test.go   # the frozen kind/scope fixture (generated)
│   ├── fluxcd/ certmanager/ …   # one _test.go beside each builder file
│   └── internal/…               # kinds, markers, upstream, maturity, gen, admission
├── stack/
│   ├── application_test.go
│   ├── bundle_test.go
│   └── ...
└── ...
internal/                        # module-root packages the tests share; not public API
├── crds/                        # reads the CustomResourceDefinition manifests a pinned module ships
├── crdvalidate/                 # the API server's create-time validation, in process
├── gotk/                        # the vendored flux2 install bundle
└── kuretest/                    # Golden and AssertValid*: output held to the pinned schemas

CI enforces a repository floor of 90% statement coverage and the same 90% for every package outside examples/, so a new package cannot be added under the floor.

Testing Patterns

Constructor Testing

func TestCreateDeployment(t *testing.T) {
    deployment := CreateDeployment("test-app", "default")
    
    // Validate non-nil result
    if deployment == nil {
        t.Fatal("expected non-nil deployment")
    }
    
    // Validate required fields
    if deployment.Name != "test-app" {
        t.Errorf("expected name 'test-app', got %s", deployment.Name)
    }
    
    if deployment.Namespace != "default" {
        t.Errorf("expected namespace 'default', got %s", deployment.Namespace)
    }

    // Assert the absence of defaults, not their presence: a constructor that
    // starts writing spec values is the regression the contract prevents.
    if !reflect.DeepEqual(deployment.Spec, appsv1.DeploymentSpec{}) {
        t.Errorf("constructor injected a spec default: %+v", deployment.Spec)
    }
}

Helper Function Testing

func TestAddPodSpecContainer(t *testing.T) {
    deployment := CreateDeployment("test-app", "default")
    container := &corev1.Container{
        Name:  "main",
        Image: "nginx:latest",
    }

    AddPodSpecContainer(&deployment.Spec.Template.Spec, container)

    // Validate container was added
    if len(deployment.Spec.Template.Spec.Containers) != 1 {
        t.Errorf("expected 1 container, got %d", len(deployment.Spec.Template.Spec.Containers))
    }

    // A nil argument is a programming error, not a runtime condition
    assertPanics(t, func() { AddPodSpecContainer(nil, container) })
    assertPanics(t, func() { AddPodSpecContainer(&deployment.Spec.Template.Spec, nil) })
}

Workflow Testing

func TestFluxWorkflowGeneration(t *testing.T) {
    // Create test cluster; deploymentConfig's Generate returns one Deployment
    app := stack.NewApplication("test-app", "default", deploymentConfig)
    
    bundle := &stack.Bundle{
        Name: "test-bundle",
        Applications: []*stack.Application{app},
    }
    
    node := &stack.Node{
        Name: "test-node",
        Bundle: bundle,
    }
    
    cluster := stack.NewCluster("test-cluster", node)
    
    // Test resource generation
    engine := fluxcd.Engine()
    resources, err := engine.GenerateFromCluster(cluster, layout.DefaultLayoutRules())
    
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    
    if len(resources) == 0 {
        t.Error("expected generated resources")
    }
    
    // Validate resource types
    hasKustomization := false
    for _, resource := range resources {
        if resource.GetObjectKind().GroupVersionKind().Kind == "Kustomization" {
            hasKustomization = true
            break
        }
    }
    
    if !hasKustomization {
        t.Error("expected Kustomization resource")
    }
}

Error Testing

func TestValidationErrors(t *testing.T) {
    // Test validation error structure: an unknown grouping is a validation error
    err := layout.LayoutRules{NodeGrouping: "sideways"}.Validate()
    
    if err == nil {
        t.Fatal("expected validation error")
    }
    
    // Test KureError interface
    kureErr := errors.GetKureError(err)
    if kureErr == nil {
        t.Fatal("expected KureError")
    }
    
    // Validate error properties
    if kureErr.Type() != errors.ErrorTypeValidation {
        t.Errorf("expected validation error type, got %s", kureErr.Type())
    }
    
    suggestion := kureErr.Suggestion()
    if suggestion == "" {
        t.Error("expected non-empty suggestion")
    }
    
    context := kureErr.Context()
    if context == nil {
        t.Error("expected error context")
    }
}

Test Utilities

internal/kuretest is the shared test helper: it holds kure’s output to what a cluster would accept. Golden(t, file, obj) encodes an object the way the fixtures are written, validates it and compares it with testdata/<file> — or rewrites the file under -update, validation first, so an invalid fixture can never be written. AssertValid(t, objs...), AssertValidYAML(t, data) and AssertValidDir(t, dir) validate objects, bytes and a written tree. AssertConsistent(t, objs, declared...), AssertConsistentYAML(t, data, declared...) and AssertConsistentDir(t, root, declared...) check that the same inputs agree with themselves: every Flux sourceRef, chartRef and dependsOn resolves, and every namespace an object applies into exists, in the tree or declared with kuretest.External or kuretest.Namespace; on a directory, every Kustomization spec.path and kustomization.yaml entry resolves in the tree, and each Flux Kustomization builds as kustomize-controller builds it. Validation is the API server’s own create-time sequence (internal/crdvalidate) against the CustomResourceDefinitions of the exact module versions the kinds table pins, read from the module cache (internal/crds) and from the vendored Flux install bundle (internal/gotk); a kind with no definition gets ObjectMeta validation only, and TestEveryKindHasOneSource names each one with the reason. The design and what it leaves out are in docs/history/20261001-DESIGN-schema-validation.md.

Other helpers stay local to the package that needs them:

// Test helper functions
func createTestCluster(name string) *stack.Cluster {
    // Standard test cluster creation
}

func validateResource(t *testing.T, resource client.Object, expectedKind string) {
    // Standard resource validation
}

func assertNoError(t *testing.T, err error) {
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
}

func assertError(t *testing.T, err error, expectedType errors.ErrorType) {
    if err == nil {
        t.Fatal("expected error")
    }
    
    if !errors.IsType(err, expectedType) {
        t.Errorf("expected error type %s, got %T", expectedType, err)
    }
}

Appendices

Appendix A: Glossary

Application: Individual Kubernetes workload or resource collection within a Bundle.

Bundle: Deployment unit typically corresponding to a single GitOps resource (e.g., Flux Kustomization).

Cluster: Root abstraction representing a complete Kubernetes cluster configuration.

Domain Model: The hierarchical structure (Cluster → Node → Bundle → Application) representing cluster organization.

GitOps Engine: Implementation of GitOps-specific resource generation and layout integration.

KureError: Structured error type providing contextual information and suggestions.

Layout: Directory structure and manifest organization for GitOps repositories.

Node: Hierarchical container for organizing related Bundles (e.g., infrastructure vs applications).

Patch: Declarative modification of Kubernetes resources using JSONPath-based operations.

Kubernetes foundation: pkg/kubernetes and its per-CRD subpackages — the registered scheme, Create[T] and its generated per-kind wrappers, the admissible sugar helpers, and the generated kinds/scope/maturity tables.

Admissible sugar: A Set*/Add* helper whose write falls into one of three classes — appends to a slice or inserts into a map, assigns a pointer-typed field or initialises a nil pointer intermediate, or writes several fields under a name that states the opinion — plus the four generic metadata helpers (SetLabels, AddLabel, SetAnnotations, AddAnnotation), which write through metav1.Object and are admitted by name. Anything else is not part of the builder contract.

Constructor: Create<Kind>(name[, namespace]) — returns an object carrying apiVersion, kind and identity, and nothing else. Generated from the registered scheme.

Workflow Engine: Complete GitOps workflow implementation combining resource generation, layout integration, and bootstrap capabilities.

Appendix B: References

Appendix C: Design Documents

Additional design documentation available in the repository:

  • pkg/kubernetes/README.md: The builder contract (ADR-038) — normative
  • docs/history/20260905-DESIGN-builder-contract.md: Why the contract replaced three builder layers
  • docs/history/20261001-DESIGN-schema-validation.md: Why kure’s output is validated against the pinned CRD schemas in process, and what that leaves to a real apiserver
  • docs/builder-contract-release-1.md: Release-1 migration ledger — every removed helper and its replacement
  • docs/api-tables.md: Generated kinds, scope and field-maturity tables
  • pkg/stack/layout/README.md: Layout system overview
  • pkg/stack/workflow.go: Workflow interface definitions

Appendix D: Migration Guide

For migrating from previous versions:

V1 to V2 Migration

Domain Model Changes:

  • Node hierarchy now uses ParentPath strings instead of direct parent pointers
  • Call InitializePathMap() on root nodes after construction
  • Bundle hierarchy follows same pattern

Workflow Interface Changes:

  • Split monolithic workflow interfaces into specialized components
  • Update implementations to use ResourceGenerator, LayoutIntegrator, BootstrapGenerator
  • Compose WorkflowEngine from specialized generators

Error Handling Changes:

  • Replace generic errors with typed KureError instances
  • Internal builder functions use void returns (no nil-checking)
  • Handle error context and suggestions in error reporting

Function Naming Changes:

  • Constructor functions now follow New* vs Create* patterns consistently
  • Update imports to use new package structure
  • Helper function signatures remain compatible

Builder contract (release 1)

The builder contract removed the constructor defaults and the bare field forwarders. This is a breaking change for callers that relied on either, and it is the one migration this release asks for. Every removed function is listed with the expression that replaces it, grouped by package, in the release-1 migration notes . Three of those replacements change what the caller emits; every other one is mechanical and emits the same YAML the helper did. The three, all called out in the notes as well:

  • A constructor no longer injects app: <name> labels or a per-kind spec default.
  • The removal of the container constructor takes a resource reservation with it.
  • An unset Flux Prune now resolves to false rather than true. KustomizationSpec.Prune is a required field with no omitempty, so an unset input cannot leave the key out of the emitted YAML — it has to pick one, and the old direction turned on destructive garbage collection for a caller who never asked for it. Set it explicitly to keep pruning.

The middle one announces itself: the constructor is gone, so the call does not compile. The other two do not. A caller who never named Prune, and one that relied on a constructor’s labels, both compile unchanged and emit different YAML — so read the migration notes even if the build is already green.