Examples¶
All source files live under docs/examples/ and are also run as CI checks.
Each example calls easykubenix to produce Kubernetes manifests and verifies
the output is valid JSON/YAML.
Basic Resources¶
Creates a ConfigMap, Secret, Pod, Deployment, and Service in the default
namespace — the fundamental building blocks of any Kubernetes project.
{
pkgs,
lib,
easykubenix,
}:
let
inherit (pkgs) lib;
ekn = easykubenix {
inherit pkgs;
modules = [
{
kubernetes.objects = {
default.ConfigMap.app-config = {
data = {
"app.properties" = "key=value\nmode=production";
};
};
default.Secret.db-credentials = {
stringData = {
username = "admin";
password = "s3cret";
};
};
default.Pod.webserver = {
spec.containers = [
{
name = "nginx";
image = "nginx:alpine";
ports = [ { containerPort = 80; } ];
}
];
};
default.Deployment.app = {
spec.replicas = 3;
spec.selector.matchLabels.app = "app";
spec.template.metadata.labels.app = "app";
spec.template.spec.containers = [
{
name = "app";
image = "nginx:alpine";
ports = [ { containerPort = 80; } ];
}
];
};
default.Service.app = {
spec.ports = [
{
port = 80;
targetPort = 80;
}
];
spec.selector.app = "app";
};
};
}
];
};
in
{
manifestJSON = ekn.manifestJSON;
manifestYAMLFile = ekn.manifestYAMLFile;
check = import ../verify.nix {
inherit pkgs lib;
name = "basic-resources";
manifestJSON = ekn.manifestJSON;
};
}
The generated manifest will contain 5 items with apiVersion, kind, and
metadata automatically populated by easykubenix.
Named Lists¶
Demonstrates the mkNamedList, mkNumberedList and mkReplaceList helpers for
overriding container lists, environment variables and flags without writing the
whole list again.
{
pkgs,
lib,
easykubenix,
}:
let
ekn = easykubenix {
inherit pkgs;
modules = [
{
kubernetes.objects = {
default.Deployment.namedlist-demo = {
spec.selector.matchLabels.app = "namedlist-demo";
spec.template.metadata.labels.app = "namedlist-demo";
spec.template.spec = {
# `mkNamedList` gives an explicit named list. The attribute name
# becomes the `name` of the container. `pkgs.lib` here is the
# extended lib, which holds easykubenix' own helpers.
containers = pkgs.lib.mkNamedList {
main = {
image = "nginx:alpine";
ports = [ { containerPort = 80; } ];
# A plain list of bare strings, as a chart renders it. The
# module below patches one of these by content.
args = [
"--metrics-addr=0.0.0.0:8443"
"--enable-leader-election"
];
env = pkgs.lib.mkNamedList {
FOO.value = "bar";
MODE.value = "test";
};
};
sidecar = {
image = "fluentd:latest";
};
};
# Objects with no `name` and no stable index. The module below
# picks one of the three with a predicate.
tolerations = [
{
key = "dedicated";
operator = "Exists";
}
{
key = "node-role.kubernetes.io/control-plane";
operator = "Exists";
effect = "NoSchedule";
}
{
key = "node.kubernetes.io/unreachable";
operator = "Exists";
tolerationSeconds = 30;
}
];
# A plain list keeps its order. Use `mkNumberedList` to override
# one entry of it by index.
initContainers = [
{
name = "init-step1";
image = "busybox";
command = [
"echo"
"step1"
];
}
{
name = "init-step2";
image = "busybox";
command = [
"echo"
"step2"
];
}
];
};
};
# A control case: plain containers with no marker at all.
default.Deployment.plain-containers = {
spec.selector.matchLabels.app = "plain";
spec.template.metadata.labels.app = "plain";
spec.template.spec.containers = [
{
name = "c1";
image = "nginx:alpine";
}
{
name = "c2";
image = "alpine:latest";
command = [
"sleep"
"infinity"
];
}
];
};
};
}
# A second module, the way a patch of a rendered chart is written: it
# names the container, and addresses one of its `args` by the start of
# the flag. An index would move with the next chart version, and a bare
# string has no `name` to address. A key that matches no element is an
# error, so the patch cannot stop applying in silence.
{
kubernetes.objects.default.Deployment.namedlist-demo.spec.template.spec.containers =
pkgs.lib.mkNamedList
{
main.args = pkgs.lib.mkReplaceList {
"--metrics-addr=" = "--metrics-addr=:8443";
};
};
# `mkReplaceWhere` for a list a prefix cannot read. The predicate runs
# against each toleration and must pick exactly one. The body names
# one field, so `key` and `operator` survive and the other two
# tolerations are untouched.
kubernetes.objects.default.Deployment.namedlist-demo.spec.template.spec.tolerations =
pkgs.lib.mkReplaceWhere
{
control-plane = {
where = toleration: (toleration.key or null) == "node-role.kubernetes.io/control-plane";
value.effect = "NoExecute";
};
};
}
];
};
in
{
manifestJSON = ekn.manifestJSON;
check = import ../verify.nix {
inherit pkgs lib;
name = "namedlists";
manifestJSON = ekn.manifestJSON;
};
}
initContainers use mkNumberedList, which addresses an entry by its index.
Regular containers and env entries use mkNamedList, which addresses an
entry by its name attribute. The container's args use mkReplaceList, which
addresses an element by the start of its own value. All three keep the order of
the entries that a plain list already defines.
mkReplaceList is for a list whose elements have neither a name nor an index
that survives the next chart version, which is what a container's args is. The
key is the start of the element, and the value takes the whole element. A key
that matches no element is an error, and so is a key that matches more than one.
A replacement needs no mkForce: it replaces an element rather than defining
one.
mkReplaceList takes an attribute set. Use lib.mkMerge to compose several of
them, or to hold a list and a patch of it in one definition:
args = lib.mkMerge [
(lib.mkReplaceList { "--metrics-addr=" = "--metrics-addr=:8443"; })
(lib.mkIf cfg.debug (lib.mkReplaceList { "--v=" = "--v=4"; }))
];
Matching with a predicate¶
A prefix only reads a string. For a list of objects with neither a name nor a
stable index — tolerations is the usual one — lib.mkReplaceWhere addresses
an element with a function.
Say the chart renders three of them, and only the second is wrong:
tolerations = [
{ key = "dedicated"; operator = "Exists"; }
{ key = "node-role.kubernetes.io/control-plane"; operator = "Exists"; effect = "NoSchedule"; }
{ key = "node.kubernetes.io/unreachable"; operator = "Exists"; tolerationSeconds = 30; }
];
where runs against each element in turn and must pick exactly one:
tolerations = lib.mkReplaceWhere {
control-plane = {
where = toleration:
(toleration.key or null) == "node-role.kubernetes.io/control-plane";
value.effect = "NoExecute";
};
};
The render keeps all three, in order, with one field of the second changed:
tolerations = [
{ key = "dedicated"; operator = "Exists"; }
{ key = "node-role.kubernetes.io/control-plane"; operator = "Exists"; effect = "NoExecute"; }
{ key = "node.kubernetes.io/unreachable"; operator = "Exists"; tolerationSeconds = 30; }
];
operator survived because the body did not name it, and effect changed with
no mkForce. A where that matches none of the three, or two of them, is an
error naming the option and printing the list.
The attribute name is a label. It names the replacement in an error, and it is
what two definitions of the same replacement merge on; it is not matched against
anything. mkReplaceList is the short form of this: a label with no where
matches by the start of its own name, and both forms share one marker, so they
compose on one field.
value merges over the element it matched. The element joins the merge as a
default, leaf by leaf, so ordinary module-system rules decide the rest:
you write |
you get |
|---|---|
|
that field changed, every other field of the element untouched, no |
a field the element does not have |
added |
|
the element dropped and the body in its place, losing every field the body does not name |
a string element |
replaced, because a string is a single leaf |
two modules setting one field |
a conflict that names the field |
A second module overrides a replacement by writing the label with no where,
because two predicates for one label cannot be merged:
tolerations = lib.mkReplaceWhere {
control-plane.value.effect = lib.mkForce "PreferNoSchedule";
};
Conditional Definitions¶
Demonstrates mkIfExists and mkIfExistsAtPath, which patch an object only
when that object is already there.
{
pkgs,
lib,
easykubenix,
}:
let
# Stand-in for something you do not own: a chart, a vendored module, a
# shared platform module. It ships one Deployment today. It can stop.
upstream = {
kubernetes.objects.default.Deployment.web = {
spec.replicas = 1;
spec.selector.matchLabels.app = "web";
spec.template.metadata.labels.app = "web";
spec.template.spec.containers = pkgs.lib.mkNamedList {
main.image = "nginx:alpine";
};
};
};
# The patch. Every definition here is conditional, so none of it creates an
# object. An ordinary definition would: writing
# `kubernetes.objects.default.Deployment.cache.spec.replicas = 5` renders a
# Deployment with that one field and nothing else, the moment nobody else
# defines `cache`.
overlay =
{ lib, ... }:
{
kubernetes.objects = lib.mkMerge [
# The path form. All three of the namespace, the Kind and the name
# must already exist for the content to land.
(lib.mkIfExistsAtPath "default.Deployment.web" {
spec.replicas = lib.mkForce 3;
metadata.annotations."example.com/patched" = "true";
})
# Nothing named `cache` exists, so this adds nothing at all.
(lib.mkIfExistsAtPath "default.Deployment.cache" {
spec.replicas = lib.mkForce 5;
})
{
# `mkIfExists` conditions the entry it is written on, and the rule
# applies again at each level below it.
default = lib.mkIfExists {
# The namespace exists, so a child of it is added normally.
ConfigMap.web-config.data.mode = "production";
# This Kind does not exist, so nothing under it is created.
CronJob = lib.mkIfExists {
nightly.spec.schedule = "0 0 * * *";
};
};
}
];
};
ekn = easykubenix {
inherit pkgs;
modules = [
upstream
overlay
];
};
in
{
manifestJSON = ekn.manifestJSON;
check = import ../verify.nix {
inherit pkgs lib;
name = "conditional";
manifestJSON = ekn.manifestJSON;
};
}
An ordinary definition creates the key it names. So a patch written against a chart resurrects the object the chart stopped shipping, as a fragment with no containers. A conditional definition contributes only when an ordinary definition for that exact key survives; otherwise it contributes nothing.
kubernetes.objects is conditional at the namespace, the Kind and the object
name, and an object's own fields are conditional too. So the rule holds at
every level: a conditional namespace that exists still adds children to
itself, and a conditional Kind that does not exist creates neither itself nor
anything under it.
mkIfExistsAtPath "namespace.Kind.name" value is the path form, and all three
levels must exist. Pass a list, [ "namespace" "Kind" "name" ], for a key that
contains a dot. Put a priority inside the marker content, as
mkIfExists { replicas = lib.mkForce 3; }, never around it.
An mkIf false definition does not count as existing. Neither does a key that
only other markers define.
Helm Charts¶
Integrates an external Helm chart (ingress-nginx) into the easykubenix
module tree. Chart objects are injected into the kubernetes.objects namespace
and run through the same transformer/generator pipeline.
{
pkgs,
lib,
easykubenix,
}:
let
ekn = easykubenix {
inherit pkgs;
modules = [
{
helm.releases."hello-world" = {
chart = pkgs.fetchHelm {
repo = "https://kubernetes.github.io/ingress-nginx";
chart = "ingress-nginx";
version = "4.12.1";
sha256 = "sha256-GEzgtcwJ+lZ9ymCDey54bD7BZkCpXoDcIQU0MjzAxcA=";
};
namespace = "ingress-nginx";
values = {
controller.admissionWebhooks.enabled = false;
controller.ingressClassResource.enabled = false;
};
};
}
];
};
in
{
manifestJSON = ekn.manifestJSON;
check = import ../verify.nix {
inherit pkgs lib;
name = "helm-chart";
manifestJSON = ekn.manifestJSON;
};
}
The fetchHelm function downloads and renders the chart. A chart list stays a
plain list. To override one entry of it by name, give the same field an
mkNamedList value in your own module. The attribute name selects the entry:
kubernetes.objects.default.Deployment.my-chart.spec.template.spec.containers =
lib.mkNamedList { main.image = lib.mkForce "my-registry/main:1.2.3"; };
The type merges the two definitions by name. An entry keeps the position it had
in the chart output. An attribute name that the chart does not use adds a new
entry at the end. Use mkNumberedList to address an entry by index instead, and
mkReplaceList to address one element of a list of strings by the start of its
own value:
kubernetes.objects.default.Deployment.my-chart.spec.template.spec.containers =
lib.mkNamedList {
main.args = lib.mkReplaceList { "--metrics-addr=" = "--metrics-addr=:8443"; };
};
Generators, Transformers & Filters¶
Showcases the three extension pipelines:
Generators — create new objects from existing ones (auto-VPA for Deployments)
Transformers — modify objects in-place (annotations on Services)
Filters — remove objects matching conditions (exclude Pods)
{
pkgs,
lib,
easykubenix,
}:
let
inherit (pkgs) lib;
ekn = easykubenix {
inherit pkgs;
modules = [
{
# Generator: auto-create VPA for Deployments that have the annotation
kubernetes.generators = [
(
object:
lib.optionalAttrs
(object.kind or "" == "Deployment" && (object.metadata.annotations.genvpa or "true") == "true")
{
apiVersion = "autoscaling.k8s.io/v1";
kind = "VerticalPodAutoscaler";
metadata = {
name = object.metadata.name;
namespace = object.metadata.namespace;
};
spec.targetRef = {
apiVersion = "apps/v1";
kind = "Deployment";
name = object.metadata.name;
};
spec.updatePolicy.updateMode = "Auto";
}
)
];
# Transformer: add annotations to all Services
kubernetes.transformers = [
(
object:
if object.kind == "Service" then
lib.recursiveUpdate object {
metadata.annotations."example.io/managed-by" = "easykubenix";
}
else
object
)
];
# Filter: remove any object whose kind is Pod
kubernetes.filters = [
(object: object.kind or "" != "Pod")
];
kubernetes.objects = {
default.Deployment.myapp = {
spec.selector.matchLabels.app = "myapp";
spec.template.metadata.labels.app = "myapp";
spec.template.spec.containers = [
{
name = "myapp";
image = "nginx:alpine";
}
];
};
default.Service.myapp = {
spec.ports = [ { port = 80; } ];
spec.selector.app = "myapp";
};
# This Pod should be filtered out by the filter above
default.Pod.should-be-filtered = {
spec.containers = [
{
name = "test";
image = "alpine";
}
];
};
};
}
];
};
in
{
manifestJSON = ekn.manifestJSON;
check = import ../verify.nix {
inherit pkgs lib;
name = "generators-transformers-filters";
manifestJSON = ekn.manifestJSON;
};
}
The Pod should-be-filtered is removed by the filter. The Service gets
annotations. The Deployment spawns a matching VerticalPodAutoscaler.
Edge Cases¶
Tests cluster-scoped resources (Namespace, ClusterIssuer, Certificate),
custom apiMappings for non-standard kinds, and the none pseudo-namespace
for resources without a namespace.
{
pkgs,
lib,
easykubenix,
}:
let
inherit (pkgs) lib;
ekn = easykubenix {
inherit pkgs;
modules = [
{
# Cluster-scoped resource (no namespace)
kubernetes.objects.none.Namespace.custom-ns = { };
# Resource in the namespace we just created
kubernetes.objects.custom-ns.ConfigMap.ns-config = {
data.ns = "custom-ns";
};
# Custom apiVersion via apiMappings
kubernetes.apiMappings = {
Certificate = "cert-manager.io/v1";
ClusterIssuer = "cert-manager.io/v1";
};
kubernetes.objects.none.ClusterIssuer.selfsigned = {
spec.selfSigned = { };
};
kubernetes.objects.none.Certificate.example = {
spec.dnsNames = [ "example.com" ];
spec.secretName = "example-tls";
spec.issuerRef.name = "selfsigned";
spec.issuerRef.kind = "ClusterIssuer";
};
# Nested namespace
kubernetes.objects.kube-system.ConfigMap.kube-proxy-config = {
data."config.conf" = "apiVersion: kubeproxy.config.k8s.io/v1alpha1\nkind: KubeProxyConfiguration\n";
};
}
];
};
in
{
manifestJSON = ekn.manifestJSON;
check = import ../verify.nix {
inherit pkgs lib;
name = "edge-cases";
manifestJSON = ekn.manifestJSON;
};
}
Note that none.Namespace, none.ClusterIssuer, and none.Certificate are
cluster-scoped (no metadata.namespace). The kube-system ConfigMap
demonstrates a resource in an existing namespace.
Validation with Real kube-apiserver¶
Spins up etcd + kube-apiserver, applies all manifests with ekn, then checks
every resource against its JSON schema with ekn _schemaCheck — including
CRDs from a Helm chart and custom resources that depend on them.
The apply goes through the same apply_and_prune that ekn kubeapply uses to
bootstrap a real cluster, so the gate exercises the path you actually deploy
with rather than a second one maintained alongside it.
{
pkgs,
lib,
easykubenix,
}:
let
ekn = easykubenix {
inherit pkgs;
modules = [
{
# Required, and no default. `validationScript` applies through the
# same path as `ekn kubeapply`, which prunes by this label.
ekn.environment = "validation-example";
kubernetes.apiMappings = {
Alertmanager = "monitoring.coreos.com/v1";
Prometheus = "monitoring.coreos.com/v1";
PrometheusRule = "monitoring.coreos.com/v1";
ServiceMonitor = "monitoring.coreos.com/v1";
};
helm.releases."prometheus-stack" = {
chart = pkgs.fetchHelm {
repo = "https://prometheus-community.github.io/helm-charts";
chart = "kube-prometheus-stack";
version = "62.0.0";
sha256 = "sha256-v8jOlnRMj23of+RqXjCKue8oUh8wprcZoip92BdMTAo=";
};
namespace = "monitoring";
includeCRDs = true;
noHooks = true;
values = {
defaultRules.enabled = false;
alertmanager.enabled = false;
grafana.enabled = false;
kubeApiServer.enabled = false;
kubelet.enabled = false;
kubeControllerManager.enabled = false;
kubeDns.enabled = false;
kubeEtcd.enabled = false;
kubeScheduler.enabled = false;
kubeStateMetrics.enabled = false;
kubeProxy.enabled = false;
coreDns.enabled = false;
nodeExporter.enabled = false;
prometheusOperator.enabled = true;
prometheus.enabled = true;
prometheus.prometheusSpec.replicas = 1;
prometheus.prometheusSpec.resources = { };
};
};
kubernetes.objects.monitoring.PrometheusRule.test-alerts = {
spec.groups = [
{
name = "test";
rules = [
{
alert = "TestAlert";
expr = "vector(1)";
}
];
}
];
};
}
];
};
in
{
inherit (ekn) manifestJSON manifestYAMLFile validationScript;
inherit (ekn.config.validation) schemaCheck;
check = import ../verify.nix {
inherit pkgs lib;
name = "validation-crds";
manifestJSON = ekn.manifestJSON;
};
}
The kube-prometheus-stack chart bundles CustomResourceDefinition objects
(Prometheus, ServiceMonitor, Alertmanager, etc.). ekn.resourcePriority puts
these in an earlier barrier and waits for each to become Established, after
which their dependent custom resources — being kinds with no configured
priority — apply and are validated against the live schema. The chart's
admission webhook configurations apply after those, in the last barriers of
all: nothing can serve a webhook in this harness, so registering one before
the custom resources would stall every one of them for its full timeout. The apiMappings option tells easykubenix what
apiVersion to use for each custom kind.
Bootstrapping GitOps¶
A GitOps engine cannot sync itself into existence. The controller, its CRDs and the root Application that points it at a branch all have to be applied before anything can be synced — and then never again by hand.
deployment.units.<name>.modules is how that is expressed: a whole separate
easykubenix configuration attached to one target, rendered into that target's
path and kept out of kubernetes.generated. A plain ekn kubeapply and
ekn validate never see it; only ekn kubeapply --target bootstrap applies it.
# The outer instance: everything ArgoCD syncs, plus the bootstrap target that
# gets ArgoCD running in the first place.
{ ekn, lib, ... }:
{
options.clusterRepoURL = lib.mkOption {
type = lib.types.str;
description = ''
Git remote the deploy branch is pushed to. Not an easykubenix option --
declared here to show that a bootstrap instance reaches its parent's
whole config through `parent`, including options the user's own modules
add.
'';
};
config = {
clusterRepoURL = "https://github.com/example/cluster.git";
# Required, and no default. `ekn` stamps it as the `ekn.dev/environment`
# label on every object it applies, and that label is half the prune
# scope. The other half is `ekn.dev/deployment-unit`, which each
# deployment unit renders onto its own objects.
ekn.environment = "bootstrap-example";
deployment = {
enable = true;
deployBranch = "deploy";
units = {
# The ordinary target: ArgoCD syncs this one, and nothing here is ever
# applied by hand.
apps.path = "clusters/example/apps";
# The bootstrap target: its own easykubenix instance, rendered into
# `bootstrap/` and applied once with
# `ekn kubeapply --target bootstrap`. Its objects stay out of this
# instance's `kubernetes.generated`, so a plain `ekn kubeapply` and
# `ekn validate` never touch them.
bootstrap = {
path = "bootstrap";
modules = [ ./argocd.nix ];
# Apply as the controller that takes over, not as `ekn`. SSA only
# drops a field when its *owning* manager stops declaring it, and a
# bootstrap apply never runs again -- so as a distinct manager `ekn`
# would keep owning every field ArgoCD does not declare, forever.
# Same manager makes the handover complete instead, and removes the
# need for `Force=true` on the Application.
fieldManager = "argocd-controller";
# ...and make ArgoCD consider the objects its own at all, which is a
# separate mechanism from field ownership. `argocd` below is the
# Application in argocd.nix that syncs this very folder once ArgoCD
# is up: the annotation is what lets it adopt the hand-applied
# objects rather than see them as strangers it will never prune.
#
# A per-object function, not a constant, because the tracking-id
# encodes each object's own identity -- and a tracking-id naming a
# different object is worse than none at all, since ArgoCD then
# silently treats the object as belonging to something else.
annotations."argocd.argoproj.io/tracking-id" = ekn.lib.argocdTrackingId {
app = "argocd";
namespace = "argocd";
};
};
};
};
# An ordinary application, routed to the `apps` target the root Application
# in argocd.nix points at. This is the half that never needs a human.
kubernetes.objects.default.ConfigMap.hello = {
ekn.deploymentUnit = "apps";
data.greeting = "synced by argocd";
};
};
}
fieldManager and annotations above are the handover. Applying as
argocd-controller rather than as ekn is what lets server-side apply transfer
the fields: SSA only drops a field when its owning manager stops declaring it,
and a bootstrap apply never runs again, so a distinct ekn manager would keep
owning everything ArgoCD does not declare, forever. The tracking annotation is
the separate question of whether ArgoCD considers the object its own at all —
and it is per-object, because the id encodes the object's own
group/kind/namespace/name. ekn.lib.argocdTrackingId builds it and declines
CRDs, which ArgoCD never stamps and which would otherwise sit permanently
OutOfSync.
The bootstrap instance itself installs ArgoCD from its chart, declares the root Application, and — the other half of the handover — a second Application that adopts the bootstrap folder, so upgrading ArgoCD stops being a manual step:
# The bootstrap instance: a whole separate easykubenix configuration, attached
# to `deployment.units.bootstrap` by cluster.nix. Its objects render into that
# target's path and never reach the parent's `kubernetes.generated`.
#
# This is the chicken-and-egg half of GitOps. Everything here is what makes
# ArgoCD able to sync, so none of it can be synced by ArgoCD: the controller
# itself, its CRDs, and the root Application that points it at the branch the
# parent commits to. It goes down once with `ekn kubeapply --target bootstrap`
# and has a different lifecycle from everything else afterwards.
{
pkgs,
parent,
...
}:
{
# `Application` is a custom resource, so it is not in the default
# `apiMappingFile`. Declared here, in the instance that uses it -- the parent
# never renders one and has no reason to know the mapping.
kubernetes.apiMappings.Application = "argoproj.io/v1alpha1";
# `ekn kubeapply` applies barriers in `ekn.resourcePriority` order, and a
# Namespace sorts at 20 -- before everything in here that goes into it. The
# chart does not create it, and neither does anything else: this is a
# bootstrap, so nothing has run before it.
kubernetes.objects.none.Namespace.argocd = { };
helm.releases.argo-cd = {
chart = pkgs.fetchHelm {
repo = "https://argoproj.github.io/argo-helm";
chart = "argo-cd";
version = "7.7.11";
sha256 = "sha256-QpF9lpJ61OL7FhM8TDhkKThHGFjDmeOo0hAhKfptDyo=";
};
namespace = "argocd";
# The CRDs are the point of applying this by hand. `Application` below is
# an instance of one of them, and it applies in a later barrier than the
# CRDs that establish it -- which `ekn kubeapply` waits for.
includeCRDs = true;
noHooks = true;
values = {
# A bootstrap wants the controller and the repo-server, not the extras.
dex.enabled = false;
notifications.enabled = false;
applicationSet.enabled = false;
# Declared rather than inherited. ArgoCD 3.0 changed this default from
# `label` to `annotation`, and the bootstrap target stamps
# `argocd.argoproj.io/tracking-id` on the objects below -- which only
# means anything under `annotation`. Pinning it here keeps the two ends
# agreeing no matter which chart version this is, instead of making the
# adoption silently depend on it.
#
# `annotation`, not `annotation+label`: the extra label is informational
# under that mode (ArgoCD still tracks by the annotation), it is
# truncated to 63 characters, and it collides with the
# `app.kubernetes.io/instance` Helm charts set to their own release
# name -- including this one. It is only worth adding if something else
# in the cluster reads that label.
configs.cm."application.resourceTrackingMethod" = "annotation";
};
};
# The root Application: the handover point. Everything past this is synced by
# ArgoCD from the branch `ekn commit` writes, so it never needs applying by
# hand again.
#
# `parent` is the outer instance's evaluated config, which is the whole
# reason a bootstrap config needs it -- the branch and path it must sync are
# declared up there, and repeating them here by hand is exactly the drift
# this avoids. `clusterRepoURL` is an option cluster.nix declares itself, so
# this reaches the user's own options too, not just easykubenix' built-ins.
#
# Read the parent's *inputs* like these. Reading its rendered outputs
# (`kubernetes.generated`, `kubernetes.deploymentUnits`) closes a loop back
# through this instance and recurses.
kubernetes.objects.argocd.Application.root = {
spec = {
project = "default";
source = {
repoURL = parent.clusterRepoURL;
targetRevision = parent.deployment.deployBranch;
path = parent.deployment.units.apps.path;
};
destination = {
server = "https://kubernetes.default.svc";
namespace = "default";
};
syncPolicy.automated = {
prune = true;
selfHeal = true;
};
};
};
# ArgoCD adopting its own installation. This is what makes the tracking
# annotation on cluster.nix's bootstrap target mean something: every object
# in this file is rendered into `bootstrap/` and applied once by hand, and
# this Application then syncs that same folder from then on. Because the
# hand-applied copies already carry `tracking-id: argocd:...`, ArgoCD adopts
# them instead of ignoring them -- and because they were applied as
# `argocd-controller`, it inherits their fields outright rather than
# conflicting over them.
#
# Without this, upgrading ArgoCD would mean re-running the bootstrap by
# hand, and any object the bootstrap creates that a later revision drops
# would linger forever with nothing that would ever prune it.
kubernetes.objects.argocd.Application.argocd = {
spec = {
project = "default";
source = {
repoURL = parent.clusterRepoURL;
targetRevision = parent.deployment.deployBranch;
path = parent.deployment.units.bootstrap.path;
};
destination = {
server = "https://kubernetes.default.svc";
# Matches the `namespace` given to `argocdTrackingId`: it is the
# fallback the tracking-id records for cluster-scoped objects, so the
# two must agree or ArgoCD reads the ids as naming something else.
namespace = "argocd";
};
syncPolicy.automated = {
# `prune = false`, unlike the root Application. Pruning here is
# ArgoCD deleting its own controller the moment this folder is
# misrendered, and it would be doing it to itself -- so it could not
# recover without another hand bootstrap.
prune = false;
selfHeal = true;
};
};
};
}
parent is the outer instance's evaluated config. That is what keeps the root
Application's targetRevision and path in agreement with the branch and
target the parent actually commits to, instead of the two being written out
twice and drifting. It reaches user-declared options too — clusterRepoURL
above is declared by cluster.nix, not by easykubenix. Read the parent's
inputs through it; reading its rendered outputs (kubernetes.generated,
kubernetes.deploymentUnits) closes a loop back through the nested instance and
recurses.
Because the nested instance is a complete easykubenix instance, it has its own
validationScript — which is the only way these objects get an API server at
all, kubernetes.generated excluding them by design:
nix run --file ./nix packages.bootstrapValidationScript
That applies ArgoCD's three CRDs, waits for them to become Established, and
then applies the Application that depends on them — in a later barrier,
because an unlisted kind sorts at 1000 while CustomResourceDefinition sorts
at 150. Pruning scopes itself to ekn.dev/deployment-unit=bootstrap, the
unit's own rendered label, so a bootstrap apply can never reach anything the
main configuration owns.