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
- Architecture Overview
- Domain Model Architecture
- Workflow Architecture
- Error Handling Architecture
- Kubernetes Foundation
- Patch System Architecture
- Layout and Packaging
- Naming Conventions
- Developer Guidelines
- Performance Characteristics
- Security Model
- Testing Architecture
- 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 --> K8SCore Components
The system is organized around four primary architectural layers:
- Domain Model (
pkg/stack/): Hierarchical abstractions for cluster configuration - Workflow Engines (
pkg/stack/workflow.go,pkg/stack/fluxcd/,pkg/stack/argocd/): GitOps-specific implementations - Kubernetes Foundation (
pkg/kubernetes/and its per-CRD subpackages): the registered scheme, the genericCreate[T]constructor and its generated per-kind wrappers, the admissible sugar helpers, and the generated kinds/scope/maturity tables - 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 kure | Reason |
|---|---|
| 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 enforcement | Enforcement rules are organizational — not a library concern |
| GitOps delivery layout decisions | Each 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:
ParentPathstring instead of direct parent pointer in serialized form- Runtime
parentfield populated duringInitializePathMap() - 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:
- Implement
stack.Workflow(pkg/stack/workflow.go):GenerateFromCluster,CreateLayoutWithResourcesandGenerateBootstrap. The Flux engine composes its implementation from theResourceGenerator,LayoutIntegratorandBootstrapGeneratorstructs above; those arefluxcdtypes, not interfaces a new workflow implements. - Register it: the package’s
initregisters a factory so thatstack.NewWorkflowcan return the workflow, aspkg/stack/fluxcddoes withstack.RegisterFluxWorkflowandpkg/stack/argocdwithstack.RegisterArgoWorkflow.NewWorkflowresolves only thefluxandargocdproviders, so a new provider name also needs its own case and register function there. - Register with Layout: Add layout rules for the new workflow
- 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, admissionThe 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:
| Class | What it does | Example |
|---|---|---|
| Appender | Appends to a slice field, or inserts into a map field, creating either if nil | AddPodSpecContainer, AddConfigMapData |
| Pointer / nil-init | Assigns a pointer-typed field, or initialises a nil pointer intermediate before writing through it | SetDeploymentReplicas |
| Composite | Writes several fields that belong together, and names the opinion | SetHPAMinMaxReplicas, 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
- Strong Return Types: All constructors return specific Kubernetes types
- 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
- 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.goclassifies everySet*/Add*body underpkg/kubernetes/..., andpkg/kubernetes/admission_params_test.gofails 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/patchmoved 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.ServiceHelper 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 enginePackage 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.gofor package documentation - Design documents:
DESIGN.md,README.mdin 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 withno 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 generateThis 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
clusterScopedUnregisteredKindsinpkg/manifestif 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, sobuiltinClusterScopedis 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 topkg/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.goas an uncovered built-in;TestEveryKindHasOneSourcefails on a module that is not in the table; - pin it in the
replaceblock ofgo.modat the release of the otherk8s.iomodules, 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 bybash 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:checkExtending 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 methods2. 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, GenerateBootstrap4. 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:
- YAML serialization (mitigated by streaming output)
- Path resolution in complex hierarchies (mitigated by path caching)
- 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.
| Layer | What it checks | Where |
|---|---|---|
| Constructors | Nothing. An unregistered type panics — a programming error, not input | pkg/kubernetes/create.go |
| Domain model | Bundle 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 unnamed | stack.ValidateCluster, Bundle.Validate |
| Layout rules | Known option values, and a ClusterName without a .. path segment; run by both walks on the rules as given, and by the Flux IntegrateWithLayout | layout.LayoutRules.Validate |
| Explicit validators | Opt-in checks a caller runs when it wants them | kubernetes.ValidatePodSpecPSA, gvk.ValidateGVK, io.ValidateOutputFormat |
| The cluster | Schema, admission, CRD structural rules | apply 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 schemasCI 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
- Kubernetes API Reference
- Flux Documentation
- ArgoCD Documentation
- cert-manager Documentation
- MetalLB Documentation
- External Secrets Operator
Appendix C: Design Documents
Additional design documentation available in the repository:
pkg/kubernetes/README.md: The builder contract (ADR-038) — normativedocs/history/20260905-DESIGN-builder-contract.md: Why the contract replaced three builder layersdocs/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 apiserverdocs/builder-contract-release-1.md: Release-1 migration ledger — every removed helper and its replacementdocs/api-tables.md: Generated kinds, scope and field-maturity tablespkg/stack/layout/README.md: Layout system overviewpkg/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
ParentPathstrings 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
WorkflowEnginefrom specialized generators
Error Handling Changes:
- Replace generic errors with typed
KureErrorinstances - 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*vsCreate*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
Prunenow resolves tofalserather thantrue.KustomizationSpec.Pruneis a required field with noomitempty, 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.