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/kureCreating 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-inThe 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
- Generating Flux Manifests for the complete workflow
- API Reference for all package documentation
- Examples for working code samples