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

Using Kure as a Library

Using Kure as a Library

Kure is primarily a Go library. This guide covers the basics of importing it, creating resources, and generating YAML output.

Each Go block below that is not an import block is the body of an Example function in pkg/kubernetes/library_usage_example_test.go, generated from it, so the page cannot show code that no longer compiles. go test runs every one of them and compares what it prints, except the maturity example, which is compiled only: its output follows the pinned upstream modules. Each import block lists what the examples after it import, less fmt: the examples add fmt.Println lines to print what they built. They use panic(err) where a program would return the error, and two things declared in the same file: frontendConfig, a stack.ApplicationConfig that emits one Deployment, and exportedObjects(), a ConfigMap carrying the resourceVersion, uid and creationTimestamp an API server sets.

Installation

go get github.com/go-kure/kure

Creating Resources

A constructor gives you an object with an identity and nothing else: its apiVersion and kind from the scheme, its metadata.name, and its metadata.namespace for a namespaced kind. From there the upstream Go struct is the API, so you set fields on it directly.

import (
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/utils/ptr"
    "github.com/go-kure/kure/pkg/kubernetes"
)
dep := kubernetes.CreateDeployment("web", "default") // identity only
dep.Spec.Replicas = ptr.To[int32](3)
dep.Spec.Selector = &metav1.LabelSelector{MatchLabels: map[string]string{"app": "web"}}
dep.Spec.Template.Spec.ServiceAccountName = "web"
fmt.Println(dep.Kind, dep.Name, *dep.Spec.Replicas, dep.Spec.Selector.MatchLabels["app"])

kubernetes.Create[appsv1.Deployment]("web", "default") is the generic form; the per-kind wrappers are generated from the scheme and carry the scope in their signature, so a cluster-scoped kind takes only a name (kubernetes.CreateNamespace("platform")).

The base kinds go beyond workloads and RBAC: PriorityClass, EndpointSlice, Lease, RuntimeClass, the two webhook configurations and the four admission policy kinds each have a wrapper too (kubernetes.CreatePriorityClass("critical"), kubernetes.CreateLease("leader", "kube-system")). The Flux image kinds ImageRepository and ImagePolicy have theirs in pkg/kubernetes/fluxcd, next to ImageUpdateAutomation. APIService has one as well (kubernetes.CreateAPIService("v1.example.com")). VerticalPodAutoscaler has none: it is out of scope. The builder contract lists what is covered, why that kind is not, and what a caller does instead.

A kind with a wrapper is also a kind io.ParseYAML returns as its upstream Go type. That changes what a parse returns when kure starts registering a kind: a PriorityClass that a strict parse used to refuse, and that a parse with AllowUnstructured returned as *unstructured.Unstructured, now comes back as *schedulingv1.PriorityClass. A list document follows its items: a PriorityClassList, a DeploymentList or a generic List is flattened into its items, typed where the kind is registered, in the list’s order and in the list’s place in the stream. A registered type is such a list only where apimachinery reads its Items as a slice: one whose Items is a nil field of interface type, or sits behind a nil embedded pointer, is not flattened, and the parse refuses it with an error instead of panicking. A list whose own metadata carries labels or annotations is refused, since its items cannot keep them, and so is a document that states apiVersion, kind or a list’s items under a key in another case (Kind, Items).

A parse returns the objects that decoded beside the errors of the documents and list items that did not: check the error and still use the objects. Where the decoder cannot move past a place in the stream, the parse ends there with one error, and the objects decoded before it are returned. Malformed JSON after two JSON objects that open the stream, one after the other with no --- line between them, is such a place. The IO reference states the rules.

One kind is registered at two versions. MetalLB serves BGPPeer at metallb.io/v1beta1, which it deprecates, and at metallb.io/v1beta2, which it stores. A manifest at either version parses to that version’s own Go type. metallb.CreateBGPPeer builds the v1beta2 object, and metallb.AddBGPPeerNodeSelector takes it with a metav1.LabelSelector. The v1beta1 type has no constructor: build it from the upstream struct if you need one. Kure does not convert between the two.

Kure adds a helper only for one of a few write shapes: appending one item to a list, inserting one key into a map, setting a pointer field, or composing a small upstream struct. A helper never defaults, never validates, and never touches a field you did not name. A helper writes each argument as you pass it, or as its JSON encoding where the field holds raw JSON (fluxcd.SetHelmReleaseValuesFromMap), and computes nothing else from it.

dep := kubernetes.CreateDeployment("web", "default")

kubernetes.AddLabel(dep, "tier", "frontend") // works on any kind
kubernetes.SetDeploymentReplicas(dep, 3)
fmt.Println(dep.Labels["tier"], *dep.Spec.Replicas)

The Kubernetes Builders page is the normative contract: what constructors emit, which helpers exist and why, and the migration notes for the constructor defaults that earlier releases injected and no longer do. If you upgraded and a field you relied on is now empty, that page lists it.

The per-kind config-struct builders (certmanager.Certificate(&CertificateConfig{...}), cnpg.Cluster(&ClusterConfig{...}) and the like in certmanager, cnpg, externalsecrets, prometheus, cilium and volsync) are gone too: build those kinds with Create<Kind> and the upstream struct, exactly as above. The release 2 migration notes map every removed function, type and field to the upstream field that replaces it, and list the values the CNPG builders used to inject that you now write yourself.

FluxCD Resources

import (
    "time"

    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    kustv1 "github.com/fluxcd/kustomize-controller/api/v1"
    sourcev1 "github.com/fluxcd/source-controller/api/v1"
    "github.com/go-kure/kure/pkg/kubernetes/fluxcd"
)
// Create a GitRepository source
repo := fluxcd.CreateGitRepository("my-repo", "flux-system")
repo.Spec.URL = "https://github.com/org/repo"
fluxcd.SetGitRepositoryReference(repo, &sourcev1.GitRepositoryRef{Branch: "main"})
repo.Spec.Interval = metav1.Duration{Duration: 5 * time.Minute}

// Create a Kustomization that references the source
ks := fluxcd.CreateKustomization("my-app", "flux-system")
ks.Spec.SourceRef = kustv1.CrossNamespaceSourceReference{
    Kind: "GitRepository",
    Name: "my-repo",
}
ks.Spec.Path = "./clusters/production"
ks.Spec.Interval = metav1.Duration{Duration: 10 * time.Minute}
ks.Spec.Prune = true
fmt.Println(repo.Spec.Reference.Branch, ks.Spec.SourceRef.Kind+"/"+ks.Spec.SourceRef.Name, ks.Spec.Path)

Helm values given as a map go through fluxcd.SetHelmReleaseValuesFromMap, which marshals them to JSON and panics on a value encoding/json refuses. Decoded YAML can carry one: gopkg.in/yaml.v3 decodes .nan and .inf into a float NaN or infinity without error, while sigs.k8s.io/yaml returns that error at decode time. Decode with the latter, or marshal the values yourself and pass them to fluxcd.SetHelmReleaseValues.

See the FluxCD Builders reference for all available resource types.

Beyond FluxCD, the Kubernetes Builders package provides typed constructors for core resources (Deployment, Service, Ingress, CronJob, NetworkPolicy, HTTPRoute), PSA security context helpers, ResourceRequirements builders, and more. The constructors write identity only, so fields the API server requires in context are yours to set: a Job from kubernetes.CreateJob needs Spec.Template.Spec.RestartPolicy set to Never or OnFailure, or the server defaults it to Always and rejects the Job. The Prometheus Builders sub-package covers ServiceMonitor, PodMonitor, and PrometheusRule CRDs.

Generating YAML

Use the io package to serialize resources. io.Marshal and io.PrintObjectsAsYAML write to the io.Writer you pass; io.SaveFile writes one object to a path:

import (
    "os"
    "path/filepath"

    "sigs.k8s.io/controller-runtime/pkg/client"
    "github.com/go-kure/kure/pkg/io"
    "github.com/go-kure/kure/pkg/kubernetes"
)
cm := kubernetes.CreateConfigMap("app-config", "default")
cm.Data = map[string]string{"LOG_LEVEL": "info"}
var obj client.Object = cm
objects := []*client.Object{&obj}
dir, err := os.MkdirTemp("", "kure-library-usage")
if err != nil {
    panic(err)
}
defer func() { _ = os.RemoveAll(dir) }()

// Serialize a single object
if err := io.Marshal(os.Stdout, cm); err != nil {
    panic(err)
}

// Write multiple objects to stdout as YAML
if err := io.PrintObjectsAsYAML(objects, os.Stdout); err != nil {
    panic(err)
}

// Save to file
if err := io.SaveFile(filepath.Join(dir, "output.yaml"), cm); err != nil {
    panic(err)
}

Clean YAML encoding

When encoding resources exported from a cluster, server-managed metadata fields (managedFields, resourceVersion, uid, generation, selfLink and the kubectl.kubernetes.io/last-applied-configuration annotation) clutter the output. The default encoding strips all of these automatically, together with a null creationTimestamp and an empty status; a creationTimestamp that is set is kept:

objects := exportedObjects()

// Default: strips server-managed fields and uses standard key order
data, err := io.EncodeObjectsToYAMLWithOptions(objects, io.EncodeOptions{
    KubernetesFieldOrder: true,
})
if err != nil {
    panic(err)
}
fmt.Print(string(data))

Use ServerFieldStripping to control the level of stripping:

objects := exportedObjects()

// Preserve server fields (e.g. for debugging)
data, err := io.EncodeObjectsToYAMLWithOptions(objects, io.EncodeOptions{
    ServerFieldStripping: io.StripServerFieldsNone,
})
if err != nil {
    panic(err)
}
fmt.Print(string(data))

Both encoders write an integer exactly when the object holds it as an integer (an integer field of a typed object, an int64 in an unstructured one), up to the largest uint64: 9007199254740993 stays 9007199254740993. A float64 that holds an integer between -2^63 and 2^64, both excluded, is written with the digits of its JSON form, in both field orders, which above 2^53 are not always its exact expansion: the float64 2^62 is written 4611686018427388000. Hold a large integer as an integer to have it written exactly.

See the IO reference for all output formats and stripping options.

Asking What Kure Knows About a Kind

Every kind kure registers is described in a table generated from the pinned upstream module sources — its scope, the module and version it came from, and the fields upstream documents as gated, alpha, beta or deprecated. The lookups are in pkg/kubernetes, and they answer from that table rather than from a hand-kept list.

import (
    "github.com/go-kure/kure/pkg/kubernetes"
    "github.com/go-kure/kure/pkg/manifest"
)
k, ok := kubernetes.KindFor("apps/v1", "Deployment") // exact group/version/kind
if ok {
    fmt.Println(k.Namespaced, k.Module, k.ScopeSource)
}

namespaced, known := kubernetes.IsNamespaced("autoscaling/v1", "HorizontalPodAutoscaler")
fmt.Println(namespaced, known)

The row also carries k.ModuleVersion, the pinned version of that module; the example leaves it out of what it prints because it changes with every dependency bump.

The two answer deliberately different questions. KindFor returns the row for one exact group/version/kind, because GoType, ImportPath and ModuleVersion are properties of that version. IsNamespaced matches on group and kind only: scope is a property of the resource, not of the version, so it still answers for a manifest at a version kure does not register — autoscaling/v1 above, where the scheme carries autoscaling/v2. KindForAnyVersion is that same version-insensitive match returning the whole row: the row of the exact version when kure registers it, and otherwise the first row of the group/kind, whose version-specific fields then describe another version. pkg/manifest’s Scope builds on the version-insensitive form, which is why a manifest at an unregistered version is classified rather than left unknown.

ScopeSource says what declared the scope, and distinguishes a built-in kind from a custom resource without keeping a list:

k, _ := kubernetes.KindForAnyVersion("cilium.io/v2", "CiliumNetworkPolicy")
fmt.Println(k.ScopeSource == kubernetes.ScopeSourceBuiltin) // false: declared by a marker

// pkg/manifest asks both forms of the scope question:
fmt.Println(manifest.IsNamespacedKind("cilium.io/v2", "CiliumNetworkPolicy"))        // true
fmt.Println(manifest.IsNamespacedBuiltinKind("cilium.io/v2", "CiliumNetworkPolicy")) // false, not a built-in

The three sources are ScopeSourceMarker (the kind’s own +kubebuilder:resource marker), ScopeSourceShippedCRD (a CustomResourceDefinition the module ships) and ScopeSourceBuiltin (the Kubernetes API itself). Only the last is a built-in, and that distinction decides who wins in manifest.Scope: a built-in’s scope comes from the table, while for a custom resource a CustomResourceDefinition in the same context governs — it names the scope the target cluster will serve, where the table only records what the pinned module declared at build time.

Maturity is reported, never enforced (this example is compiled, not run, because what it prints follows the pinned upstream modules):

for _, f := range kubernetes.MaturityForType("k8s.io/api/core/v1", "PodSpec") {
    fmt.Println(f.Field, f.Stability, f.Gates)
}
gated := kubernetes.GatedFields() // every field behind a feature gate
fmt.Println(len(gated) > 0)

kure does not warn, reject or filter on any of it — a consumer with cluster knowledge decides. The table exists because the failure it describes is silent: the API server does not reject a field whose feature gate is off, it clears the field and admits the object, so the manifest reads as applied and is not. The same data is published as docs/api-tables.json and API Tables for readers outside Go. See the Kubernetes Builders page for what each column means and how it is derived.

Working with the Domain Model

For more complex scenarios, use the Stack package to define cluster topologies:

import "github.com/go-kure/kure/pkg/stack"
cluster, err := stack.NewClusterBuilder("production").
    WithNode("apps").
    WithBundle("web").
    WithApplication("frontend", frontendConfig).
    End().
    End().
    Build()
if err != nil {
    panic(err)
}
fmt.Println(cluster.Name, cluster.Node.Name, cluster.Node.Bundle.Name)

Build returns the cluster and an error, which reports an invalid tree.

Then use the Flux Engine and Layout Engine to generate a complete GitOps repository structure. See the Generating Flux Manifests guide for the full workflow.

Error Handling

All Kure packages use the errors package:

import (
    "github.com/go-kure/kure/pkg/errors"
    "github.com/go-kure/kure/pkg/io"
)
load := func(path string) error {
    _, err := io.ParseFile(path)
    if err != nil {
        return errors.Wrap(err, "failed to generate manifests")
    }
    return nil
}
fmt.Println(load("does-not-exist.yaml"))

Next Steps