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

value.effect = "NoExecute"

that field changed, every other field of the element untouched, no mkForce needed

a field the element does not have

added

value = lib.mkForce { ... }

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.