From e502e0ff76aba5812d8aeea644dbfd74b345e8eb Mon Sep 17 00:00:00 2001 From: Konstantin Grachev Date: Tue, 15 Sep 2026 22:21:43 +0300 Subject: [PATCH] Initial commit --- .editorconfig | 22 + .gitattributes | 10 + .github/labels.yaml | 9 + .github/release.yaml | 5 + .../e2e/cilium-network-policy.yaml | 12 + .github/template-tests/e2e/cluster.sh | 291 +++++++++ .github/template-tests/e2e/cluster.toml.tmpl | 22 + .github/template-tests/e2e/network.yaml.tmpl | 42 ++ .github/template-tests/e2e/node.toml.tmpl | 7 + .../template-tests/e2e/sops-secret.yaml.tmpl | 8 + .github/template-tests/e2e/talos-cluster.yaml | 21 + .../template-tests/invalid/bad-bgp-asn.toml | 34 ++ .../invalid/bad-mac-address.toml | 29 + .../template-tests/invalid/bad-repo-url.toml | 30 + .../template-tests/invalid/bad-vlan-tag.toml | 30 + .../invalid/duplicate-gateway-addrs.toml | 29 + .../invalid/duplicate-node-names.toml | 37 ++ .../invalid/gateway-node-collision.toml | 29 + .../invalid/missing-dns-token.toml | 29 + .../invalid/missing-external-gateway.toml | 28 + .../invalid/missing-known-hosts.toml | 30 + .../invalid/missing-schematic.toml | 28 + .../invalid/nested-cidr-overlap.toml | 30 + .../invalid/node-addr-outside-cidr.toml | 29 + .../invalid/node-uses-gateway-addr.toml | 30 + .../invalid/non-canonical-cidr.toml | 30 + .../invalid/overlapping-cidrs.toml | 29 + .../template-tests/invalid/partial-bgp.toml | 34 ++ .../invalid/reserved-node-name.toml | 29 + .../template-tests/invalid/tiny-svc-cidr.toml | 33 + .../invalid/tunnel-without-dns.toml | 32 + .github/template-tests/valid/direct.toml | 38 ++ .github/template-tests/valid/internal.toml | 34 ++ .../valid/multi-controller.toml | 42 ++ .github/template-tests/valid/no-webhook.toml | 36 ++ .github/template-tests/valid/private.toml | 39 ++ .github/template-tests/valid/public.toml | 59 ++ .github/template-tests/valid/selfhosted.toml | 39 ++ .github/template-tests/valid/single-node.toml | 26 + .github/workflows/flate.yaml | 61 ++ .github/workflows/label-sync.yaml | 32 + .github/workflows/template-e2e-cluster.yaml | 103 ++++ .github/workflows/template-e2e.yaml | 176 ++++++ .github/workflows/template-release.yaml | 58 ++ .gitignore | 25 + .lefthook.toml | 36 ++ .mise/conf.d/template.toml | 5 + .mise/config.toml | 35 ++ .mise/mise.lock | 577 ++++++++++++++++++ .oxfmtrc.json | 3 + .renovaterc.json5 | 79 +++ .vscode/extensions.json | 9 + .vscode/settings.json | 18 + LICENSE | 21 + README.md | 474 ++++++++++++++ cluster.sample.toml | 292 +++++++++ cluster.schema.json | 433 +++++++++++++ justfile | 34 ++ makejinja.toml | 19 + pyproject.toml | 16 + template/config/.sops.yaml.j2 | 12 + .../config/bootstrap/deploy-key.sops.yaml.j2 | 15 + .../config/bootstrap/helmfile/apps.yaml.j2 | 60 ++ .../config/bootstrap/helmfile/crds.yaml.j2 | 37 ++ .../config/bootstrap/helmfile/default.yaml.j2 | 8 + .../helmfile/templates/release.yaml.gotmpl.j2 | 3 + .../helmfile/templates/values.yaml.gotmpl.j2 | 1 + template/config/bootstrap/mod.just | 105 ++++ .../config/bootstrap/sops-age.sops.yaml.j2 | 8 + .../cert-manager/app/clusterissuer.yaml.j2 | 52 ++ .../cert-manager/app/helmrelease.yaml.j2 | 20 + .../cert-manager/app/kustomization.yaml.j2 | 10 + .../cert-manager/app/ocirepository.yaml.j2 | 13 + .../cert-manager/app/secret.sops.yaml.j2 | 9 + .../apps/cert-manager/cert-manager/ks.yaml.j2 | 30 + .../apps/cert-manager/kustomization.yaml.j2 | 11 + .../apps/cert-manager/namespace.yaml.j2 | 7 + .../apps/default/echo/app/helmrelease.yaml.j2 | 31 + .../default/echo/app/kustomization.yaml.j2 | 6 + .../default/echo/app/ocirepository.yaml.j2 | 13 + .../kubernetes/apps/default/echo/ks.yaml.j2 | 19 + .../apps/default/kustomization.yaml.j2 | 11 + .../kubernetes/apps/default/namespace.yaml.j2 | 7 + .../flux-instance/app/helmrelease.yaml.j2 | 136 +++++ .../flux-instance/app/httproute.yaml.j2 | 22 + .../flux-instance/app/kustomization.yaml.j2 | 11 + .../flux-instance/app/ocirepository.yaml.j2 | 13 + .../flux-instance/app/receiver.yaml.j2 | 21 + .../flux-instance/app/secret.sops.yaml.j2 | 9 + .../apps/flux-system/flux-instance/ks.yaml.j2 | 21 + .../flux-operator/app/helmrelease.yaml.j2 | 13 + .../flux-operator/app/kustomization.yaml.j2 | 6 + .../flux-operator/app/ocirepository.yaml.j2 | 13 + .../apps/flux-system/flux-operator/ks.yaml.j2 | 19 + .../apps/flux-system/kustomization.yaml.j2 | 12 + .../apps/flux-system/namespace.yaml.j2 | 7 + .../cilium/app/helmrelease.yaml.j2 | 91 +++ .../cilium/app/kustomization.yaml.j2 | 7 + .../kube-system/cilium/app/networks.yaml.j2 | 71 +++ .../cilium/app/ocirepository.yaml.j2 | 13 + .../apps/kube-system/cilium/ks.yaml.j2 | 19 + .../coredns/app/helmrelease.yaml.j2 | 69 +++ .../coredns/app/kustomization.yaml.j2 | 6 + .../coredns/app/ocirepository.yaml.j2 | 13 + .../apps/kube-system/coredns/ks.yaml.j2 | 19 + .../apps/kube-system/kustomization.yaml.j2 | 17 + .../metrics-server/app/helmrelease.yaml.j2 | 21 + .../metrics-server/app/kustomization.yaml.j2 | 6 + .../metrics-server/app/ocirepository.yaml.j2 | 13 + .../kube-system/metrics-server/ks.yaml.j2 | 19 + .../apps/kube-system/namespace.yaml.j2 | 7 + .../reloader/app/helmrelease.yaml.j2 | 17 + .../reloader/app/kustomization.yaml.j2 | 6 + .../reloader/app/ocirepository.yaml.j2 | 13 + .../apps/kube-system/reloader/ks.yaml.j2 | 19 + .../spegel/app/helmrelease.yaml.j2 | 21 + .../spegel/app/kustomization.yaml.j2 | 8 + .../spegel/app/ocirepository.yaml.j2 | 15 + .../apps/kube-system/spegel/ks.yaml.j2 | 21 + .../cloudflare-dns/app/helmrelease.yaml.j2 | 37 ++ .../cloudflare-dns/app/kustomization.yaml.j2 | 9 + .../cloudflare-dns/app/ocirepository.yaml.j2 | 15 + .../cloudflare-dns/app/secret.sops.yaml.j2 | 9 + .../apps/network/cloudflare-dns/ks.yaml.j2 | 21 + .../cloudflare-tunnel/app/dnsendpoint.yaml.j2 | 12 + .../cloudflare-tunnel/app/helmrelease.yaml.j2 | 86 +++ .../app/kustomization.yaml.j2 | 10 + .../app/ocirepository.yaml.j2 | 15 + .../cloudflare-tunnel/app/secret.sops.yaml.j2 | 9 + .../apps/network/cloudflare-tunnel/ks.yaml.j2 | 21 + .../envoy-gateway/app/certificate.yaml.j2 | 18 + .../network/envoy-gateway/app/envoy.yaml.j2 | 174 ++++++ .../envoy-gateway/app/helmrelease.yaml.j2 | 20 + .../envoy-gateway/app/kustomization.yaml.j2 | 9 + .../envoy-gateway/app/ocirepository.yaml.j2 | 13 + .../envoy-gateway/app/podmonitor.yaml.j2 | 18 + .../apps/network/envoy-gateway/ks.yaml.j2 | 19 + .../k8s-gateway/app/helmrelease.yaml.j2 | 21 + .../k8s-gateway/app/kustomization.yaml.j2 | 6 + .../k8s-gateway/app/ocirepository.yaml.j2 | 13 + .../apps/network/k8s-gateway/ks.yaml.j2 | 19 + .../apps/network/kustomization.yaml.j2 | 18 + .../kubernetes/apps/network/namespace.yaml.j2 | 7 + .../sops/cluster-secrets.sops.yaml.j2 | 7 + .../components/sops/kustomization.yaml.j2 | 5 + .../config/kubernetes/flux/cluster/ks.yaml.j2 | 57 ++ template/config/kubernetes/mod.just | 10 + template/config/talos/README.md.j2 | 20 + .../config/talos/all/00-install.yaml.tpl.j2 | 9 + .../config/talos/all/01-hostname.yaml.tpl.j2 | 4 + template/config/talos/all/10-cluster.yaml.j2 | 10 + .../talos/all/20-network-links.yaml.tpl.j2 | 41 ++ template/config/talos/all/21-network.yaml.j2 | 8 + template/config/talos/all/22-time.yaml.j2 | 7 + template/config/talos/all/30-kubelet.yaml.j2 | 16 + template/config/talos/all/40-sysctls.yaml.j2 | 12 + template/config/talos/all/50-files.yaml.j2 | 8 + .../talos/all/60-encryption.yaml.tpl.j2 | 20 + .../talos/all/61-kernel-modules.yaml.tpl.j2 | 6 + template/config/talos/all/70-security.yaml.j2 | 7 + .../config/talos/all/71-filesystem.yaml.j2 | 9 + .../talos/control-plane/00-cluster.yaml.j2 | 62 ++ template/config/talos/mod.just | 64 ++ template/config/talos/topf.yaml.j2 | 33 + template/mod.just | 182 ++++++ template/overrides/readme.partial.yaml.j2 | 5 + template/resources/kubeconform.sh | 50 ++ template/scripts/plugin.py | 150 +++++ template/scripts/test_validate.py | 173 ++++++ template/scripts/validate.py | 402 ++++++++++++ uv.lock | 365 +++++++++++ 171 files changed, 7520 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/labels.yaml create mode 100644 .github/release.yaml create mode 100644 .github/template-tests/e2e/cilium-network-policy.yaml create mode 100755 .github/template-tests/e2e/cluster.sh create mode 100644 .github/template-tests/e2e/cluster.toml.tmpl create mode 100644 .github/template-tests/e2e/network.yaml.tmpl create mode 100644 .github/template-tests/e2e/node.toml.tmpl create mode 100644 .github/template-tests/e2e/sops-secret.yaml.tmpl create mode 100644 .github/template-tests/e2e/talos-cluster.yaml create mode 100644 .github/template-tests/invalid/bad-bgp-asn.toml create mode 100644 .github/template-tests/invalid/bad-mac-address.toml create mode 100644 .github/template-tests/invalid/bad-repo-url.toml create mode 100644 .github/template-tests/invalid/bad-vlan-tag.toml create mode 100644 .github/template-tests/invalid/duplicate-gateway-addrs.toml create mode 100644 .github/template-tests/invalid/duplicate-node-names.toml create mode 100644 .github/template-tests/invalid/gateway-node-collision.toml create mode 100644 .github/template-tests/invalid/missing-dns-token.toml create mode 100644 .github/template-tests/invalid/missing-external-gateway.toml create mode 100644 .github/template-tests/invalid/missing-known-hosts.toml create mode 100644 .github/template-tests/invalid/missing-schematic.toml create mode 100644 .github/template-tests/invalid/nested-cidr-overlap.toml create mode 100644 .github/template-tests/invalid/node-addr-outside-cidr.toml create mode 100644 .github/template-tests/invalid/node-uses-gateway-addr.toml create mode 100644 .github/template-tests/invalid/non-canonical-cidr.toml create mode 100644 .github/template-tests/invalid/overlapping-cidrs.toml create mode 100644 .github/template-tests/invalid/partial-bgp.toml create mode 100644 .github/template-tests/invalid/reserved-node-name.toml create mode 100644 .github/template-tests/invalid/tiny-svc-cidr.toml create mode 100644 .github/template-tests/invalid/tunnel-without-dns.toml create mode 100644 .github/template-tests/valid/direct.toml create mode 100644 .github/template-tests/valid/internal.toml create mode 100644 .github/template-tests/valid/multi-controller.toml create mode 100644 .github/template-tests/valid/no-webhook.toml create mode 100644 .github/template-tests/valid/private.toml create mode 100644 .github/template-tests/valid/public.toml create mode 100644 .github/template-tests/valid/selfhosted.toml create mode 100644 .github/template-tests/valid/single-node.toml create mode 100644 .github/workflows/flate.yaml create mode 100644 .github/workflows/label-sync.yaml create mode 100644 .github/workflows/template-e2e-cluster.yaml create mode 100644 .github/workflows/template-e2e.yaml create mode 100644 .github/workflows/template-release.yaml create mode 100644 .gitignore create mode 100644 .lefthook.toml create mode 100644 .mise/conf.d/template.toml create mode 100644 .mise/config.toml create mode 100644 .mise/mise.lock create mode 100644 .oxfmtrc.json create mode 100644 .renovaterc.json5 create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cluster.sample.toml create mode 100644 cluster.schema.json create mode 100644 justfile create mode 100644 makejinja.toml create mode 100644 pyproject.toml create mode 100644 template/config/.sops.yaml.j2 create mode 100644 template/config/bootstrap/deploy-key.sops.yaml.j2 create mode 100644 template/config/bootstrap/helmfile/apps.yaml.j2 create mode 100644 template/config/bootstrap/helmfile/crds.yaml.j2 create mode 100644 template/config/bootstrap/helmfile/default.yaml.j2 create mode 100644 template/config/bootstrap/helmfile/templates/release.yaml.gotmpl.j2 create mode 100644 template/config/bootstrap/helmfile/templates/values.yaml.gotmpl.j2 create mode 100644 template/config/bootstrap/mod.just create mode 100644 template/config/bootstrap/sops-age.sops.yaml.j2 create mode 100644 template/config/kubernetes/apps/cert-manager/cert-manager/app/clusterissuer.yaml.j2 create mode 100644 template/config/kubernetes/apps/cert-manager/cert-manager/app/helmrelease.yaml.j2 create mode 100644 template/config/kubernetes/apps/cert-manager/cert-manager/app/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/cert-manager/cert-manager/app/ocirepository.yaml.j2 create mode 100644 template/config/kubernetes/apps/cert-manager/cert-manager/app/secret.sops.yaml.j2 create mode 100644 template/config/kubernetes/apps/cert-manager/cert-manager/ks.yaml.j2 create mode 100644 template/config/kubernetes/apps/cert-manager/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/cert-manager/namespace.yaml.j2 create mode 100644 template/config/kubernetes/apps/default/echo/app/helmrelease.yaml.j2 create mode 100644 template/config/kubernetes/apps/default/echo/app/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/default/echo/app/ocirepository.yaml.j2 create mode 100644 template/config/kubernetes/apps/default/echo/ks.yaml.j2 create mode 100644 template/config/kubernetes/apps/default/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/default/namespace.yaml.j2 create mode 100644 template/config/kubernetes/apps/flux-system/flux-instance/app/helmrelease.yaml.j2 create mode 100644 template/config/kubernetes/apps/flux-system/flux-instance/app/httproute.yaml.j2 create mode 100644 template/config/kubernetes/apps/flux-system/flux-instance/app/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/flux-system/flux-instance/app/ocirepository.yaml.j2 create mode 100644 template/config/kubernetes/apps/flux-system/flux-instance/app/receiver.yaml.j2 create mode 100644 template/config/kubernetes/apps/flux-system/flux-instance/app/secret.sops.yaml.j2 create mode 100644 template/config/kubernetes/apps/flux-system/flux-instance/ks.yaml.j2 create mode 100644 template/config/kubernetes/apps/flux-system/flux-operator/app/helmrelease.yaml.j2 create mode 100644 template/config/kubernetes/apps/flux-system/flux-operator/app/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/flux-system/flux-operator/app/ocirepository.yaml.j2 create mode 100644 template/config/kubernetes/apps/flux-system/flux-operator/ks.yaml.j2 create mode 100644 template/config/kubernetes/apps/flux-system/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/flux-system/namespace.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/cilium/app/helmrelease.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/cilium/app/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/cilium/app/networks.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/cilium/app/ocirepository.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/cilium/ks.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/coredns/app/helmrelease.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/coredns/app/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/coredns/app/ocirepository.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/coredns/ks.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/metrics-server/app/helmrelease.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/metrics-server/app/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/metrics-server/app/ocirepository.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/metrics-server/ks.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/namespace.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/reloader/app/helmrelease.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/reloader/app/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/reloader/app/ocirepository.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/reloader/ks.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/spegel/app/helmrelease.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/spegel/app/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/spegel/app/ocirepository.yaml.j2 create mode 100644 template/config/kubernetes/apps/kube-system/spegel/ks.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/cloudflare-dns/app/helmrelease.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/cloudflare-dns/app/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/cloudflare-dns/app/ocirepository.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/cloudflare-dns/app/secret.sops.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/cloudflare-dns/ks.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/cloudflare-tunnel/app/dnsendpoint.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/cloudflare-tunnel/app/helmrelease.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/cloudflare-tunnel/app/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/cloudflare-tunnel/app/ocirepository.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/cloudflare-tunnel/app/secret.sops.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/cloudflare-tunnel/ks.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/envoy-gateway/app/certificate.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/envoy-gateway/app/envoy.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/envoy-gateway/app/helmrelease.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/envoy-gateway/app/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/envoy-gateway/app/ocirepository.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/envoy-gateway/app/podmonitor.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/envoy-gateway/ks.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/k8s-gateway/app/helmrelease.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/k8s-gateway/app/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/k8s-gateway/app/ocirepository.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/k8s-gateway/ks.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/apps/network/namespace.yaml.j2 create mode 100644 template/config/kubernetes/components/sops/cluster-secrets.sops.yaml.j2 create mode 100644 template/config/kubernetes/components/sops/kustomization.yaml.j2 create mode 100644 template/config/kubernetes/flux/cluster/ks.yaml.j2 create mode 100644 template/config/kubernetes/mod.just create mode 100644 template/config/talos/README.md.j2 create mode 100644 template/config/talos/all/00-install.yaml.tpl.j2 create mode 100644 template/config/talos/all/01-hostname.yaml.tpl.j2 create mode 100644 template/config/talos/all/10-cluster.yaml.j2 create mode 100644 template/config/talos/all/20-network-links.yaml.tpl.j2 create mode 100644 template/config/talos/all/21-network.yaml.j2 create mode 100644 template/config/talos/all/22-time.yaml.j2 create mode 100644 template/config/talos/all/30-kubelet.yaml.j2 create mode 100644 template/config/talos/all/40-sysctls.yaml.j2 create mode 100644 template/config/talos/all/50-files.yaml.j2 create mode 100644 template/config/talos/all/60-encryption.yaml.tpl.j2 create mode 100644 template/config/talos/all/61-kernel-modules.yaml.tpl.j2 create mode 100644 template/config/talos/all/70-security.yaml.j2 create mode 100644 template/config/talos/all/71-filesystem.yaml.j2 create mode 100644 template/config/talos/control-plane/00-cluster.yaml.j2 create mode 100644 template/config/talos/mod.just create mode 100644 template/config/talos/topf.yaml.j2 create mode 100644 template/mod.just create mode 100644 template/overrides/readme.partial.yaml.j2 create mode 100644 template/resources/kubeconform.sh create mode 100644 template/scripts/plugin.py create mode 100644 template/scripts/test_validate.py create mode 100644 template/scripts/validate.py create mode 100644 uv.lock diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fd65cad --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +; https://editorconfig.org/ + +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.cue] +indent_style = tab +indent_size = 4 + +[*.md] +indent_size = 4 +trim_trailing_whitespace = false + +[*.sh] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2b39659 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text=auto eol=lf +*.env linguist-detectable linguist-language=SHELL +*.json linguist-detectable linguist-language=JSON +*.json5 linguist-detectable linguist-language=JSON5 +*.md linguist-detectable linguist-language=MARKDOWN +*.sh linguist-detectable linguist-language=SHELL +*.toml linguist-detectable linguist-language=TOML +*.yml linguist-detectable linguist-language=YAML +*.yaml linguist-detectable linguist-language=YAML +*.yaml.j2 linguist-detectable linguist-language=YAML diff --git a/.github/labels.yaml b/.github/labels.yaml new file mode 100644 index 0000000..49fad78 --- /dev/null +++ b/.github/labels.yaml @@ -0,0 +1,9 @@ +--- +- name: type/digest + color: ffeC19 +- name: type/patch + color: ffeC19 +- name: type/minor + color: ff9800 +- name: type/major + color: f6412d diff --git a/.github/release.yaml b/.github/release.yaml new file mode 100644 index 0000000..c32986c --- /dev/null +++ b/.github/release.yaml @@ -0,0 +1,5 @@ +changelog: + exclude: + authors: + - github-actions + - renovate diff --git a/.github/template-tests/e2e/cilium-network-policy.yaml b/.github/template-tests/e2e/cilium-network-policy.yaml new file mode 100644 index 0000000..c3e02bf --- /dev/null +++ b/.github/template-tests/e2e/cilium-network-policy.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: e2e-deny-server + namespace: default +spec: + endpointSelector: + matchLabels: + app: e2e-network-server + ingress: + - {} diff --git a/.github/template-tests/e2e/cluster.sh b/.github/template-tests/e2e/cluster.sh new file mode 100755 index 0000000..601f637 --- /dev/null +++ b/.github/template-tests/e2e/cluster.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +# Full-fidelity bootstrap e2e: takes maintenance-mode Talos VMs, discovers +# their hardware the same way the README instructs users to, writes a +# cluster.toml from the discovered facts, and runs the template's real +# bootstrap flow against them. +# +# Two provisioning paths share this test body: +# - CI: talosctl-cluster-action boots the nodes (talos-cluster.yaml) and +# passes E2E_CONTROLPLANE_IPS / E2E_WORKER_IPS / E2E_CIDR; the action's +# post step destroys them. +# - Local: run with no env set; the script boots and destroys the cluster +# itself. Requires Docker, /dev/kvm, passwordless sudo, qemu-system-x86, +# and the repo's mise toolchain on PATH. +# +# Renders into the working tree like any configure run. +set -euo pipefail + +NAME="${E2E_NAME:-template-e2e}" +MODE="${1:-all}" +E2E_DIR=".github/template-tests/e2e" +CIDR="${E2E_CIDR:-10.9.0.0/24}" +PREFIX="${CIDR%/*}" +PREFIX="${PREFIX%.*}" +TALOSCTL="$(command -v talosctl)" +# The Image Factory vanilla schematic, matching the ISO the nodes boot from. +SCHEMATIC="376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" + +if [ -n "${E2E_CONTROLPLANE_IPS:-}" ]; then + PROVISIONED=true + IFS=',' read -r -a CONTROLPLANES <<< "$E2E_CONTROLPLANE_IPS" + IFS=',' read -r -a WORKERS <<< "${E2E_WORKER_IPS:-}" +else + PROVISIONED=false + CONTROLPLANES=("$PREFIX.2") + WORKERS=("$PREFIX.3") +fi +NODES=("${CONTROLPLANES[@]}" "${WORKERS[@]}") +# The VMs reach the host at the gateway address; the rendered workspace is +# served from there over git smart HTTP so Flux can sync it. +GIT_HOST="${E2E_GATEWAY:-$PREFIX.1}" +GIT_PORT=8418 +GIT_SERVER_CONTAINER="" + +# The provisioner runs under sudo and writes state relative to its cwd and +# TALOSCONFIG, so both are pointed at a scratch dir to keep root-owned files +# out of the repo. +if [ -n "${E2E_STATE:-}" ]; then + STATE="$E2E_STATE" + STATE_OWNED=false +else + STATE="$(mktemp -d)" + STATE_OWNED=true +fi +mkdir -p "$STATE" +GIT_PUSH_URL="http://127.0.0.1:$GIT_PORT/repo.git" + +cleanup() { + rc=$? + if [ "$rc" -ne 0 ]; then + echo "==> e2e failed (rc=$rc), collecting diagnostics" + kubectl get pods --all-namespaces 2>/dev/null || true + kubectl get gitrepositories,kustomizations,helmreleases --all-namespaces 2>/dev/null || true + kubectl get events --all-namespaces --sort-by=.lastTimestamp 2>/dev/null | tail -30 || true + [ -n "$GIT_SERVER_CONTAINER" ] && docker logs --tail 5 "$GIT_SERVER_CONTAINER" 2>/dev/null || true + for ip in "${NODES[@]}"; do + talosctl -n "$ip" dmesg 2>/dev/null | tail -20 || true + done + fi + if [ "$MODE" = all ]; then + [ -n "$GIT_SERVER_CONTAINER" ] && docker stop "$GIT_SERVER_CONTAINER" >/dev/null 2>&1 || true + fi + if [ "$MODE" = all ] && [ "$PROVISIONED" = false ]; then + (cd "$STATE" && sudo -E env TALOSCONFIG="$STATE/talosconfig" \ + "$TALOSCTL" cluster destroy --name "$NAME" --provisioner qemu >/dev/null 2>&1) || true + fi + if [ "$MODE" = all ] && [ "$STATE_OWNED" = true ]; then + sudo rm -rf "$STATE" || true + fi + exit "$rc" +} +trap cleanup EXIT + +start_local_git_server() { + GIT_SERVER_CONTAINER="$NAME-git" + docker run --detach --rm --name "$GIT_SERVER_CONTAINER" \ + --publish "$GIT_PORT:23232" \ + --env SOFT_SERVE_GIT_ENABLED=false \ + --env SOFT_SERVE_LFS_ENABLED=false \ + --env SOFT_SERVE_SSH_LISTEN_ADDR=127.0.0.1:23231 \ + --env SOFT_SERVE_STATS_ENABLED=false \ + --entrypoint /bin/sh \ + ghcr.io/charmbracelet/soft-serve:v0.11.6 \ + -c 'set -eu; ssh-keygen -q -t ed25519 -N "" -f /tmp/admin; export SOFT_SERVE_INITIAL_ADMIN_KEYS="$(cat /tmp/admin.pub)"; /usr/local/bin/soft serve & pid=$!; until ssh -q -i /tmp/admin -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -p 23231 localhost settings anon-access read-write; do sleep 1; done; ssh -q -i /tmp/admin -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -p 23231 localhost repo create repo; wait "$pid"' \ + >/dev/null + deadline=$((SECONDS + 60)) + until git ls-remote "$GIT_PUSH_URL" >/dev/null 2>&1; do + if (( SECONDS >= deadline )); then + just log fatal "Soft Serve is not reachable" + fi + sleep 1 + done +} + +prepare() { +# In CI the action itself waits for every node's maintenance API before +# returning, so the poll here covers only the local path, where cluster +# create returns as soon as the VMs launch. +if [ "$PROVISIONED" = false ]; then + echo "==> booting maintenance-mode nodes" + (cd "$STATE" && sudo -E env TALOSCONFIG="$STATE/talosconfig" \ + "$TALOSCTL" cluster create qemu --name "$NAME" --presets iso,maintenance \ + --controlplanes 1 --workers 1 --cidr "$CIDR" \ + --memory-controlplanes 4GiB --memory-workers 3GiB) + + echo "==> waiting for the maintenance API" + for ip in "${NODES[@]}"; do + until talosctl -n "$ip" get links --insecure >/dev/null 2>&1; do sleep 5; done + done +fi + +echo "==> discovering node hardware" +declare -A MACS DISKS +for ip in "${NODES[@]}"; do + MACS[$ip]="$(talosctl -n "$ip" get links --insecure -o json \ + | jq -r 'select(.spec.type == "ether" and .spec.operationalState == "up" and (.metadata.id | startswith("bond") | not)) | .spec.hardwareAddr' | head -1)" + DISKS[$ip]="/dev/$(talosctl -n "$ip" get disks --insecure -o json \ + | jq -r 'select(.spec.readonly == false and (.metadata.id | startswith("loop") | not)) | .metadata.id' | head -1)" + echo " $ip mac=${MACS[$ip]} disk=${DISKS[$ip]}" +done + +echo "==> generating cluster.toml" +export E2E_CIDR="$CIDR" +export E2E_GATEWAY="${E2E_GATEWAY:-$PREFIX.1}" +export E2E_GIT_HOST="$GIT_HOST" +export E2E_GIT_PORT="$GIT_PORT" +export E2E_PREFIX="$PREFIX" +export E2E_SCHEMATIC="$SCHEMATIC" +envsubst '${E2E_CIDR} ${E2E_GATEWAY} ${E2E_GIT_HOST} ${E2E_GIT_PORT} ${E2E_PREFIX} ${E2E_SCHEMATIC}' \ + < "$E2E_DIR/cluster.toml.tmpl" > cluster.toml +index=0 +for ip in "${NODES[@]}"; do + controller=false + for cp in "${CONTROLPLANES[@]}"; do [ "$ip" = "$cp" ] && controller=true; done + export E2E_NODE_NAME="e2e-$index" + export E2E_NODE_ADDRESS="$ip" + export E2E_NODE_CONTROLLER="$controller" + export E2E_NODE_DISK="${DISKS[$ip]}" + export E2E_NODE_MAC="${MACS[$ip]}" + envsubst '${E2E_NODE_ADDRESS} ${E2E_NODE_CONTROLLER} ${E2E_NODE_DISK} ${E2E_NODE_MAC} ${E2E_NODE_NAME}' \ + < "$E2E_DIR/node.toml.tmpl" >> cluster.toml + index=$((index + 1)) +done + +echo "==> configure" +just init +just configure + +# Flux's FluxInstance only reports Ready once its Git sync succeeds, so the +# rendered kubernetes/ tree is committed to a bare repo and served to the +# cluster — the same push-then-bootstrap flow the README walks users through. +echo "==> publishing rendered repo" +mkdir -p "$STATE/gitwork" +cp -r kubernetes "$STATE/gitwork/" +git -C "$STATE/gitwork" init --quiet --initial-branch main +git -C "$STATE/gitwork" add --all +git -C "$STATE/gitwork" -c user.name=e2e -c user.email=e2e@cluster.local \ + commit --quiet --message "rendered workspace" +git -C "$STATE/gitwork" push --quiet "$GIT_PUSH_URL" main +} + +assert_cluster_health() { +echo "==> asserting cluster health" +kubectl wait nodes --all --for=condition=Ready --timeout=10m +for ns in kube-system cert-manager flux-system; do + kubectl wait pods --namespace "$ns" --all --for=condition=Ready --timeout=10m +done + +echo "==> asserting flux reconciliation" +kubectl wait fluxinstance/flux --namespace flux-system --for=condition=Ready --timeout=10m +kubectl wait gitrepositories --all --all-namespaces --for=condition=Ready --timeout=5m +kubectl wait kustomizations --all --all-namespaces --for=condition=Ready --timeout=10m +kubectl wait helmreleases --all --all-namespaces --for=condition=Ready --timeout=10m +} + +foundation() { +deadline=$((SECONDS + 60)) +until git ls-remote "http://$GIT_HOST:$GIT_PORT/repo.git" >/dev/null 2>&1; do + if (( SECONDS >= deadline )); then + just log fatal "Rendered repository server is not reachable" + fi + sleep 1 +done +echo "==> bootstrap talos" +just bootstrap talos + +echo "==> bootstrap apps" +just bootstrap apps +assert_cluster_health + +echo "==> asserting bootstrap idempotency" +just configure +just bootstrap talos +just bootstrap apps +assert_cluster_health +} + +flux_sops() { +echo "==> asserting Flux SOPS decryption" +SOPS_SECRET="$STATE/gitwork/kubernetes/apps/default/e2e-sops.sops.yaml" +export E2E_SOPS_VALUE=flux-decrypted +envsubst '${E2E_SOPS_VALUE}' < "$E2E_DIR/sops-secret.yaml.tmpl" > "$SOPS_SECRET" +sops encrypt --filename-override kubernetes/apps/default/e2e-sops.sops.yaml \ + --in-place "$SOPS_SECRET" +yq --inplace '.resources += ["./e2e-sops.sops.yaml"]' \ + "$STATE/gitwork/kubernetes/apps/default/kustomization.yaml" +git -C "$STATE/gitwork" add --all +git -C "$STATE/gitwork" -c user.name=e2e -c user.email=e2e@cluster.local \ + commit --quiet --message "test Flux SOPS decryption" +git -C "$STATE/gitwork" push --quiet "$GIT_PUSH_URL" main +flux reconcile kustomization cluster-apps --with-source --timeout=10m +test "$(kubectl get secret e2e-sops --namespace default \ + --output jsonpath='{.data.value}' | base64 --decode)" = "flux-decrypted" +} + +networking() { +echo "==> asserting pod networking and DNS" +export E2E_CONTROLPLANE_NODE="$(kubectl get nodes \ + --selector=node-role.kubernetes.io/control-plane \ + --output jsonpath='{.items[0].metadata.name}')" +export E2E_WORKER_NODE="$(kubectl get nodes \ + --selector='!node-role.kubernetes.io/control-plane' \ + --output jsonpath='{.items[0].metadata.name}')" +NETWORK_CONFIG="$STATE/network.yaml" +envsubst '${E2E_CONTROLPLANE_NODE} ${E2E_WORKER_NODE}' \ + < "$E2E_DIR/network.yaml.tmpl" > "$NETWORK_CONFIG" +kubectl apply --filename "$NETWORK_CONFIG" +kubectl wait pods/e2e-network-server pods/e2e-network-client \ + --namespace default --for=condition=Ready --timeout=5m +SERVER_IP="$(kubectl get pod e2e-network-server --namespace default \ + --output jsonpath='{.status.podIP}')" +kubectl exec --namespace default e2e-network-client -- \ + /agnhost connect --timeout=10s "$SERVER_IP:8080" +kubectl exec --namespace default e2e-network-client -- \ + /agnhost connect --timeout=10s e2e-network-server.default.svc.cluster.local:8080 +kubectl exec --namespace default e2e-network-client -- \ + /agnhost connect --timeout=10s github.com:443 +kubectl apply --filename "$E2E_DIR/cilium-network-policy.yaml" +deadline=$((SECONDS + 60)) +while kubectl exec --namespace default e2e-network-client -- \ + /agnhost connect --timeout=2s "$SERVER_IP:8080" &>/dev/null; do + if (( SECONDS >= deadline )); then + just log fatal "CiliumNetworkPolicy did not block pod traffic" + fi + sleep 2 +done +kubectl delete ciliumnetworkpolicy e2e-deny-server --namespace default +deadline=$((SECONDS + 60)) +until kubectl exec --namespace default e2e-network-client -- \ + /agnhost connect --timeout=2s "$SERVER_IP:8080" &>/dev/null; do + if (( SECONDS >= deadline )); then + just log fatal "Pod traffic did not recover after removing CiliumNetworkPolicy" + fi + sleep 2 +done +} + +summary() { +kubectl get nodes --output wide +kubectl get kustomizations,helmreleases --all-namespaces +echo "==> e2e bootstrap succeeded" +} + +case "$MODE" in + prepare) prepare ;; + foundation) foundation ;; + flux-sops) flux_sops ;; + networking) networking ;; + summary) summary ;; + all) + start_local_git_server + prepare + foundation + flux_sops + networking + summary + ;; + *) + echo "usage: $0 {prepare|foundation|flux-sops|networking|summary|all}" >&2 + exit 2 + ;; +esac diff --git a/.github/template-tests/e2e/cluster.toml.tmpl b/.github/template-tests/e2e/cluster.toml.tmpl new file mode 100644 index 0000000..387724a --- /dev/null +++ b/.github/template-tests/e2e/cluster.toml.tmpl @@ -0,0 +1,22 @@ +[network] +node_cidr = "${E2E_CIDR}" +default_gateway = "${E2E_GATEWAY}" + +[kubernetes.api] +addr = "${E2E_PREFIX}.100" + +[gateways] +internal = "${E2E_PREFIX}.101" +dns = "${E2E_PREFIX}.102" + +[domain] +name = "e2e.example.com" + +[dns] +provider = "none" + +[repository] +url = "http://${E2E_GIT_HOST}:${E2E_GIT_PORT}/repo.git" + +[talos] +schematic_id = "${E2E_SCHEMATIC}" diff --git a/.github/template-tests/e2e/network.yaml.tmpl b/.github/template-tests/e2e/network.yaml.tmpl new file mode 100644 index 0000000..07a862c --- /dev/null +++ b/.github/template-tests/e2e/network.yaml.tmpl @@ -0,0 +1,42 @@ +--- +apiVersion: v1 +kind: Pod +metadata: + name: e2e-network-server + namespace: default + labels: + app: e2e-network-server +spec: + nodeName: "${E2E_WORKER_NODE}" + containers: + - name: server + image: registry.k8s.io/e2e-test-images/agnhost:2.66.0 + args: ["netexec", "--http-port=8080"] + ports: + - name: http + containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: e2e-network-server + namespace: default +spec: + selector: + app: e2e-network-server + ports: + - name: http + port: 8080 + targetPort: http +--- +apiVersion: v1 +kind: Pod +metadata: + name: e2e-network-client + namespace: default +spec: + nodeName: "${E2E_CONTROLPLANE_NODE}" + containers: + - name: client + image: registry.k8s.io/e2e-test-images/agnhost:2.66.0 + args: ["pause"] diff --git a/.github/template-tests/e2e/node.toml.tmpl b/.github/template-tests/e2e/node.toml.tmpl new file mode 100644 index 0000000..3ebf9e2 --- /dev/null +++ b/.github/template-tests/e2e/node.toml.tmpl @@ -0,0 +1,7 @@ + +[[nodes]] +name = "${E2E_NODE_NAME}" +address = "${E2E_NODE_ADDRESS}" +controller = ${E2E_NODE_CONTROLLER} +disk = "${E2E_NODE_DISK}" +mac_addr = "${E2E_NODE_MAC}" diff --git a/.github/template-tests/e2e/sops-secret.yaml.tmpl b/.github/template-tests/e2e/sops-secret.yaml.tmpl new file mode 100644 index 0000000..0dec0ec --- /dev/null +++ b/.github/template-tests/e2e/sops-secret.yaml.tmpl @@ -0,0 +1,8 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: e2e-sops + namespace: default +stringData: + value: "${E2E_SOPS_VALUE}" diff --git a/.github/template-tests/e2e/talos-cluster.yaml b/.github/template-tests/e2e/talos-cluster.yaml new file mode 100644 index 0000000..2ad1566 --- /dev/null +++ b/.github/template-tests/e2e/talos-cluster.yaml @@ -0,0 +1,21 @@ +--- +# yaml-language-server: $schema=https://raw.githubusercontent.com/home-operations/talosctl-cluster-action/main/schema/talos-cluster.json +# Maintenance-mode nodes for the bootstrap e2e: the action boots and destroys +# them, and cluster.sh exercises the template's real bootstrap flow against +# the unconfigured nodes. +apiVersion: v1alpha1 +kind: TalosCluster +metadata: + name: template-e2e +spec: + controlplanes: + count: 1 + memory: 4GiB + workers: + count: 1 + memory: 3GiB + network: + cidr: 10.9.0.0/24 + qemu: + presets: [iso, maintenance] + disks: [virtio:10GiB] diff --git a/.github/template-tests/invalid/bad-bgp-asn.toml b/.github/template-tests/invalid/bad-bgp-asn.toml new file mode 100644 index 0000000..abd68d8 --- /dev/null +++ b/.github/template-tests/invalid/bad-bgp-asn.toml @@ -0,0 +1,34 @@ +# Negative fixture: BGP router ASN above the 32-bit ASN range. +# Expected to be rejected by _router_asn_in_range. +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[cilium.bgp] +router_addr = "10.10.1.1" +router_asn = "4294967296" +node_asn = "64514" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/bad-mac-address.toml b/.github/template-tests/invalid/bad-mac-address.toml new file mode 100644 index 0000000..d623202 --- /dev/null +++ b/.github/template-tests/invalid/bad-mac-address.toml @@ -0,0 +1,29 @@ +# Negative fixture: malformed MAC address (uppercase + missing colons). +# Expected to be rejected by the #Node.mac_addr regex constraint. +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "AABBCCDDEEFF" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/bad-repo-url.toml b/.github/template-tests/invalid/bad-repo-url.toml new file mode 100644 index 0000000..2ab4a7e --- /dev/null +++ b/.github/template-tests/invalid/bad-repo-url.toml @@ -0,0 +1,30 @@ +# Negative fixture: scp-style git URL ("git@host:owner/repo.git") instead of +# the canonical https:// or ssh://git@ form. +# Expected to be rejected by the repository.url pattern. +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[repository] +url = "git@github.com:onedr0p/cluster-template.git" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/bad-vlan-tag.toml b/.github/template-tests/invalid/bad-vlan-tag.toml new file mode 100644 index 0000000..846a794 --- /dev/null +++ b/.github/template-tests/invalid/bad-vlan-tag.toml @@ -0,0 +1,30 @@ +# Negative fixture: vlan_tag outside the valid 1-4094 range. +# Expected to be rejected by _vlan_tag_in_range. +[network] +node_cidr = "10.10.10.0/24" +vlan_tag = "5000" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/duplicate-gateway-addrs.toml b/.github/template-tests/invalid/duplicate-gateway-addrs.toml new file mode 100644 index 0000000..2f0be1a --- /dev/null +++ b/.github/template-tests/invalid/duplicate-gateway-addrs.toml @@ -0,0 +1,29 @@ +# Negative fixture: gateways.internal == gateways.dns. +# Expected to be rejected by `_addrs_check` (list.UniqueItems). +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.252" +external = "10.10.10.251" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/duplicate-node-names.toml b/.github/template-tests/invalid/duplicate-node-names.toml new file mode 100644 index 0000000..4836923 --- /dev/null +++ b/.github/template-tests/invalid/duplicate-node-names.toml @@ -0,0 +1,37 @@ +# Negative fixture: two nodes with the same name. +# Expected to be rejected by `_node_name_check` (list.UniqueItems). +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.101" +controller = false +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:01" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/gateway-node-collision.toml b/.github/template-tests/invalid/gateway-node-collision.toml new file mode 100644 index 0000000..c068e0c --- /dev/null +++ b/.github/template-tests/invalid/gateway-node-collision.toml @@ -0,0 +1,29 @@ +# Negative fixture: a node reuses the internal gateway VIP. +# Expected to be rejected by _addr_uniqueness_check. +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.252" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/missing-dns-token.toml b/.github/template-tests/invalid/missing-dns-token.toml new file mode 100644 index 0000000..352d923 --- /dev/null +++ b/.github/template-tests/invalid/missing-dns-token.toml @@ -0,0 +1,29 @@ +# Negative fixture: dns.provider "cloudflare" (the default) without a token. +# Expected to be rejected by the Dns validator. +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[domain] +name = "example.com" + +[dns] +provider = "cloudflare" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/missing-external-gateway.toml b/.github/template-tests/invalid/missing-external-gateway.toml new file mode 100644 index 0000000..1b996e3 --- /dev/null +++ b/.github/template-tests/invalid/missing-external-gateway.toml @@ -0,0 +1,28 @@ +# Negative fixture: cloudflare-tunnel ingress without gateways.external. +# Expected to be rejected by the Config cross-check. +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/missing-known-hosts.toml b/.github/template-tests/invalid/missing-known-hosts.toml new file mode 100644 index 0000000..2fe0595 --- /dev/null +++ b/.github/template-tests/invalid/missing-known-hosts.toml @@ -0,0 +1,30 @@ +# Negative fixture: ssh:// URL to a host without bundled SSH host keys +# (github.com/gitlab.com/codeberg.org) and no repository.known_hosts set. +# Expected to be rejected by the conditional known_hosts constraint. +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[repository] +url = "ssh://git@git.example.com/k8s/home-ops.git" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/missing-schematic.toml b/.github/template-tests/invalid/missing-schematic.toml new file mode 100644 index 0000000..fb08541 --- /dev/null +++ b/.github/template-tests/invalid/missing-schematic.toml @@ -0,0 +1,28 @@ +# Negative fixture: a node without schematic_id and no [talos] default. +# Expected to be rejected by the schematic resolution in Config. +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" diff --git a/.github/template-tests/invalid/nested-cidr-overlap.toml b/.github/template-tests/invalid/nested-cidr-overlap.toml new file mode 100644 index 0000000..fa8a61e --- /dev/null +++ b/.github/template-tests/invalid/nested-cidr-overlap.toml @@ -0,0 +1,30 @@ +# Negative fixture: node_cidr is nested inside the default pod_cidr +# (10.42.0.0/16) without being string-equal to it. +# Expected to be rejected by _cidr_overlap_check. +[network] +node_cidr = "10.42.128.0/17" + +[kubernetes.api] +addr = "10.42.128.254" + +[gateways] +internal = "10.42.128.252" +dns = "10.42.128.253" +external = "10.42.128.251" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "k8s-0" +address = "10.42.128.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/node-addr-outside-cidr.toml b/.github/template-tests/invalid/node-addr-outside-cidr.toml new file mode 100644 index 0000000..c161ffa --- /dev/null +++ b/.github/template-tests/invalid/node-addr-outside-cidr.toml @@ -0,0 +1,29 @@ +# Negative fixture: node address is not inside network.node_cidr. +# Expected to be rejected by _node_addrs_in_node_cidr. +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "k8s-0" +address = "192.168.1.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/node-uses-gateway-addr.toml b/.github/template-tests/invalid/node-uses-gateway-addr.toml new file mode 100644 index 0000000..28dd9bd --- /dev/null +++ b/.github/template-tests/invalid/node-uses-gateway-addr.toml @@ -0,0 +1,30 @@ +# Negative fixture: a node claims the default gateway address (defaults to +# the first IP in node_cidr). +# Expected to be rejected by _addr_uniqueness_check. +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.1" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/non-canonical-cidr.toml b/.github/template-tests/invalid/non-canonical-cidr.toml new file mode 100644 index 0000000..a94acbc --- /dev/null +++ b/.github/template-tests/invalid/non-canonical-cidr.toml @@ -0,0 +1,30 @@ +# Negative fixture: node_cidr written with host bits set instead of the +# network address. +# Expected to be rejected by _cidr_canonical_check. +[network] +node_cidr = "10.10.10.5/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/overlapping-cidrs.toml b/.github/template-tests/invalid/overlapping-cidrs.toml new file mode 100644 index 0000000..ec3f1e3 --- /dev/null +++ b/.github/template-tests/invalid/overlapping-cidrs.toml @@ -0,0 +1,29 @@ +# Negative fixture: network.node_cidr overlaps the default kubernetes.pod_cidr (10.42.0.0/16). +# Expected to be rejected by `_cidrs_check` (list.UniqueItems). +[network] +node_cidr = "10.42.0.0/16" + +[kubernetes.api] +addr = "10.42.0.254" + +[gateways] +internal = "10.42.0.252" +dns = "10.42.0.253" +external = "10.42.0.251" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "k8s-0" +address = "10.42.0.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/partial-bgp.toml b/.github/template-tests/invalid/partial-bgp.toml new file mode 100644 index 0000000..f9a5bd4 --- /dev/null +++ b/.github/template-tests/invalid/partial-bgp.toml @@ -0,0 +1,34 @@ +# Negative fixture: two of the three BGP fields set; previously this +# silently left BGP disabled. +# Expected to be rejected by the Bgp all-or-nothing check. +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[cilium.bgp] +router_addr = "10.10.1.1" +router_asn = "64513" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/reserved-node-name.toml b/.github/template-tests/invalid/reserved-node-name.toml new file mode 100644 index 0000000..a961742 --- /dev/null +++ b/.github/template-tests/invalid/reserved-node-name.toml @@ -0,0 +1,29 @@ +# Negative fixture: a node name uses the reserved word "controller". +# Expected to be rejected by the #Node.name regex constraint. +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "controller" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/tiny-svc-cidr.toml b/.github/template-tests/invalid/tiny-svc-cidr.toml new file mode 100644 index 0000000..54c641a --- /dev/null +++ b/.github/template-tests/invalid/tiny-svc-cidr.toml @@ -0,0 +1,33 @@ +# Negative fixture: svc_cidr too small to contain the derived CoreDNS +# address (10th IP). +# Expected to be rejected by _coredns_addr_in_svc_cidr. +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes] +svc_cidr = "10.43.0.0/29" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/invalid/tunnel-without-dns.toml b/.github/template-tests/invalid/tunnel-without-dns.toml new file mode 100644 index 0000000..93fd88f --- /dev/null +++ b/.github/template-tests/invalid/tunnel-without-dns.toml @@ -0,0 +1,32 @@ +# Negative fixture: cloudflare-tunnel ingress with dns.provider "none". +# Expected to be rejected by the Config cross-check. +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[domain] +name = "example.com" + +[dns] +provider = "none" + +[ingress] +mode = "cloudflare-tunnel" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/valid/direct.toml b/.github/template-tests/valid/direct.toml new file mode 100644 index 0000000..0ab192d --- /dev/null +++ b/.github/template-tests/valid/direct.toml @@ -0,0 +1,38 @@ +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[ingress] +mode = "direct" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" + +[[nodes]] +name = "k8s-1" +address = "10.10.10.101" +controller = false +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:01" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/valid/internal.toml b/.github/template-tests/valid/internal.toml new file mode 100644 index 0000000..e3e33d3 --- /dev/null +++ b/.github/template-tests/valid/internal.toml @@ -0,0 +1,34 @@ +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" + +[domain] +name = "example.com" + +[dns] +provider = "none" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" + +[[nodes]] +name = "k8s-1" +address = "10.10.10.101" +controller = false +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:01" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/valid/multi-controller.toml b/.github/template-tests/valid/multi-controller.toml new file mode 100644 index 0000000..71eb652 --- /dev/null +++ b/.github/template-tests/valid/multi-controller.toml @@ -0,0 +1,42 @@ +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" + +[repository] +url = "ssh://git@github.com/onedr0p/cluster-template.git" + +[domain] +name = "example.com" + +[dns] +provider = "none" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" + +[[nodes]] +name = "k8s-1" +address = "10.10.10.101" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:01" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" + +[[nodes]] +name = "k8s-2" +address = "10.10.10.102" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:02" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/valid/no-webhook.toml b/.github/template-tests/valid/no-webhook.toml new file mode 100644 index 0000000..a907294 --- /dev/null +++ b/.github/template-tests/valid/no-webhook.toml @@ -0,0 +1,36 @@ +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" +webhook_provider = "none" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" + +[[nodes]] +name = "k8s-1" +address = "10.10.10.101" +controller = false +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:01" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/valid/private.toml b/.github/template-tests/valid/private.toml new file mode 100644 index 0000000..b862618 --- /dev/null +++ b/.github/template-tests/valid/private.toml @@ -0,0 +1,39 @@ +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[repository] +url = "ssh://git@github.com/onedr0p/cluster-template.git" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" + +[[nodes]] +name = "k8s-1" +address = "10.10.10.101" +controller = false +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:01" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" +mtu = 1500 +secureboot = true +encrypt_disk = true +kernel_modules = ["nvidia", "nvidia_uvm"] diff --git a/.github/template-tests/valid/public.toml b/.github/template-tests/valid/public.toml new file mode 100644 index 0000000..8c427c7 --- /dev/null +++ b/.github/template-tests/valid/public.toml @@ -0,0 +1,59 @@ +[network] +node_cidr = "10.10.10.0/24" +default_gateway = "10.10.10.1" +vlan_tag = "100" +dns_servers = ["1.1.1.1"] +ntp_servers = ["162.159.200.123"] + +[kubernetes] +pod_cidr = "10.42.0.0/16" +svc_cidr = "10.43.0.0/16" + +[kubernetes.api] +addr = "10.10.10.254" +tls_sans = ["example.com"] + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" +branch = "main" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[cilium] +loadbalancer_mode = "dsr" + +[cilium.bgp] +router_addr = "10.10.1.1" +router_asn = "64513" +node_asn = "64514" + +[talos] +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" + +[[nodes]] +name = "k8s-1" +address = "10.10.10.101" +controller = false +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:01" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" +mtu = 1500 +secureboot = true +encrypt_disk = true +kernel_modules = ["nvidia", "nvidia_uvm"] diff --git a/.github/template-tests/valid/selfhosted.toml b/.github/template-tests/valid/selfhosted.toml new file mode 100644 index 0000000..0176450 --- /dev/null +++ b/.github/template-tests/valid/selfhosted.toml @@ -0,0 +1,39 @@ +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" +external = "10.10.10.251" + +[repository] +url = "ssh://git@git.example.com/k8s/home-ops.git" +webhook_provider = "generic-hmac" +known_hosts = """ +git.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl +""" + +[domain] +name = "example.com" + +[dns] +token = "fake" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" + +[[nodes]] +name = "k8s-1" +address = "10.10.10.101" +controller = false +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:01" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/template-tests/valid/single-node.toml b/.github/template-tests/valid/single-node.toml new file mode 100644 index 0000000..bda61ef --- /dev/null +++ b/.github/template-tests/valid/single-node.toml @@ -0,0 +1,26 @@ +[network] +node_cidr = "10.10.10.0/24" + +[kubernetes.api] +addr = "10.10.10.254" + +[gateways] +internal = "10.10.10.252" +dns = "10.10.10.253" + +[domain] +name = "example.com" + +[dns] +provider = "none" + +[repository] +url = "https://github.com/onedr0p/cluster-template.git" + +[[nodes]] +name = "k8s-0" +address = "10.10.10.100" +controller = true +disk = "/dev/sdfake" +mac_addr = "00:00:00:00:00:00" +schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" diff --git a/.github/workflows/flate.yaml b/.github/workflows/flate.yaml new file mode 100644 index 0000000..1634dfe --- /dev/null +++ b/.github/workflows/flate.yaml @@ -0,0 +1,61 @@ +--- +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +name: "Flate" + +on: + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.event.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + filter: + name: Flate - Filter + runs-on: ubuntu-latest + outputs: + changed-files: ${{ steps.changed-files.outputs.changed_files }} + steps: + - name: Get Changed Files + id: changed-files + uses: bjw-s-labs/action-changed-files@a9a36fb08ce06db9b02fbd8026cc2c0945eb9841 # v0.6.0 + with: + patterns: kubernetes/**/* + + flate: + if: ${{ needs.filter.outputs.changed-files != '[]' }} + needs: filter + name: Flate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Install Flate + uses: home-operations/flate/action@631b76b69c4e58c6f4d1cb01e23616fa61aebafa # v0.6.5 + + - name: Run Flate + id: flate + run: flate test all -p ./kubernetes/flux/cluster + + success: + if: ${{ !cancelled() }} + needs: flate + name: Flate - Success + runs-on: ubuntu-latest + steps: + - name: Any jobs failed? + if: ${{ contains(needs.*.result, 'failure') }} + run: exit 1 + + - name: All jobs passed or skipped? + if: ${{ !(contains(needs.*.result, 'failure')) }} + run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}" diff --git a/.github/workflows/label-sync.yaml b/.github/workflows/label-sync.yaml new file mode 100644 index 0000000..4cd210d --- /dev/null +++ b/.github/workflows/label-sync.yaml @@ -0,0 +1,32 @@ +--- +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +name: "Label Sync" + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - .github/labels.yaml + +permissions: {} + +jobs: + label-sync: + name: Label Sync + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Sync Labels + uses: EndBug/label-sync@52074158190acb45f3077f9099fea818aa43f97a # v2.3.3 + with: + config-file: .github/labels.yaml + delete-other-labels: true diff --git a/.github/workflows/template-e2e-cluster.yaml b/.github/workflows/template-e2e-cluster.yaml new file mode 100644 index 0000000..f22f9a9 --- /dev/null +++ b/.github/workflows/template-e2e-cluster.yaml @@ -0,0 +1,103 @@ +--- +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +name: "E2E Cluster" + +on: + workflow_dispatch: + pull_request: + branches: ["main"] + schedule: + - cron: "30 5 * * *" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + bootstrap: + if: ${{ github.repository == 'onedr0p/cluster-template' }} + name: bootstrap (qemu) + runs-on: ubuntu-latest + permissions: + contents: read + services: + git: + image: ghcr.io/charmbracelet/soft-serve:v0.12.2 + env: + SOFT_SERVE_GIT_ENABLED: "false" + SOFT_SERVE_LFS_ENABLED: "false" + SOFT_SERVE_SSH_LISTEN_ADDR: "127.0.0.1:23231" + SOFT_SERVE_STATS_ENABLED: "false" + ports: + - 8418:23232 + entrypoint: /bin/sh + command: >- + -c "set -eu; ssh-keygen -q -t ed25519 -N '' -f /tmp/admin; + export SOFT_SERVE_INITIAL_ADMIN_KEYS=$(cat /tmp/admin.pub); + /usr/local/bin/soft serve & pid=$!; + until ssh -q -i /tmp/admin -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -p 23231 localhost settings anon-access read-write; do sleep 1; done; + ssh -q -i /tmp/admin -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -p 23231 localhost repo create repo; + wait $pid" + options: >- + --health-cmd "git ls-remote http://localhost:23232/repo.git" + --health-interval 2s + --health-timeout 2s + --health-retries 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install QEMU + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends gettext-base qemu-system-x86 qemu-utils ovmf + + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + + - name: Boot maintenance-mode nodes + id: cluster + uses: home-operations/talosctl-cluster-action@fb6a31bf5de43218acc80d2e23958a16eee7380c # v0.2.2 + with: + config: ./.github/template-tests/e2e/talos-cluster.yaml + cache: true + + - name: Export cluster environment + env: + CONTROLPLANE_IPS: "${{ steps.cluster.outputs.controlplane-ips }}" + GATEWAY: "${{ steps.cluster.outputs.gateway }}" + WORKER_IPS: "${{ steps.cluster.outputs.worker-ips }}" + run: | + echo "E2E_CONTROLPLANE_IPS=$CONTROLPLANE_IPS" >> "$GITHUB_ENV" + echo "E2E_WORKER_IPS=$WORKER_IPS" >> "$GITHUB_ENV" + echo "E2E_GATEWAY=$GATEWAY" >> "$GITHUB_ENV" + echo "E2E_CIDR=10.9.0.0/24" >> "$GITHUB_ENV" + echo "E2E_STATE=$RUNNER_TEMP/template-e2e" >> "$GITHUB_ENV" + + - name: Prepare cluster + run: bash ./.github/template-tests/e2e/cluster.sh prepare + + - name: Build healthy cluster foundation + run: bash ./.github/template-tests/e2e/cluster.sh foundation + + - name: Test Flux SOPS + id: flux-sops + run: bash ./.github/template-tests/e2e/cluster.sh flux-sops + background: true + + - name: Test networking + id: networking + run: bash ./.github/template-tests/e2e/cluster.sh networking + background: true + + - name: Wait for E2E tests + wait: [flux-sops, networking] + + - name: Summarize cluster + run: bash ./.github/template-tests/e2e/cluster.sh summary diff --git a/.github/workflows/template-e2e.yaml b/.github/workflows/template-e2e.yaml new file mode 100644 index 0000000..097ad5c --- /dev/null +++ b/.github/workflows/template-e2e.yaml @@ -0,0 +1,176 @@ +--- +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +name: "E2E" + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.event.number || github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + validate-invalid: + if: ${{ github.repository == 'onedr0p/cluster-template' }} + name: reject-invalid (${{ matrix.fixture }}) + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + fixture: + - overlapping-cidrs + - nested-cidr-overlap + - non-canonical-cidr + - tiny-svc-cidr + - duplicate-gateway-addrs + - duplicate-node-names + - reserved-node-name + - bad-mac-address + - bad-repo-url + - missing-known-hosts + - node-addr-outside-cidr + - node-uses-gateway-addr + - gateway-node-collision + - bad-vlan-tag + - bad-bgp-asn + - missing-dns-token + - tunnel-without-dns + - missing-external-gateway + - missing-schematic + - partial-bgp + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + + - name: Assert schema rejects ${{ matrix.fixture }}.toml + run: | + fixture=./.github/template-tests/invalid/${{ matrix.fixture }}.toml + if uv run --quiet --locked --no-dev ./template/scripts/validate.py "$fixture" >/dev/null 2>&1; then + echo "::error::schema accepted invalid fixture ${{ matrix.fixture }} (expected rejection)" + exit 1 + fi + echo "schema correctly rejected ${{ matrix.fixture }}" + # Also surface the actual error message in the log for debuggability. + uv run --quiet --locked --no-dev ./template/scripts/validate.py "$fixture" || true + + validator-tests: + if: ${{ github.repository == 'onedr0p/cluster-template' }} + name: validator-tests + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + + - name: Run validator tests + run: uv run --quiet --locked pytest ./template/scripts/test_validate.py + + validate-valid: + if: ${{ github.repository == 'onedr0p/cluster-template' }} + name: accept-valid (${{ matrix.fixture }}) + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + fixture: + - public + - private + - selfhosted + - no-webhook + - internal + - direct + - single-node + - multi-controller + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup mise + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + experimental: true + install_args: --locked + + - name: Run init recipe + run: just init + + - name: Prepare files + run: | + cp ./.github/template-tests/valid/${{ matrix.fixture }}.toml cluster.toml + echo '{"AccountTag":"fake","TunnelSecret":"fake","TunnelID":"fake"}' > cloudflare-tunnel.json + touch kubeconfig + + - name: Assert cluster.toml passes the JSON Schema + run: taplo check --schema "file://$PWD/cluster.schema.json" ./cluster.toml + + - name: Run configure recipe + run: just configure + + # Rendered output must already match the format-yaml pre-commit hook, + # otherwise every `just configure` shows up as formatting churn. + - name: Assert rendered output is formatted + run: oxfmt --check ./.sops.yaml ./bootstrap ./kubernetes ./talos + + - name: Install flate + uses: home-operations/flate/action@631b76b69c4e58c6f4d1cb01e23616fa61aebafa # v0.6.5 + with: + base: "" + + - name: Run flate test + run: flate test all -p ./kubernetes/flux/cluster + + - name: Render bootstrap helmfile charts + run: just template test-helmfile + + - name: Dry run bootstrap talos recipe + run: just --dry-run bootstrap talos + + - name: Create talos secret + run: just bootstrap talos-secret + + - name: Render talos configs + run: just talos render + + - name: Validate talos configs + run: | + for config in ./talos/rendered/*.yaml; do + talosctl validate --config "$config" --mode metal + done + + - name: Dry run bootstrap apps recipe + run: just --dry-run bootstrap apps + + - name: Run reset recipe + run: yes | just template reset + + - name: Run tidy recipe + run: yes | just template tidy diff --git a/.github/workflows/template-release.yaml b/.github/workflows/template-release.yaml new file mode 100644 index 0000000..abb455e --- /dev/null +++ b/.github/workflows/template-release.yaml @@ -0,0 +1,58 @@ +--- +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +name: "Release" + +on: + workflow_dispatch: + schedule: + - cron: 0 0 1 * * + +permissions: {} + +jobs: + release: + name: Release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Get Previous Release Tag and Determine Next Tag + id: determine-next-tag + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + result-encoding: string + script: | + const { data: releases } = await github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 1, + }); + + let previousTag = "0.0.0"; // Default if no previous release exists + if (releases.length > 0) { + previousTag = releases[0].tag_name; + } + + const [previousMajor, previousMinor, previousPatch] = previousTag.split('.').map(Number); + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth() + 1; // Months are 0-indexed in JavaScript + + const nextMajorMinor = `${currentYear}.${currentMonth}`; + let nextPatch; + + if (`${previousMajor}.${previousMinor}` === nextMajorMinor) { + console.log("Month release already exists for the year. Incrementing patch number by 1."); + nextPatch = previousPatch + 1; + } else { + console.log("Month release does not exist for the year. Starting with patch number 0."); + nextPatch = 0; + } + + return `${nextMajorMinor}.${nextPatch}`; + + - name: Create Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.determine-next-tag.outputs.result }} + run: gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --generate-notes diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0247c90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Secrets +*.pub +*.key +*.decrypted~*.yaml +/age.key +/cloudflare-tunnel.json +/deploy.key +/deploy.key.pub +/flux-webhook-token.txt +# Template config files +/cluster.toml +# Kubernetes +kubeconfig +talosconfig +# Python +__pycache__/ +*.py[cod] +# Misc. +.claude/ +.private/ +.venv/ +.DS_Store +Thumbs.db +/talos/output/ +/talos/rendered/ diff --git a/.lefthook.toml b/.lefthook.toml new file mode 100644 index 0000000..d30eb0c --- /dev/null +++ b/.lefthook.toml @@ -0,0 +1,36 @@ +[pre-commit] +parallel = true +skip = ["merge", "rebase"] + +[pre-commit.commands.format-just] +glob = ["justfile", "*.just", ".justfile"] +run = 'for f in {staged_files}; do just --justfile "$f" --fmt; done' +stage_fixed = true + +[pre-commit.commands.format-mise] +glob = [".mise.toml", ".mise/config.toml", ".mise/conf.d/*.toml"] +run = "mise fmt" +stage_fixed = true + +[pre-commit.commands.format-json] +glob = ["*.json", "*.json5", "*.jsonc"] +run = "oxfmt {staged_files}" +stage_fixed = true + +[pre-commit.commands.format-markdown] +glob = ["*.md", "*.markdown", "*.mdx"] +run = "oxfmt {staged_files}" +stage_fixed = true + +[pre-commit.commands.format-yaml] +glob = ["*.yaml", "*.yml"] +run = "oxfmt {staged_files}" +stage_fixed = true + +[pre-commit.commands.mise-lock] +glob = [".mise.toml", ".mise/config.toml", ".mise/conf.d/*.toml"] +run = "mise lock && git add .mise/mise.lock" + +[pre-commit.commands.zizmor] +glob = [".github/workflows/*.yaml", ".github/actions/**/action.yaml"] +run = "zizmor --offline {staged_files}" diff --git a/.mise/conf.d/template.toml b/.mise/conf.d/template.toml new file mode 100644 index 0000000..fa07c43 --- /dev/null +++ b/.mise/conf.d/template.toml @@ -0,0 +1,5 @@ +# Tools only the template step needs; `just template tidy` archives this file. +[tools] +uv = "0.12.13" +sd = "1.1.0" +taplo = "0.10.0" diff --git a/.mise/config.toml b/.mise/config.toml new file mode 100644 index 0000000..be55fa4 --- /dev/null +++ b/.mise/config.toml @@ -0,0 +1,35 @@ +min_version = "2026.7.0" + +[env] +KUBECONFIG = "{{config_root}}/kubeconfig" +SOPS_CONFIG = "{{config_root}}/.sops.yaml" +SOPS_AGE_KEY_FILE = "{{config_root}}/age.key" +TALOSCONFIG = "{{config_root}}/talos/talosconfig" + +[tools] +age = "1.3.2" +cloudflared = "2026.9.1" +flux2 = "2.9.5" +gh = "2.100.0" +gum = "2.0.1" +helm = "4.3.0" +helmfile = "1.7.4" +jq = "1.8.2" +just = "1.58.0" +kubeconform = "0.8.0" +kubectl = "1.37.0" +kustomize = "5.8.1" +lefthook = "2.1.12" +node = "24.21.0" +oxfmt = "0.67.0" +sops = "3.13.3" +talosctl = "1.14.0" +"github:postfinance/topf" = "v0.6.0" +yq = "4.53.6" +zizmor = "1.30.1" + +[settings] +lockfile_platforms = ["linux-x64", "linux-arm64", "macos-arm64", "macos-x64"] + +[hooks] +postinstall = "lefthook install" diff --git a/.mise/mise.lock b/.mise/mise.lock new file mode 100644 index 0000000..f00e523 --- /dev/null +++ b/.mise/mise.lock @@ -0,0 +1,577 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html + +[[tools.age]] +version = "1.3.2" +backend = "aqua:FiloSottile/age" + +[tools.age."platforms.linux-arm64"] +checksum = "sha256:6b8dc4333c53a5a57c9e5834e3a48f92605d7154014cd07269ff3327db5d37f4" +url = "https://github.com/FiloSottile/age/releases/download/v1.3.2/age-v1.3.2-linux-arm64.tar.gz" +url_api = "https://api.github.com/repos/FiloSottile/age/releases/assets/535541932" +provenance = "github-attestations" + +[tools.age."platforms.linux-x64"] +checksum = "sha256:cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10" +url = "https://github.com/FiloSottile/age/releases/download/v1.3.2/age-v1.3.2-linux-amd64.tar.gz" +url_api = "https://api.github.com/repos/FiloSottile/age/releases/assets/535541903" +provenance = "github-attestations" + +[tools.age."platforms.macos-arm64"] +checksum = "sha256:e2020b073c44f692685a24d6abc378817eb81ffaaf49fd0531ef8565f767f2f5" +url = "https://github.com/FiloSottile/age/releases/download/v1.3.2/age-v1.3.2-darwin-arm64.tar.gz" +url_api = "https://api.github.com/repos/FiloSottile/age/releases/assets/535541897" +provenance = "github-attestations" + +[tools.age."platforms.macos-x64"] +checksum = "sha256:1d1e4bc66e1427edad7739ae7616157de0e79db8b6d2a1497d7d9925fb06a539" +url = "https://github.com/FiloSottile/age/releases/download/v1.3.2/age-v1.3.2-darwin-amd64.tar.gz" +url_api = "https://api.github.com/repos/FiloSottile/age/releases/assets/535541887" +provenance = "github-attestations" + +[[tools.cloudflared]] +version = "2026.9.1" +backend = "aqua:cloudflare/cloudflared" + +[tools.cloudflared."platforms.linux-arm64"] +checksum = "sha256:3d97437c71848bd8df68041e12436b484a661d95073ea1937f01a845ce88faa3" +url = "https://github.com/cloudflare/cloudflared/releases/download/2026.9.1/cloudflared-linux-arm64" +url_api = "https://api.github.com/repos/cloudflare/cloudflared/releases/assets/557330419" + +[tools.cloudflared."platforms.linux-x64"] +checksum = "sha256:03f1f25d1cc93b9ad6c60569d44060bc4f17ed97075760ed8cfca4b12dcd68cc" +url = "https://github.com/cloudflare/cloudflared/releases/download/2026.9.1/cloudflared-linux-amd64" +url_api = "https://api.github.com/repos/cloudflare/cloudflared/releases/assets/557330171" + +[tools.cloudflared."platforms.macos-arm64"] +checksum = "sha256:c27ab8fd0aa489449e3d201eb02f957ef460a13b613662928b1b23394bf1bcfe" +url = "https://github.com/cloudflare/cloudflared/releases/download/2026.9.1/cloudflared-darwin-arm64.tgz" +url_api = "https://api.github.com/repos/cloudflare/cloudflared/releases/assets/557329375" + +[tools.cloudflared."platforms.macos-x64"] +checksum = "sha256:ff0d3b51d5ff70eceef89d6b32145fee985018a2174596a5dbe405e2766e2ac4" +url = "https://github.com/cloudflare/cloudflared/releases/download/2026.9.1/cloudflared-darwin-amd64.tgz" +url_api = "https://api.github.com/repos/cloudflare/cloudflared/releases/assets/557330132" + +[[tools.flux2]] +version = "2.9.5" +backend = "aqua:fluxcd/flux2" + +[tools.flux2."platforms.linux-arm64"] +checksum = "sha256:f3e159af616ec0b9bd0a405c2185cf09d06b74652c1de3c7f377e8166826651a" +url = "https://github.com/fluxcd/flux2/releases/download/v2.9.5/flux_2.9.5_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/fluxcd/flux2/releases/assets/538282890" + +[tools.flux2."platforms.linux-arm64".provenance.slsa] +url = "https://github.com/fluxcd/flux2/releases/download/v2.9.5/provenance.intoto.jsonl" + +[tools.flux2."platforms.linux-x64"] +checksum = "sha256:b853df82adfd7736f580692f9f734473d571606307139f8fd20c2a80dd1ff473" +url = "https://github.com/fluxcd/flux2/releases/download/v2.9.5/flux_2.9.5_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/fluxcd/flux2/releases/assets/538282891" + +[tools.flux2."platforms.linux-x64".provenance.slsa] +url = "https://github.com/fluxcd/flux2/releases/download/v2.9.5/provenance.intoto.jsonl" + +[tools.flux2."platforms.macos-arm64"] +checksum = "sha256:2869ef7151a6f1b27e6b5d2a6804f3ef23c7bdaa06a74e00d3fe5bfc646547fd" +url = "https://github.com/fluxcd/flux2/releases/download/v2.9.5/flux_2.9.5_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/fluxcd/flux2/releases/assets/538282876" + +[tools.flux2."platforms.macos-arm64".provenance.slsa] +url = "https://github.com/fluxcd/flux2/releases/download/v2.9.5/provenance.intoto.jsonl" + +[tools.flux2."platforms.macos-x64"] +checksum = "sha256:5748583cf5da035ca2d751190d2c15f5f656d305c166ec28a312a2f3b6799e31" +url = "https://github.com/fluxcd/flux2/releases/download/v2.9.5/flux_2.9.5_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/fluxcd/flux2/releases/assets/538282892" + +[tools.flux2."platforms.macos-x64".provenance.slsa] +url = "https://github.com/fluxcd/flux2/releases/download/v2.9.5/provenance.intoto.jsonl" + +[[tools.gh]] +version = "2.100.0" +backend = "aqua:cli/cli" + +[tools.gh."platforms.linux-arm64"] +checksum = "sha256:ea4e7a581a32ccad6cc7923cb1576ac5859ba4b9a16ab22eb8f8a96e78e2e961" +url = "https://github.com/cli/cli/releases/download/v2.100.0/gh_2.100.0_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/542974279" +provenance = "github-attestations" + +[tools.gh."platforms.linux-x64"] +checksum = "sha256:e4d4bb4498e8d007abe545b6568926793ace1b6447da598294a610018cb164be" +url = "https://github.com/cli/cli/releases/download/v2.100.0/gh_2.100.0_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/542974264" +provenance = "github-attestations" + +[tools.gh."platforms.macos-arm64"] +checksum = "sha256:45f9a62da2f6e641a7fad57e2ce39656dfd7ef331372d80a2a2aed65abb01642" +url = "https://github.com/cli/cli/releases/download/v2.100.0/gh_2.100.0_macOS_arm64.zip" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/542974295" +provenance = "github-attestations" + +[tools.gh."platforms.macos-x64"] +checksum = "sha256:fcd7799e85eb575f3c7d2b1679bfbfedaefa1269d4bc7d096b51e10939b4812b" +url = "https://github.com/cli/cli/releases/download/v2.100.0/gh_2.100.0_macOS_amd64.zip" +url_api = "https://api.github.com/repos/cli/cli/releases/assets/542974293" +provenance = "github-attestations" + +[[tools."github:postfinance/topf"]] +version = "0.6.0" +backend = "github:postfinance/topf" + +[tools."github:postfinance/topf"."platforms.linux-arm64"] +checksum = "sha256:7e5d4bf21f07ba91b83c4653eb65dd3cf5ee67aa8fa4131ca403b38791476367" +url = "https://github.com/postfinance/topf/releases/download/v0.6.0/topf_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/postfinance/topf/releases/assets/542955869" + +[tools."github:postfinance/topf"."platforms.linux-x64"] +checksum = "sha256:458df4b25f4181a31ed361c0592194f7eb9e6e7b13e7096f8745453afdeaadfb" +url = "https://github.com/postfinance/topf/releases/download/v0.6.0/topf_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/postfinance/topf/releases/assets/542955866" + +[tools."github:postfinance/topf"."platforms.macos-arm64"] +checksum = "sha256:48b22175d61eadba0c287411c34a90f73dbaa716fd792b7b28625d3edbf870e2" +url = "https://github.com/postfinance/topf/releases/download/v0.6.0/topf_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/postfinance/topf/releases/assets/542955870" + +[tools."github:postfinance/topf"."platforms.macos-x64"] +checksum = "sha256:b4c76fb5985c2e0c73d6a365a5ae7a45f97f9d61cb502a01b1ee82e63da0b1cd" +url = "https://github.com/postfinance/topf/releases/download/v0.6.0/topf_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/postfinance/topf/releases/assets/542955865" + +[[tools.gum]] +version = "2.0.1" +backend = "aqua:charmbracelet/gum" + +[tools.gum."platforms.linux-arm64"] +checksum = "sha256:6998202a8fea27bb2007f69e44ec5dcb4cff5268c62d995de857eee0e2cd52cb" +url = "https://github.com/charmbracelet/gum/releases/download/v2.0.1/gum_2.0.1_Linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/charmbracelet/gum/releases/assets/556562997" +provenance = "cosign" + +[tools.gum."platforms.linux-x64"] +checksum = "sha256:4dfe4547f960813864c803b3617aa64427fa32ca566707fde949e08975297c48" +url = "https://github.com/charmbracelet/gum/releases/download/v2.0.1/gum_2.0.1_Linux_x86_64.tar.gz" +url_api = "https://api.github.com/repos/charmbracelet/gum/releases/assets/556563001" +provenance = "cosign" + +[tools.gum."platforms.macos-arm64"] +checksum = "sha256:994662daab6fcfe9dcfc57d87ca42bcae2948d86fa864f9010a3848fa9bb7d6e" +url = "https://github.com/charmbracelet/gum/releases/download/v2.0.1/gum_2.0.1_Darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/charmbracelet/gum/releases/assets/556563007" +provenance = "cosign" + +[tools.gum."platforms.macos-x64"] +checksum = "sha256:4d125b60fbaa28ef1674bb16f3859ed4e813acb027a79d24603b598ea4a0b71f" +url = "https://github.com/charmbracelet/gum/releases/download/v2.0.1/gum_2.0.1_Darwin_x86_64.tar.gz" +url_api = "https://api.github.com/repos/charmbracelet/gum/releases/assets/556562991" +provenance = "cosign" + +[[tools.helm]] +version = "4.3.0" +backend = "aqua:helm/helm" + +[tools.helm."platforms.linux-arm64"] +checksum = "sha256:31c5794dd55c66a51e6b7d2e2ac7a114ae8b1de41ff1d9ba51748ac973b06a08" +url = "https://get.helm.sh/helm-v4.3.0-linux-arm64.tar.gz" + +[tools.helm."platforms.linux-x64"] +checksum = "sha256:86584a54def73570558f66f5111cc53dfed56689637ae32c1201205d494f54fb" +url = "https://get.helm.sh/helm-v4.3.0-linux-amd64.tar.gz" + +[tools.helm."platforms.macos-arm64"] +checksum = "sha256:d3870437e1e95b67f8edbde964156c84a26503f560821d40c542441658934fba" +url = "https://get.helm.sh/helm-v4.3.0-darwin-arm64.tar.gz" + +[tools.helm."platforms.macos-x64"] +checksum = "sha256:347a784877e0e20eac865e8d1c36a80f6bb0861d6f29abd34defb6570ef95d92" +url = "https://get.helm.sh/helm-v4.3.0-darwin-amd64.tar.gz" + +[[tools.helmfile]] +version = "1.7.4" +backend = "aqua:helmfile/helmfile" + +[tools.helmfile."platforms.linux-arm64"] +checksum = "sha256:0292f57a4638a21e775b0ce8bde37ee3fdd65dbbdbe0e5b9a06718f1dd04c7fa" +url = "https://github.com/helmfile/helmfile/releases/download/v1.7.4/helmfile_1.7.4_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/helmfile/helmfile/releases/assets/516813268" + +[tools.helmfile."platforms.linux-x64"] +checksum = "sha256:f96ef0a015df06b29d7f38bf0ca08821018ae25eb96bf2c7abd3affa1b84e112" +url = "https://github.com/helmfile/helmfile/releases/download/v1.7.4/helmfile_1.7.4_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/helmfile/helmfile/releases/assets/516813296" + +[tools.helmfile."platforms.macos-arm64"] +checksum = "sha256:e1490d371fecc1f2d9aae914ef34058f3cb2363c9ee63350b37a85c3994d71d9" +url = "https://github.com/helmfile/helmfile/releases/download/v1.7.4/helmfile_1.7.4_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/helmfile/helmfile/releases/assets/516813297" + +[tools.helmfile."platforms.macos-x64"] +checksum = "sha256:7a0951fcc5bb991d7ea3a0c80e35754eb3981dcadba19795edbec7cba2518ae2" +url = "https://github.com/helmfile/helmfile/releases/download/v1.7.4/helmfile_1.7.4_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/helmfile/helmfile/releases/assets/516813301" + +[[tools.jq]] +version = "1.8.2" +backend = "aqua:jqlang/jq" + +[tools.jq."platforms.linux-arm64"] +checksum = "sha256:8b85c817833814ddca00a144c33705546355afccf0cf39b188f3cdb48b852309" +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-arm64" +url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012756" +provenance = "github-attestations" + +[tools.jq."platforms.linux-x64"] +checksum = "sha256:b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f" +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-amd64" +url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012752" +provenance = "github-attestations" + +[tools.jq."platforms.macos-arm64"] +checksum = "sha256:2d75340ba57a4b4b4c8708a21c2dc8e958a48aaa8bba13b27f77f6e4c0eca07e" +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-macos-arm64" +url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012783" +provenance = "github-attestations" + +[tools.jq."platforms.macos-x64"] +checksum = "sha256:e94b266e3c26690550006abe63152b782280f4e14374accdf04cbde844f00bc0" +url = "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-macos-amd64" +url_api = "https://api.github.com/repos/jqlang/jq/releases/assets/453012782" +provenance = "github-attestations" + +[[tools.just]] +version = "1.58.0" +backend = "aqua:casey/just" + +[tools.just."platforms.linux-arm64"] +checksum = "sha256:748237128c4c40cbdabc65e841d05ceba13cc23a91eaba395495894c1d9764df" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500510099" + +[tools.just."platforms.linux-x64"] +checksum = "sha256:4a5cc2f53e6f0f8c59092a6cc38291eb729d46a7dd95d3ae582008881b84931d" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500509978" + +[tools.just."platforms.macos-arm64"] +checksum = "sha256:50ae3e996c974a0bf32ea7d10f495070df33f1b43e0616b2769e3d4821ed8f48" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500509965" + +[tools.just."platforms.macos-x64"] +checksum = "sha256:9a09cfef66aaa79da58203970103a0684307716caaabd3e9844cacc4dc0f4023" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500510084" + +[[tools.kubeconform]] +version = "0.8.0" +backend = "aqua:yannh/kubeconform" + +[tools.kubeconform."platforms.linux-arm64"] +checksum = "sha256:1f53fc8e81258197a35e8603054162a5af1de8c5af13746c71ab680d9534ed87" +url = "https://github.com/yannh/kubeconform/releases/download/v0.8.0/kubeconform-linux-arm64.tar.gz" +url_api = "https://api.github.com/repos/yannh/kubeconform/releases/assets/438612685" + +[tools.kubeconform."platforms.linux-x64"] +checksum = "sha256:9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883" +url = "https://github.com/yannh/kubeconform/releases/download/v0.8.0/kubeconform-linux-amd64.tar.gz" +url_api = "https://api.github.com/repos/yannh/kubeconform/releases/assets/438612666" + +[tools.kubeconform."platforms.macos-arm64"] +checksum = "sha256:f84f4dfbebf4a6b0b230385fa065a39ea35e02608c2b50d025dcf64775a69d67" +url = "https://github.com/yannh/kubeconform/releases/download/v0.8.0/kubeconform-darwin-arm64.tar.gz" +url_api = "https://api.github.com/repos/yannh/kubeconform/releases/assets/438612680" + +[tools.kubeconform."platforms.macos-x64"] +checksum = "sha256:71dbc87ac9f24099a62b93570e65aa06312ba6ac8aea63b7f86e9d999edf5a92" +url = "https://github.com/yannh/kubeconform/releases/download/v0.8.0/kubeconform-darwin-amd64.tar.gz" +url_api = "https://api.github.com/repos/yannh/kubeconform/releases/assets/438612679" + +[[tools.kubectl]] +version = "1.37.0" +backend = "aqua:kubernetes/kubernetes/kubectl" + +[tools.kubectl."platforms.linux-arm64"] +checksum = "sha256:922df28df248cc00a9e025f947704f1d1482de64ece54cfe57e61f19eaf1eef3" +url = "https://dl.k8s.io/v1.37.0/bin/linux/arm64/kubectl" + +[tools.kubectl."platforms.linux-x64"] +checksum = "sha256:6129359f4e1f3848a5572ccb0b26cf28b8ca08cef38c95a765b2f64a2c961a2f" +url = "https://dl.k8s.io/v1.37.0/bin/linux/amd64/kubectl" + +[tools.kubectl."platforms.macos-arm64"] +checksum = "sha256:583beedaebe422e71d3f1a96acef8b1fef86ea2f09a45ad01aa6c9ce287c1380" +url = "https://dl.k8s.io/v1.37.0/bin/darwin/arm64/kubectl" + +[tools.kubectl."platforms.macos-x64"] +checksum = "sha256:d5276c0f4fde77fc446070290f345944a7f1fda153df6b960e5fde93b7a9bccd" +url = "https://dl.k8s.io/v1.37.0/bin/darwin/amd64/kubectl" + +[[tools.kustomize]] +version = "5.8.1" +backend = "aqua:kubernetes-sigs/kustomize" + +[tools.kustomize."platforms.linux-arm64"] +checksum = "sha256:0953ea3e476f66d6ddfcd911d750f5167b9365aa9491b2326398e289fef2c142" +url = "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize/v5.8.1/kustomize_v5.8.1_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/kubernetes-sigs/kustomize/releases/assets/353160971" + +[tools.kustomize."platforms.linux-x64"] +checksum = "sha256:029a7f0f4e1932c52a0476cf02a0fd855c0bb85694b82c338fc648dcb53a819d" +url = "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize/v5.8.1/kustomize_v5.8.1_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/kubernetes-sigs/kustomize/releases/assets/353160972" + +[tools.kustomize."platforms.macos-arm64"] +checksum = "sha256:8886f8a78474e608cc81234f729fda188a9767da23e28925802f00ece2bab288" +url = "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize/v5.8.1/kustomize_v5.8.1_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/kubernetes-sigs/kustomize/releases/assets/353160975" + +[tools.kustomize."platforms.macos-x64"] +checksum = "sha256:ee7cf0c1e3592aa7bb66ba82b359933a95e7f2e0b36e5f53ed0a4535b017f2f8" +url = "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize/v5.8.1/kustomize_v5.8.1_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/kubernetes-sigs/kustomize/releases/assets/353160974" + +[[tools.lefthook]] +version = "2.1.12" +backend = "aqua:evilmartians/lefthook" + +[tools.lefthook."platforms.linux-arm64"] +checksum = "sha256:d96ac16753f6d3d69b92098621c2a2427522a9c2c3e78cd7c9b4a9790b0b8f17" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.1.12/lefthook_2.1.12_Linux_aarch64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/533627819" +provenance = "github-attestations" + +[tools.lefthook."platforms.linux-x64"] +checksum = "sha256:dad908593c859d139b886c14913a44401d95288aa7642d9bdf6f3bd36bd788ff" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.1.12/lefthook_2.1.12_Linux_x86_64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/533627780" +provenance = "github-attestations" + +[tools.lefthook."platforms.macos-arm64"] +checksum = "sha256:f23eac328ca50c7b775dc24dc275cdeacb65e16cf8bd1745d18e3c563f704f94" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.1.12/lefthook_2.1.12_MacOS_arm64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/533627758" +provenance = "github-attestations" + +[tools.lefthook."platforms.macos-x64"] +checksum = "sha256:83c8591d338b1b789944480d9effe2807adc903cf27e4a16ab013e39d7723c31" +url = "https://github.com/evilmartians/lefthook/releases/download/v2.1.12/lefthook_2.1.12_MacOS_x86_64.gz" +url_api = "https://api.github.com/repos/evilmartians/lefthook/releases/assets/533627775" +provenance = "github-attestations" + +[[tools.node]] +version = "24.21.0" +backend = "core:node" + +[tools.node."platforms.linux-arm64"] +checksum = "sha256:724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.gz" + +[tools.node."platforms.linux-x64"] +checksum = "sha256:6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz" + +[tools.node."platforms.macos-arm64"] +checksum = "sha256:bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz" + +[tools.node."platforms.macos-x64"] +checksum = "sha256:1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz" + +[[tools.oxfmt]] +version = "0.67.0" +backend = "npm:oxfmt" + +[[tools.sd]] +version = "1.1.0" +backend = "aqua:chmln/sd" + +[tools.sd."platforms.linux-arm64"] +checksum = "sha256:ec8c93c0533ff21f4851d11566808d4082544baf063d9b96ea77c27e98b7cd99" +url = "https://github.com/chmln/sd/releases/download/v1.1.0/sd-v1.1.0-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/chmln/sd/releases/assets/362325535" + +[tools.sd."platforms.linux-x64"] +checksum = "sha256:3613eca74cd686739bb5a6d68319aa56c747e7315274d02323a2ca2b1c5d82d2" +url = "https://github.com/chmln/sd/releases/download/v1.1.0/sd-v1.1.0-x86_64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/chmln/sd/releases/assets/362325296" + +[tools.sd."platforms.macos-arm64"] +checksum = "sha256:4bd3c09226376ca0a1d69589c91e86276fae36c5fbaaee669afce583f6682030" +url = "https://github.com/chmln/sd/releases/download/v1.1.0/sd-v1.1.0-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/chmln/sd/releases/assets/362325451" + +[tools.sd."platforms.macos-x64"] +checksum = "sha256:1fca1e9c91813a8aac6821063c923107ba0f66a83309e095edcd3b202f67f97e" +url = "https://github.com/chmln/sd/releases/download/v1.1.0/sd-v1.1.0-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/chmln/sd/releases/assets/362325430" + +[[tools.sops]] +version = "3.13.3" +backend = "aqua:getsops/sops" + +[tools.sops."platforms.linux-arm64"] +checksum = "sha256:53b0abacd38ef1b12a66d6c100956691b9cefce018d91f81e73ddf7438b94d77" +url = "https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.linux.arm64" +url_api = "https://api.github.com/repos/getsops/sops/releases/assets/486808229" + +[tools.sops."platforms.linux-arm64".provenance.slsa] +url = "https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.intoto.jsonl" + +[tools.sops."platforms.linux-x64"] +checksum = "sha256:e5bec3346a873ae91d871550f3e698c1aad962aff462a080e40f25fde17fef6b" +url = "https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.linux.amd64" +url_api = "https://api.github.com/repos/getsops/sops/releases/assets/486808201" + +[tools.sops."platforms.linux-x64".provenance.slsa] +url = "https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.intoto.jsonl" + +[tools.sops."platforms.macos-arm64"] +checksum = "sha256:b97c0d434aab577dc40310e8d22ff9e45eef4c80638ab978daae9b4681c59286" +url = "https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.darwin.arm64" +url_api = "https://api.github.com/repos/getsops/sops/releases/assets/486808198" + +[tools.sops."platforms.macos-arm64".provenance.slsa] +url = "https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.intoto.jsonl" + +[tools.sops."platforms.macos-x64"] +checksum = "sha256:42162d5cef10b74fcf80a045a70e658d7ce6e63d6ea1be6f347e44015714468d" +url = "https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.darwin.amd64" +url_api = "https://api.github.com/repos/getsops/sops/releases/assets/486808228" + +[tools.sops."platforms.macos-x64".provenance.slsa] +url = "https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.intoto.jsonl" + +[[tools.talosctl]] +version = "1.14.0" +backend = "aqua:siderolabs/talos" + +[tools.talosctl."platforms.linux-arm64"] +checksum = "sha256:19615e1d0eb222de86ec2f1487e7d6e74f5171a9038e73aeacde8cc647e3d9e0" +url = "https://github.com/siderolabs/talos/releases/download/v1.14.0/talosctl-linux-arm64" +url_api = "https://api.github.com/repos/siderolabs/talos/releases/assets/542359013" +provenance = "cosign" + +[tools.talosctl."platforms.linux-x64"] +checksum = "sha256:2c147c4a99d124c95bd5c190fe054e0b3c93495f2243fd652ebd423adb8377c7" +url = "https://github.com/siderolabs/talos/releases/download/v1.14.0/talosctl-linux-amd64" +url_api = "https://api.github.com/repos/siderolabs/talos/releases/assets/542359015" +provenance = "cosign" + +[tools.talosctl."platforms.macos-arm64"] +checksum = "sha256:f0c65a0e970b6f23cf0160e432e7496ba93957a49f369780d4e53888ed66ef46" +url = "https://github.com/siderolabs/talos/releases/download/v1.14.0/talosctl-darwin-arm64" +url_api = "https://api.github.com/repos/siderolabs/talos/releases/assets/542359001" +provenance = "cosign" + +[tools.talosctl."platforms.macos-x64"] +checksum = "sha256:6563baa43774ef5c351e0d9f2fd720941c482da01fe3aed53ee9644b552453c5" +url = "https://github.com/siderolabs/talos/releases/download/v1.14.0/talosctl-darwin-amd64" +url_api = "https://api.github.com/repos/siderolabs/talos/releases/assets/542359004" +provenance = "cosign" + +[[tools.taplo]] +version = "0.10.0" +backend = "aqua:tamasfe/taplo" + +[tools.taplo."platforms.linux-arm64"] +url = "https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-linux-aarch64.gz" +url_api = "https://api.github.com/repos/tamasfe/taplo/releases/assets/257322597" + +[tools.taplo."platforms.linux-x64"] +url = "https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-linux-x86_64.gz" +url_api = "https://api.github.com/repos/tamasfe/taplo/releases/assets/257322600" + +[tools.taplo."platforms.macos-arm64"] +url = "https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-darwin-aarch64.gz" +url_api = "https://api.github.com/repos/tamasfe/taplo/releases/assets/257323110" + +[tools.taplo."platforms.macos-x64"] +url = "https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-darwin-x86_64.gz" +url_api = "https://api.github.com/repos/tamasfe/taplo/releases/assets/257323116" + +[[tools.uv]] +version = "0.12.13" +backend = "aqua:astral-sh/uv" + +[tools.uv."platforms.linux-arm64"] +checksum = "sha256:2eaa5d94f5db7b3a1a092156b9420459e42ab0217d917fe74a876309cef9b5e9" +url = "https://github.com/astral-sh/uv/releases/download/0.12.13/uv-aarch64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/555670186" +provenance = "github-attestations" + +[tools.uv."platforms.linux-x64"] +checksum = "sha256:745765a3b6e360ad76743599ae5c42e9278c7edf8bbff9fc76d05bf2623a04dd" +url = "https://github.com/astral-sh/uv/releases/download/0.12.13/uv-x86_64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/555670375" +provenance = "github-attestations" + +[tools.uv."platforms.macos-arm64"] +checksum = "sha256:7e6ddb9316acc00f2296c82ff4d99977870ee34b2f0ddcae9444d714db9364ed" +url = "https://github.com/astral-sh/uv/releases/download/0.12.13/uv-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/555670160" +provenance = "github-attestations" + +[tools.uv."platforms.macos-x64"] +checksum = "sha256:5e287ef61cb6a9b61b3a83fef124fd143e400468a7dac794230147a810e17119" +url = "https://github.com/astral-sh/uv/releases/download/0.12.13/uv-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/555670348" +provenance = "github-attestations" + +[[tools.yq]] +version = "4.53.6" +backend = "aqua:mikefarah/yq" + +[tools.yq."platforms.linux-arm64"] +checksum = "sha256:88a1016bc1d657375a35864e4f44b6f333df8ff97b559f51bba0adcb2169df09" +url = "https://github.com/mikefarah/yq/releases/download/v4.53.6/yq_linux_arm64" +url_api = "https://api.github.com/repos/mikefarah/yq/releases/assets/522028007" +provenance = "cosign" + +[tools.yq."platforms.linux-x64"] +checksum = "sha256:c5f056448f973ae7d39b5401949648a78f2dc1947d6a8eb65be60d5c504b9385" +url = "https://github.com/mikefarah/yq/releases/download/v4.53.6/yq_linux_amd64" +url_api = "https://api.github.com/repos/mikefarah/yq/releases/assets/522028022" +provenance = "cosign" + +[tools.yq."platforms.macos-arm64"] +checksum = "sha256:cceb0b8d71ea5294334121f8429f33f92b920e7217d904a2f9f35443968ac424" +url = "https://github.com/mikefarah/yq/releases/download/v4.53.6/yq_darwin_arm64" +url_api = "https://api.github.com/repos/mikefarah/yq/releases/assets/522028033" +provenance = "cosign" + +[tools.yq."platforms.macos-x64"] +checksum = "sha256:caa513cb04f3804b34d4752f0e0d7904fecb9e7cf1d34081289f83259319a7f6" +url = "https://github.com/mikefarah/yq/releases/download/v4.53.6/yq_darwin_amd64" +url_api = "https://api.github.com/repos/mikefarah/yq/releases/assets/522028031" +provenance = "cosign" + +[[tools.zizmor]] +version = "1.30.1" +backend = "aqua:zizmorcore/zizmor" + +[tools.zizmor."platforms.linux-arm64"] +checksum = "sha256:7ff1dce33bdd18fd2a4affe63bdd47efcccca97b2cec1c1863ec26e9e2647540" +url = "https://github.com/zizmorcore/zizmor/releases/download/v1.30.1/zizmor-aarch64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/zizmorcore/zizmor/releases/assets/552067643" +provenance = "github-attestations" + +[tools.zizmor."platforms.linux-x64"] +checksum = "sha256:e65324f4430c2717591937edcec90ccbefaf14c174f8ec9415e03ca875b46e1a" +url = "https://github.com/zizmorcore/zizmor/releases/download/v1.30.1/zizmor-x86_64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/zizmorcore/zizmor/releases/assets/552067642" +provenance = "github-attestations" + +[tools.zizmor."platforms.macos-arm64"] +checksum = "sha256:e28d22b087f9ebb8d99da6e740d348c930f559961c7c3f12badda54f882195a2" +url = "https://github.com/zizmorcore/zizmor/releases/download/v1.30.1/zizmor-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/zizmorcore/zizmor/releases/assets/552067640" +provenance = "github-attestations" + +[tools.zizmor."platforms.macos-x64"] +checksum = "sha256:10e6b18b11ea07e515a16f0f0518c7b07527bc9977c1fd5698181ce7f3554202" +url = "https://github.com/zizmorcore/zizmor/releases/download/v1.30.1/zizmor-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/zizmorcore/zizmor/releases/assets/552067641" +provenance = "github-attestations" diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..de753c5 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,3 @@ +{ + "printWidth": 100 +} diff --git a/.renovaterc.json5 b/.renovaterc.json5 new file mode 100644 index 0000000..f3afdad --- /dev/null +++ b/.renovaterc.json5 @@ -0,0 +1,79 @@ +{ + $schema: "https://docs.renovatebot.com/renovate-schema.json", + extends: ["github>home-operations/renovate-presets#8.1.0"], + schedule: ["every weekend"], + lockFileMaintenance: { + enabled: true, + schedule: ["before 3am on the first day of the month"], + commitMessageExtra: "({{manager}})", + additionalBranchPrefix: "{{manager}}-", + }, + mise: { + managerFilePatterns: ["/^\\.mise/conf\\.d/[^/]+\\.toml$/"], + }, + packageRules: [ + { + description: "GitHub Actions Group", + matchManagers: ["github-actions"], + groupName: "github-actions", + minimumReleaseAge: "3 days", + }, + { + description: "Mise Tools Group", + matchManagers: ["mise"], + groupName: "mise tools", + minimumReleaseAge: "3 days", + }, + { + description: "Talos Group", + groupName: "talos", + matchPackageNames: ["siderolabs/talos"], + group: { + commitMessageTopic: "{{{groupName}}} group", + }, + minimumGroupSize: 2, + }, + { + description: "Flux Operator Group", + groupName: "flux-operator", + matchDatasources: ["docker"], + matchPackageNames: ["/flux-operator/", "/flux-instance/"], + group: { + commitMessageTopic: "{{{groupName}}} group", + }, + minimumGroupSize: 2, + }, + { + matchUpdateTypes: ["major"], + addLabels: ["type/major"], + }, + { + matchUpdateTypes: ["minor"], + addLabels: ["type/minor"], + }, + { + matchUpdateTypes: ["patch"], + addLabels: ["type/patch"], + }, + { + matchUpdateTypes: ["digest"], + addLabels: ["type/digest"], + }, + { + matchDatasources: ["docker"], + addLabels: ["renovate/container"], + }, + { + matchDatasources: ["helm"], + addLabels: ["renovate/helm"], + }, + { + matchManagers: ["github-actions"], + addLabels: ["renovate/github-action"], + }, + { + matchDatasources: ["github-releases"], + addLabels: ["renovate/github-release"], + }, + ], +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..bd341bc --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "blueglassblock.better-json5", + "irongeek.vscode-env", + "redhat.vscode-yaml", + "signageos.signageos-vscode-sops", + "hverlin.mise-vscode" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..53fccd6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "editor.bracketPairColorization.enabled": true, + "files.associations": { + "**/*.json5": "json5" + }, + "files.trimTrailingWhitespace": true, + "sops.defaults.ageKeyFile": "age.key", + "vs-kubernetes": { + "vs-kubernetes.kubeconfig": "./kubeconfig", + "vs-kubernetes.knownKubeconfigs": [ + "./kubeconfig" + ] + }, + "yaml.schemaStore.enable": true, + "yaml.schemas": { + "kubernetes": "./kubernetes/**/*.yaml" + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..11a02c9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 onedr0p + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8085c6 --- /dev/null +++ b/README.md @@ -0,0 +1,474 @@ +# ⛵ Cluster Template + +Welcome to my template designed for deploying a single Kubernetes cluster. Whether you're setting up a cluster at home on bare-metal or virtual machines (VMs), this project aims to simplify the process and make Kubernetes more accessible. This template is inspired by my personal [home-ops](https://github.com/onedr0p/home-ops) repository, providing a practical starting point for anyone interested in managing their own Kubernetes environment. + +At its core, this project leverages [makejinja](https://github.com/mirkolenz/makejinja), a powerful tool for rendering templates. By reading the [cluster.toml](./cluster.sample.toml) configuration file—validated and defaulted by [pydantic](https://docs.pydantic.dev/)—Makejinja generates the necessary configurations to deploy a Kubernetes cluster with the following features: + +- Easy configuration through a single TOML file. +- Compatibility with home setups, whether on physical hardware or VMs. +- A modular and extensible approach to cluster deployment and management. + +With this approach, you'll gain a solid foundation to build and manage your Kubernetes cluster efficiently. + +## ✨ Features + +A Kubernetes cluster deployed with [Talos Linux](https://github.com/siderolabs/talos) and an opinionated implementation of [Flux](https://github.com/fluxcd/flux2) syncing from the Git provider of your choice (GitHub, GitLab, Gitea, Forgejo, Codeberg or self-hosted), [sops](https://github.com/getsops/sops) to manage secrets and [cloudflared](https://github.com/cloudflare/cloudflared) to access applications external to your local network. + +- **Required:** Some knowledge of [Containers](https://opencontainers.org/), [YAML](https://noyaml.com/), [Git](https://git-scm.com/), and a **domain**. Exposing apps to the public internet requires a **Cloudflare account**; internal-only clusters don't. +- **Included components:** [flux](https://github.com/fluxcd/flux2), [cilium](https://github.com/cilium/cilium), [cert-manager](https://github.com/cert-manager/cert-manager), [spegel](https://github.com/spegel-org/spegel), [reloader](https://github.com/stakater/Reloader), [envoy-gateway](https://github.com/envoyproxy/gateway), [external-dns](https://github.com/kubernetes-sigs/external-dns) and [cloudflared](https://github.com/cloudflare/cloudflared). + +**Other features include:** + +- Dev env managed w/ [mise](https://mise.jdx.dev/) +- Workflow automation w/ [GitHub Actions](https://github.com/features/actions) +- Dependency automation w/ [Renovate](https://www.mend.io/renovate) +- Flux `HelmRelease` and `Kustomization` diffs w/ [flate](https://github.com/home-operations/flate) + +Does this sound cool to you? If so, continue to read on! 👇 + +## 🚀 Let's Go! + +There are **6 stages** outlined below for completing this project, make sure you follow the stages in order. + +### Stage 1: Hardware Configuration + +For a **stable** and **high-availability** production Kubernetes cluster, hardware selection is critical. NVMe/SSDs are strongly preferred over HDDs, and **Bare Metal is strongly recommended** over virtualized platforms like Proxmox. + +Using **enterprise NVMe or SATA SSDs on Bare Metal** (even used drives) provides the most reliable performance and rock-solid stability. Consumer **NVMe or SATA SSDs**, on the other hand, carry risks such as latency spikes, corruption, and fsync delays, particularly in multi-node setups. + +**Proxmox with enterprise drives can work** for testing or carefully tuned production clusters, but it introduces additional layers of potential I/O contention — especially if consumer drives are used. Any **replicated storage** (e.g., Rook-Ceph, Longhorn) should always use **dedicated disks separate from control plane and etcd nodes** to ensure reliability. Worker nodes are more flexible, but risky configurations should still be avoided for stateful workloads to maintain cluster stability. + +These guidelines provide a strong baseline, but there are always exceptions and nuances. The best way to ensure your hardware configuration works is to **test it thoroughly and benchmark performance** under realistic workloads. + +### Stage 2: Machine Preparation + +> [!IMPORTANT] +> If you have **3 or more nodes** it is recommended to make 3 of them controller nodes for a highly available control plane. This project configures **all nodes** to be able to run workloads. **Worker nodes** are therefore **optional**. +> +> **Minimum system requirements** +> +> | Role | Cores | Memory | System Disk | +> | -------------- | ----- | ------ | -------------- | +> | Control/Worker | 4 | 16GB | 256GB SSD/NVMe | + +1. Head over to the [Talos Linux Image Factory](https://factory.talos.dev) and follow the instructions. Be sure to only choose the **bare-minimum system extensions** as some might require additional configuration and prevent Talos from booting without it. Depending on your CPU start with the Intel/AMD system extensions (`i915`, `intel-ucode` & `mei` **or** `amdgpu` & `amd-ucode`), you can always add system extensions after Talos is installed and working. + +2. This will eventually lead you to download a Talos Linux ISO (or for SBCs a RAW) image. Make sure to note the **schematic ID** you will need this later on. + +3. Flash the Talos ISO or RAW image to a USB drive and boot from it on your nodes. + +4. Verify with `nmap` that your nodes are available on the network. (Replace `192.168.1.0/24` with the network your nodes are on.) + + ```sh + nmap -Pn -n -p 50000 192.168.1.0/24 -vv | grep 'Discovered' + ``` + +### Stage 3: Local Workstation + +> [!TIP] +> It is recommended to set the visibility of your repository to `Public` so you can easily request help if you get stuck. + +1. Create a new repository by clicking the green `Use this template` button at the top of this page, then clone the new repo you just created and `cd` into it. Alternatively you can use the [GitHub CLI](https://cli.github.com/) ... + + ```sh + export REPONAME="home-ops" + gh repo create $REPONAME --template onedr0p/cluster-template --public --clone + cd $REPONAME + ``` + + 📍 _**Not using GitHub?** Any Git provider works (GitLab, Gitea, Forgejo, Codeberg or self-hosted). Create an empty repository on your provider, download this template with `git clone --depth 1 https://github.com/onedr0p/cluster-template`, re-initialize it with `git init` and push it to your repository._ + +2. **Install** the [Mise CLI](https://mise.jdx.dev/getting-started.html#installing-mise-cli) on your local workstation. + +3. **Activate** Mise in your shell by following the [activation guide](https://mise.jdx.dev/getting-started.html#activate-mise). + +4. Use `mise` to install the **required** CLI tools: + + ```sh + mise trust + mise install + ``` + + 📍 _**Having trouble installing the tools?** Try unsetting the `GITHUB_TOKEN` env var and then run these commands again_ + + 📍 _**Platforms:** `.mise/mise.lock` pins tool downloads for the platforms listed under `lockfile_platforms` in `.mise/config.toml`: Linux and macOS on amd64 and arm64 (`linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`). If you also need musl (e.g. Alpine) or Windows, add the platform to that list (`linux-x64-musl`, `linux-arm64-musl`, `windows-x64`), run `mise lock`, and commit both files. Your own platform is always locked, even when it is not in the list._ + +5. Logout of the GitHub Container Registry as this may cause authorization problems in future steps when using the public registry: + + ```sh + docker logout ghcr.io + helm registry logout ghcr.io + ``` + +### Stage 4: Cloudflare configuration + +> [!TIP] +> **Internal-only cluster?** Set `provider = "none"` under `[dns]` in `cluster.toml` and skip this stage entirely: no Cloudflare account, API token, or `cloudflare-tunnel.json` is needed. Nothing is exposed to the internet, apps are reachable on your LAN via the internal gateway, and the wildcard certificate is issued by an in-cluster self-signed CA instead of Let's Encrypt. + +> [!WARNING] +> If any of the commands fail with `command not found` or `unknown command` it means `mise` is either not installed, activated or it could be configured incorrectly. + +1. Create a Cloudflare API token for use with cloudflared and external-dns by reviewing the official [documentation](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/) and following the instructions below. + + - Click the blue `Use template` button for the `Edit zone DNS` template. + - Name your token `kubernetes` + - Under `Permissions`, click `+ Add More` and add permissions `Zone - DNS - Edit` and `Account - Cloudflare Tunnel - Read` + - Limit the permissions to a specific account and/or zone resources and then click `Continue to Summary` and then `Create Token`. + - **Save this token somewhere safe**, you will need it later on. + +2. Create the Cloudflare Tunnel: + + ```sh + cloudflared tunnel login + cloudflared tunnel create --credentials-file cloudflare-tunnel.json kubernetes + ``` + + 📍 _**Prefer port-forwarding over a tunnel?** Set `mode = "direct"` under `[ingress]` in `cluster.toml` and skip this step: no `cloudflare-tunnel.json` is needed. Instead, forward TCP 443 (and optionally 80) on your router to the `gateways.external` IP, and create an `external.` DNS record yourself pointing at your WAN address (an A record, or a CNAME to a DDNS hostname). Per-app records are still published automatically._ + +### Stage 5: Cluster configuration + +1. Generate the config files from the sample files: + + ```sh + just init + ``` + +2. Fill out the `cluster.toml` configuration file using the comments in it as a guide. Editors with TOML schema support (VS Code's Even Better TOML, taplo in Neovim) pick up the `#:schema` directive at the top of the file and provide completion and inline validation. + +3. Template out the kubernetes and talos configuration files, if any issues come up be sure to read the error and adjust your config files accordingly. + + ```sh + just configure + ``` + +4. Push your changes to git: + + 📍 _**Verify** all the `./bootstrap/**/*.sops.*`, `./kubernetes/**/*.sops.*` and `./talos/secrets.sops.yaml` files are **encrypted** with SOPS_ + + ```sh + git add -A + git commit -m "chore: initial commit :rocket:" + git push + ``` + +> [!TIP] +> Using a **private repository** (an `ssh://` URL in `cluster.toml`)? Make sure to paste the public key from `deploy.key.pub` into the deploy keys section of your repository settings (GitHub: `Settings/Deploy keys`, GitLab: `Settings/Repository/Deploy keys`, Gitea/Forgejo: `Settings/Deploy keys`). This will make sure Flux has read/write access to your repository. + +### Stage 6: Bootstrap Talos, Kubernetes, and Flux + +> [!WARNING] +> It might take a while for the cluster to be setup (10+ minutes is normal). During which time you will see a variety of error messages like: "couldn't get current server API group list," "error: no matching resources found", etc. 'Ready' will remain "False" as no CNI is deployed yet. **This is normal.** If this step gets interrupted, e.g. by pressing Ctrl + C, you likely will need to [reset the cluster](#-reset) before trying again + +1. Install Talos: + + ```sh + just bootstrap talos + ``` + +2. Install cilium, coredns, spegel, flux and sync the cluster to the repository state: + + ```sh + just bootstrap apps + ``` + +3. Watch the rollout of your cluster happen: + + ```sh + kubectl get pods --all-namespaces --watch + ``` + +## 📣 Post installation + +### ✅ Verifications + +1. Check the status of Cilium: + + ```sh + kubectl -n kube-system exec ds/cilium --container cilium-agent -- cilium status + ``` + +2. Check the status of Flux and if the Flux resources are up-to-date and in a ready state: + + 📍 _Run `just kube reconcile` to force Flux to sync your Git repository state_ + + ```sh + flux check + flux get sources git flux-system + flux get ks -A + flux get hr -A + ``` + +3. Check TCP connectivity to both the internal and external gateways: + + 📍 _The variables are only placeholders, replace them with your actual values_ + + ```sh + nmap -Pn -n -p 443 ${gateways_internal} ${gateways_external} -vv + ``` + +4. Check you can resolve DNS for `echo`, this should resolve to `${gateways_external}`: + + 📍 _The variables are only placeholders, replace them with your actual values_ + + ```sh + dig @${gateways_dns} echo.${cloudflare_domain} + ``` + +5. Check the status of your wildcard `Certificate`: + + ```sh + kubectl -n network describe certificates + ``` + +### 🌐 Public DNS + +> [!TIP] +> Use the `envoy-external` gateway on `HTTPRoutes` to make applications public to the internet. These are also accessible on your private network once you set up split DNS. + +The `external-dns` application created in the `network` namespace will handle creating public DNS records. By default, `echo` and the `flux-webhook` are the only subdomains reachable from the public internet. In order to make additional applications public you must **set the correct gateway** like in the HelmRelease for `echo`. + +### 🏠 Home DNS + +> [!TIP] +> Use the `envoy-internal` gateway on `HTTPRoutes` to make applications private to your network. If you're having trouble with internal DNS resolution check out [this](https://github.com/onedr0p/cluster-template/discussions/719) GitHub discussion. + +`k8s_gateway` will provide DNS resolution to external Kubernetes resources (i.e. points of entry to the cluster) from any device that uses your home DNS server. For this to work, your home DNS server must be configured to forward DNS queries for `${cloudflare_domain}` to `${gateways_dns}` instead of the upstream DNS server(s) it normally uses. This is a form of **split DNS** (aka split-horizon DNS / conditional forwarding). + +_... Nothing working? That is expected, this is DNS after all!_ + +### 🪝 Git Webhook + +By default Flux will periodically check your git repository for changes. In-order to have Flux reconcile on `git push` you must configure your Git provider to send `push` events to Flux. + +📍 _Don't want a webhook, or your Git provider can't reach the cluster? Set `webhook_provider = "none"` in `cluster.toml` and skip this section; Flux will keep polling on an interval._ + +1. Obtain the webhook path: + + 📍 _Hook id and path should look like `/hook/12ebd1e363c641dc3c2e430ecf3cee2b3c7a5ac9e1234506f6f5f3ce1230e123`_ + + ```sh + kubectl -n flux-system get receiver flux-webhook --output=jsonpath='{.status.webhookPath}' + ``` + +2. Piece together the full URL with the webhook path appended: + + ```text + https://flux-webhook.${cloudflare_domain}/hook/12ebd1e363c641dc3c2e430ecf3cee2b3c7a5ac9e1234506f6f5f3ce1230e123 + ``` + +3. Navigate to your repository settings and add a webhook with that URL and the secret token from `flux-webhook-token.txt`: + + - **GitHub**: under "Settings/Webhooks" press the "Add webhook" button. Fill in the webhook URL, paste the token as the secret, Content type: `application/json`, Events: Choose Just the push event, and save. + - **GitLab**: under "Settings/Webhooks" fill in the webhook URL, paste the token as the secret token, check the push events trigger, and save. Also set `webhook_provider = "gitlab"` in `cluster.toml`. + - **Gitea/Forgejo**: under "Settings/Webhooks" add a **Gitea/Forgejo** webhook with the webhook URL, method `POST`, content type `application/json`, paste the token as the secret, trigger on push events, and save. Keep the default `webhook_provider = "github"` since these providers emulate GitHub webhooks. + +## 💥 Reset + +> [!CAUTION] +> **Resetting** the cluster **multiple times in a short period of time** could lead to being **rate limited by DockerHub or Let's Encrypt**. + +There might be a situation where you want to destroy your Kubernetes cluster. The following command will reset your nodes back to maintenance mode. + +```sh +just talos reset +``` + +## 🛠️ Talos and Kubernetes Maintenance + +### ⚙️ Updating Talos node configuration + +> [!TIP] +> Ensure you have updated `topf.yaml` and any patches with your updated configuration. In some cases you **not only need to apply the configuration but also upgrade talos** to apply new configuration. + +```sh +# Preview the rendered machine configs (optional) +just talos render +# Apply the config to the node +just talos apply-node +# e.g. just talos apply-node k8s-0 +``` + +### ⬆️ Updating Talos and Kubernetes versions + +> [!TIP] +> Ensure the `talosVersion` and `kubernetesVersion` in `topf.yaml` are up-to-date with the version you wish to upgrade to. + +```sh +# Upgrade talos on a node +just talos upgrade-node +# e.g. just talos upgrade-node k8s-0 +``` + +```sh +# Upgrade cluster to a newer Kubernetes version +just talos upgrade-k8s +``` + +### ➕ Adding a node to your cluster + +At some point you might want to expand your cluster to run more workloads and/or improve the reliability of your cluster. Keep in mind it is recommended to have an **odd number** of control plane nodes for quorum reasons. + +You don't need to re-bootstrap the cluster to add new nodes. Follow these steps: + +1. **Prepare the new node**: Review the [Stage 2: Machine Preparation](#stage-2-machine-preparation) section and boot your new node into maintenance mode. + +2. **Get the node information**: While the node is in maintenance mode, retrieve the disk and MAC address information needed for configuration: + + ```sh + talosctl get disks -n --insecure + talosctl get links -n --insecure + ``` + +3. **Update the configuration**: Read the documentation for [topf](https://postfinance.github.io/topf/) and extend `topf.yaml` (and any `node//` patches) manually with the new node information (including the disk and MAC address from step 2). + +4. **Apply the configuration**: + + ```sh + # Preview the rendered machine configs (optional) + just talos render + + # Apply the configuration to the node + just talos apply-node + # e.g. just talos apply-node k8s-3 + ``` + +The node should join the cluster automatically and workloads will be scheduled once they report as ready. + +## 🤖 Renovate + +[Renovate](https://www.mend.io/renovate) is a tool that automates dependency management. It is designed to scan your repository around the clock and open PRs for out-of-date dependencies it finds. Common dependencies it can discover are Helm charts, container images, GitHub Actions and more! In most cases merging a PR will cause Flux to apply the update to your cluster. + +To enable Renovate on GitHub, click the 'Configure' button over at their [Github app page](https://github.com/apps/renovate) and select your repository. On other Git providers you can [self-host Renovate](https://docs.renovatebot.com/getting-started/running/#self-hosting-renovate); note that fetching the shared preset in `.renovaterc.json5` requires a `GITHUB_COM_TOKEN`. Renovate creates a "Dependency Dashboard" as an issue in your repository, giving an overview of the status of all updates. The dashboard has interactive checkboxes that let you do things like advance scheduling or reattempt update PRs you closed without merging. + +The base Renovate configuration in your repository can be viewed at [.renovaterc.json5](.renovaterc.json5). By default it is scheduled to be active with PRs every weekend, but you can [change the schedule to anything you want](https://docs.renovatebot.com/presets-schedule), or remove it if you want Renovate to open PRs immediately. + +## 🐛 Debugging + +Below is a general guide on trying to debug an issue with an resource or application. For example, if a workload/resource is not showing up or a pod has started but in a `CrashLoopBackOff` or `Pending` state. These steps do not include a way to fix the problem as the problem could be one of many different things. + +1. Check if the Flux resources are up-to-date and in a ready state: + + 📍 _Run `just kube reconcile` to force Flux to sync your Git repository state_ + + ```sh + flux get sources git -A + flux get ks -A + flux get hr -A + ``` + +2. Do you see the pod of the workload you are debugging: + + ```sh + kubectl -n get pods -o wide + ``` + +3. Check the logs of the pod if it's there: + + ```sh + kubectl -n logs -f + ``` + +4. If a resource exists, try to describe it to see what problems it might have: + + ```sh + kubectl -n describe + ``` + +5. Check the namespace events: + + ```sh + kubectl -n get events --sort-by='.metadata.creationTimestamp' + ``` + +Resolving problems that you have could take some tweaking of your YAML manifests in order to get things working, other times it could be a external factor like permissions on a NFS server. If you are unable to figure out your problem see the support sections below. + +## 🧹 Tidy up + +Once your cluster is fully configured and you no longer need to run `just configure`, it's a good idea to clean up the repository by removing the [template](./template) directory and any files related to the templating process. This will help eliminate unnecessary clutter from the upstream template repository and resolve any "duplicate registry" warnings from Renovate. + +1. Tidy up your repository: + + ```sh + just template tidy + ``` + +2. Push your changes to git: + + ```sh + git add -A + git commit -m "chore: tidy up :broom:" + git push + ``` + +## ❔ What's next + +There's a lot to absorb here, especially if you're new to these tools. Take some time to familiarize yourself with the tooling and understand how all the components interconnect. Dive into the documentation of the various tools included — they are a valuable resource. This shouldn't be a production environment yet, so embrace the freedom to experiment. Move fast, break things intentionally, and challenge yourself to fix them. + +Below are some optional considerations you may want to explore. + +### DNS + +The template uses [k8s_gateway](https://github.com/k8s-gateway/k8s_gateway) to provide DNS for your applications, consider exploring [external-dns](https://github.com/kubernetes-sigs/external-dns) as an alternative. + +External-DNS offers broad support for various DNS providers, including but not limited to: + +- [Pi-hole](https://github.com/kubernetes-sigs/external-dns/blob/master/docs/tutorials/pihole.md) +- [UniFi](https://github.com/kashalls/external-dns-unifi-webhook) +- [Adguard Home](https://github.com/muhlba91/external-dns-provider-adguard) +- [Bind](https://github.com/kubernetes-sigs/external-dns/blob/master/docs/tutorials/rfc2136.md) + +This flexibility allows you to integrate seamlessly with a range of DNS solutions to suit your environment and offload DNS from your cluster to your router, or external device. + +### Secrets + +SOPS is an excellent tool for managing secrets in a GitOps workflow. However, it can become cumbersome when rotating secrets or maintaining a single source of truth for secret items. + +For a more streamlined approach to those issues, consider [External Secrets](https://external-secrets.io/latest/). This tool allows you to move away from SOPs and leverage an external provider for managing your secrets. External Secrets supports a wide range of providers, from cloud-based solutions to self-hosted options. + +### Storage + +If your workloads require persistent storage with features like replication or connectivity to NFS, SMB, or iSCSI servers, there are several projects worth exploring: + +- [rook-ceph](https://github.com/rook/rook) / [longhorn](https://github.com/longhorn/longhorn) / [openebs](https://github.com/openebs/openebs) +- [democratic-csi](https://github.com/democratic-csi/democratic-csi) +- [csi-driver-nfs](https://github.com/kubernetes-csi/csi-driver-nfs) / [csi-driver-smb](https://github.com/kubernetes-csi/csi-driver-smb) +- [synology-csi](https://github.com/SynologyOpenSource/synology-csi) +- [truenas-csi](https://github.com/truenas/truenas-csi) / [tns-csi](https://github.com/fenio/tns-csi) + +These tools offer a variety of solutions to meet your persistent storage needs, whether you’re using cloud-native or self-hosted infrastructures. + +### Community Repositories + +Community member [@whazor](https://github.com/whazor) created [Kubesearch](https://kubesearch.dev) to allow searching Flux HelmReleases across Github and Gitlab repositories with the `kubesearch` topic. + +## 🙋 Support + +### Community + +- Make a post in this repository's GitHub [Discussions](https://github.com/onedr0p/cluster-template/discussions). +- Start a thread in the `#support` or `#cluster-template` channels in the [Home Operations](https://discord.gg/home-operations) Discord server. + +## 📺 Media + +Check out these videos below. If you find them helpful, a like and subscribe goes a long way! + + + Youtube Video + +   + + Youtube Video + + +## 🙌 Related Projects + +If this repo is too hot to handle or too cold to hold check out these following projects. + +- [ajaykumar4/cluster-template](https://github.com/ajaykumar4/cluster-template) - _A template for deploying a Talos Kubernetes cluster including Argo for GitOps_ +- [mitchross/k3s-argocd-starter](https://github.com/mitchross/k3s-argocd-starter) - starter kit for k3s, argocd +- [ricsanfre/pi-cluster](https://github.com/ricsanfre/pi-cluster) - _Pi Kubernetes Cluster. Homelab kubernetes cluster automated with Ansible and FluxCD_ +- [techno-tim/k3s-ansible](https://github.com/techno-tim/k3s-ansible) - _The easiest way to bootstrap a self-hosted High Availability Kubernetes cluster. A fully automated HA k3s etcd install with kube-vip, MetalLB, and more. Build. Destroy. Repeat._ + +## 🤝 Thanks + +Big shout out to all the contributors, sponsors and everyone else who has helped on this project. diff --git a/cluster.sample.toml b/cluster.sample.toml new file mode 100644 index 0000000..7cb749f --- /dev/null +++ b/cluster.sample.toml @@ -0,0 +1,292 @@ +#:schema ./cluster.schema.json +# ============================================================================= +# Physical LAN that your Talos nodes live on. Defines the address space +# used for node IPs, the gateway/DNS/NTP servers nodes will use, and an +# optional VLAN tag for switch ports that aren't natively tagged. +# ============================================================================= +[network] + +# The CIDR block your nodes' IPs come from. Every node's `address`, the Kube +# API VIP, and the gateway VIPs (internal/dns/external) must all sit inside +# this range. +# REQUIRED. Example: "192.168.1.0/24" +node_cidr = "" + +# Upstream DNS servers Talos nodes use for name resolution. Defaults to +# Cloudflare (1.1.1.1 / 1.0.0.1). Override if you run an internal resolver +# (Pi-hole, Unbound, AdGuard) or want a different public provider. +# OPTIONAL. Default: ["1.1.1.1", "1.0.0.1"] + +# dns_servers = ["1.1.1.1", "1.0.0.1"] + +# Upstream NTP servers. Defaults to Cloudflare's anycast NTP. Most homelabs +# don't need to change this. +# OPTIONAL. Default: ["162.159.200.1", "162.159.200.123"] + +# ntp_servers = ["162.159.200.1", "162.159.200.123"] + +# Default gateway IP that nodes use to reach the rest of your LAN/WAN. +# Defaults to the first usable host in node_cidr (e.g. 192.168.1.1 for +# 192.168.1.0/24), which is correct for most home routers. Override if your +# router lives at a non-standard address inside the subnet. +# OPTIONAL. Default: first IP in node_cidr + +# default_gateway = "" + +# 802.1Q VLAN tag to apply to the Talos node interface. Only set this if +# your switch ports are configured as trunks (passing tagged traffic to the +# nodes); access ports already untag VLAN traffic. Must be 1-4094. +# REF: https://www.talos.dev/latest/advanced/advanced-networking/#vlans +# OPTIONAL. + +# vlan_tag = "" + + +# ============================================================================= +# Cluster-internal control plane and overlay networks. The pod and service +# CIDRs are in-cluster only — they don't have to be routable on your LAN +# and never appear on the wire outside the nodes. +# ============================================================================= +[kubernetes] + +# CIDR Cilium hands out to pods. /16 gives ~64K pod IPs, which is well beyond +# what a homelab needs but matches the upstream default. Must NOT overlap +# with node_cidr or svc_cidr. +# OPTIONAL. Default: "10.42.0.0/16" + +# pod_cidr = "10.42.0.0/16" + +# CIDR for ClusterIP services (the virtual IPs `kubectl get svc` shows). +# Same /16 reasoning as pod_cidr. Must NOT overlap with node_cidr or +# pod_cidr. +# OPTIONAL. Default: "10.43.0.0/16" + +# svc_cidr = "10.43.0.0/16" + +# ClusterIP for the CoreDNS Service. Must be inside svc_cidr. +# OPTIONAL. Default: the 10th IP in svc_cidr + +# coredns_addr = "" + +[kubernetes.api] + +# Virtual IP for the Kubernetes API server. kubectl, flux, and every other +# client connect here on port 6443. Must be an unused IP inside +# network.node_cidr — kube-vip floats it across controller nodes. +# REQUIRED. +addr = "" + +# Additional Subject Alternative Names to put on the Kube API cert. Useful +# if you want to call the API by hostname (e.g. via a CNAME or local +# /etc/hosts entry) instead of the raw IP. +# OPTIONAL. Example: ["mycluster.example.com"] + +# tls_sans = ["mycluster.example.com"] + + +# ============================================================================= +# LoadBalancer IPs that Cilium hands out to the cluster's edge gateways. +# Each must be an unused address inside network.node_cidr, and all four +# (these three plus kubernetes.api.addr) must be distinct. +# ============================================================================= +[gateways] + +# IP for the `envoy-internal` gateway — used by HTTPRoutes intended for +# private/in-network access only. Most apps use this gateway by default. +# REQUIRED. +internal = "" + +# IP for `k8s_gateway`, which serves DNS for cluster-managed hostnames. +# Point your home DNS server's conditional forwarder for domain.name +# at this IP to enable split-DNS resolution from your LAN. +# REQUIRED. +dns = "" + +# IP for the `envoy-external` gateway — sits behind the ingress path (e.g. +# the cloudflared tunnel) and handles traffic exposed to the public +# internet. HTTPRoutes that reference this gateway become reachable via +# your public domain. +# REQUIRED unless ingress.mode is "none" (internal-only cluster). +external = "" + + +# ============================================================================= +# The Git repo Flux will sync from. This is the single source of truth +# for everything in your cluster — once bootstrapped, changes are made by +# pushing to this repo. Any Git host works: GitHub, GitLab, Gitea, +# Forgejo, Codeberg or self-hosted. +# ============================================================================= +[repository] + +# Full clone URL of the repository this cluster will pull from. +# Must be the repo you cloned this template into. +# Use `https://` if the repo is publicly readable (or `http://` for a +# LAN-local git server). Use `ssh://git@` if it is +# private: the template then wires up a deploy key (`deploy.key.pub`) so +# Flux can clone over SSH; see the README for the extra setup step. +# REQUIRED. Examples: +# "https://github.com/onedr0p/home-ops.git" +# "ssh://git@gitlab.com/onedr0p/home-ops.git" +# "ssh://git@git.example.com/k8s/home-ops.git" +url = "" + +# Branch Flux watches. Changes pushed to this branch get reconciled into the +# cluster. +# OPTIONAL. Default: "main" + +# branch = "main" + +# Webhook payload format the Flux webhook Receiver verifies, so pushes are +# reconciled instantly. Gitea and Forgejo emulate GitHub webhooks, so keep +# "github" for them. Use "generic-hmac" for anything else that signs with +# HMAC; see https://fluxcd.io/flux/components/notification/receivers/ +# Use "none" to skip the webhook entirely (e.g. your Git host cannot reach +# the cluster); Flux then only polls on an interval. +# OPTIONAL. Default: "github". +# Allowed: "github" | "gitlab" | "generic-hmac" | "none" + +# webhook_provider = "github" + +# SSH host keys for your Git host (`ssh-keyscan -t ed25519,ecdsa,rsa ` +# output). Only used with `ssh://` URLs. Keys for github.com, gitlab.com and +# codeberg.org are bundled; REQUIRED for any other host. +# OPTIONAL. Example: +# known_hosts = """ +# git.example.com ssh-ed25519 AAAA... +# """ + +# known_hosts = "" + + +# ============================================================================= +# The domain your cluster's hostnames live under. Used for every rendered +# hostname (echo, flux-webhook, internal split DNS) and the wildcard +# certificate, regardless of DNS provider. +# ============================================================================= +[domain] + +# REQUIRED. Example: "example.com" +name = "" + + +# ============================================================================= +# Public DNS authority and certificate issuance. With "cloudflare", +# external-dns publishes records automatically and cert-manager issues a +# Let's Encrypt wildcard via ACME DNS-01. With "none", nothing is +# published and the wildcard certificate is issued by an in-cluster +# self-signed CA instead (internal-only cluster). +# ============================================================================= +[dns] + +# OPTIONAL. Default: "cloudflare". Allowed: "cloudflare" | "none" + +# provider = "cloudflare" + +# Cloudflare API token (NOT the global API key) with `Zone - DNS - Edit` and +# `Account - Cloudflare Tunnel - Read` permissions, scoped to the zone +# above. See the README for token creation steps. +# REQUIRED when provider is "cloudflare"; must be empty otherwise. +token = "" + + +# ============================================================================= +# How the public internet reaches the cluster's external gateway. With +# "cloudflare-tunnel", cloudflared connects outbound so no ports are +# forwarded (requires dns.provider = "cloudflare" and +# cloudflare-tunnel.json). With "direct", you forward TCP 443 (and +# optionally 80) on your router to gateways.external and point an +# `external.` DNS record at your WAN address (A record or DDNS +# CNAME) yourself; per-app records are still published automatically. +# With "none", nothing is exposed and apps are only reachable on your +# LAN via the internal gateway. +# ============================================================================= +[ingress] + +# OPTIONAL. Default: "cloudflare-tunnel" when dns.provider is "cloudflare", +# otherwise "none". Allowed: "cloudflare-tunnel" | "direct" | "none" + +# mode = "cloudflare-tunnel" + + +# ============================================================================= +# CNI configuration. Defaults are sane for most homelab setups; touch +# this section only if you need BGP peering or a different LB mode. +# ============================================================================= +[cilium] + +# How Cilium's load balancer handles return traffic. `dsr` (Direct Server +# Return) preserves the client IP and is faster, but requires a switch +# fabric that won't drop asymmetric flows. `snat` masquerades the client +# and is the safe default for unknown topologies. +# REF: https://docs.cilium.io/en/stable/network/kubernetes/kubeproxy-free/ +# OPTIONAL. Default: "dsr". Allowed: "dsr" | "snat" + +# loadbalancer_mode = "dsr" + +# Cilium BGP peering — advertises Service IPs to your upstream router so +# LoadBalancer addresses become reachable from anywhere on your LAN +# (rather than only via L2 ARP). Set ALL THREE fields below to enable; +# leaving any blank disables BGP entirely. +# REF: https://docs.cilium.io/en/latest/network/bgp-control-plane/bgp-control-plane/ +[cilium.bgp] + +# IP of your BGP-speaking router. The cluster peers with it from each node. +# OPTIONAL. Example: "192.168.1.1" + +# router_addr = "" + +# ASN your router uses for BGP. Anything in the private range (64512-65534) +# is fine if you're not peering with the public internet. +# OPTIONAL. Example: "64513" + +# router_asn = "" + +# ASN the cluster's nodes use for BGP. Pick a different value than +# router_asn so peering is eBGP rather than iBGP. +# OPTIONAL. Example: "64514" + +# node_asn = "" + + +# ============================================================================= +# Talos Image Factory settings shared by all nodes. +# ============================================================================= +[talos] + +# Default schematic for every node that doesn't set its own schematic_id. +# The 64-character hex string from your build at https://factory.talos.dev/ +# OPTIONAL if every node sets schematic_id itself. +schematic_id = "" + + +# ============================================================================= +# One [[nodes]] table per physical machine or VM in the cluster. At least +# one controller (controller=true) is required; worker nodes are optional. +# For HA, use 3 controllers. +# +# Discover hardware details from a node already booted into Talos +# maintenance mode: +# talosctl get disks -n --insecure +# talosctl get links -n --insecure +# Schematic ID is the 64-character hex string from your build at: +# https://factory.talos.dev/ +# +# The block below is a template — copy it once per node, uncomment, and +# fill in the values. +# ============================================================================= + +# [[nodes]] +# name = "k8s-0" # Hostname; must match [a-z0-9-]+ (not "global"/"controller"/"worker"). +# address = "192.168.1.10" # Static IP; must be inside network.node_cidr. +# controller = true # true = control-plane (etcd + API server), false = worker. +# disk = "/dev/nvme0n1" # Block device or /dev/disk/by-id/... symlink to install Talos onto. +# mac_addr = "aa:bb:cc:dd:ee:ff" # Primary NIC MAC. +# +# # Optional when [talos] sets a cluster-wide default: +# schematic_id = "376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba" # 64-hex from factory.talos.dev. +# +# # Optional advanced fields (each independently uncommentable): +# mtu = 1500 # Set only for jumbo frames / non-1500 MTUs (1450-9000). +# secureboot = false # UEFI SecureBoot — requires a SecureBoot-enabled schematic. +# encrypt_disk = false # TPM-bound full-disk encryption. +# kernel_modules = ["nvidia", "nvidia_uvm"] # Only for schematics shipping matching extensions. diff --git a/cluster.schema.json b/cluster.schema.json new file mode 100644 index 0000000..f2b92c5 --- /dev/null +++ b/cluster.schema.json @@ -0,0 +1,433 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Api": { + "additionalProperties": false, + "properties": { + "addr": { + "format": "ipv4", + "title": "Addr", + "type": "string" + }, + "tls_sans": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Fqdn" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tls Sans" + } + }, + "required": ["addr"], + "title": "Api", + "type": "object" + }, + "Asn": { + "type": "string" + }, + "Bgp": { + "additionalProperties": false, + "properties": { + "router_addr": { + "anyOf": [ + { + "format": "ipv4", + "type": "string" + }, + { + "const": "", + "type": "string" + } + ], + "default": "", + "title": "Router Addr" + }, + "router_asn": { + "$ref": "#/$defs/Asn", + "default": "" + }, + "node_asn": { + "$ref": "#/$defs/Asn", + "default": "" + } + }, + "title": "Bgp", + "type": "object" + }, + "Cidr": { + "format": "ipv4network", + "type": "string" + }, + "Cilium": { + "additionalProperties": false, + "properties": { + "loadbalancer_mode": { + "default": "dsr", + "enum": ["dsr", "snat"], + "title": "Loadbalancer Mode", + "type": "string" + }, + "bgp": { + "$ref": "#/$defs/Bgp", + "default": { + "router_addr": "", + "router_asn": "", + "node_asn": "" + } + } + }, + "title": "Cilium", + "type": "object" + }, + "Dns": { + "additionalProperties": false, + "properties": { + "provider": { + "default": "cloudflare", + "enum": ["cloudflare", "none"], + "title": "Provider", + "type": "string" + }, + "token": { + "default": "", + "title": "Token", + "type": "string" + } + }, + "title": "Dns", + "type": "object" + }, + "Domain": { + "additionalProperties": false, + "properties": { + "name": { + "$ref": "#/$defs/Fqdn" + } + }, + "required": ["name"], + "title": "Domain", + "type": "object" + }, + "Fqdn": { + "pattern": "^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}$", + "type": "string" + }, + "Gateways": { + "additionalProperties": false, + "properties": { + "internal": { + "format": "ipv4", + "title": "Internal", + "type": "string" + }, + "dns": { + "format": "ipv4", + "title": "Dns", + "type": "string" + }, + "external": { + "anyOf": [ + { + "format": "ipv4", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "External" + } + }, + "required": ["internal", "dns"], + "title": "Gateways", + "type": "object" + }, + "Ingress": { + "additionalProperties": false, + "properties": { + "mode": { + "default": "cloudflare-tunnel", + "enum": ["cloudflare-tunnel", "direct", "none"], + "title": "Mode", + "type": "string" + } + }, + "title": "Ingress", + "type": "object" + }, + "Kubernetes": { + "additionalProperties": false, + "properties": { + "pod_cidr": { + "$ref": "#/$defs/Cidr", + "default": "10.42.0.0/16" + }, + "svc_cidr": { + "$ref": "#/$defs/Cidr", + "default": "10.43.0.0/16" + }, + "coredns_addr": { + "format": "ipv4", + "title": "Coredns Addr", + "type": "string" + }, + "api": { + "$ref": "#/$defs/Api" + } + }, + "required": ["api"], + "title": "Kubernetes", + "type": "object" + }, + "Network": { + "additionalProperties": false, + "properties": { + "node_cidr": { + "$ref": "#/$defs/Cidr" + }, + "dns_servers": { + "default": ["1.1.1.1", "1.0.0.1"], + "items": { + "format": "ipv4", + "type": "string" + }, + "title": "Dns Servers", + "type": "array" + }, + "ntp_servers": { + "default": ["162.159.200.1", "162.159.200.123"], + "items": { + "format": "ipv4", + "type": "string" + }, + "title": "Ntp Servers", + "type": "array" + }, + "default_gateway": { + "format": "ipv4", + "title": "Default Gateway", + "type": "string" + }, + "vlan_tag": { + "anyOf": [ + { + "pattern": "^[0-9]+$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Vlan Tag" + } + }, + "required": ["node_cidr"], + "title": "Network", + "type": "object" + }, + "Node": { + "additionalProperties": false, + "properties": { + "name": { + "pattern": "^[a-z0-9][a-z0-9\\-]{0,61}[a-z0-9]$|^[a-z0-9]$", + "title": "Name", + "type": "string" + }, + "address": { + "format": "ipv4", + "title": "Address", + "type": "string" + }, + "controller": { + "title": "Controller", + "type": "boolean" + }, + "disk": { + "title": "Disk", + "type": "string" + }, + "mac_addr": { + "pattern": "^([0-9a-f]{2}:){5}[0-9a-f]{2}$", + "title": "Mac Addr", + "type": "string" + }, + "schematic_id": { + "anyOf": [ + { + "pattern": "^[a-z0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Schematic Id" + }, + "mtu": { + "default": 1500, + "maximum": 9000, + "minimum": 1450, + "title": "Mtu", + "type": "integer" + }, + "secureboot": { + "default": false, + "title": "Secureboot", + "type": "boolean" + }, + "encrypt_disk": { + "default": false, + "title": "Encrypt Disk", + "type": "boolean" + }, + "kernel_modules": { + "default": [], + "items": { + "type": "string" + }, + "title": "Kernel Modules", + "type": "array" + } + }, + "required": ["name", "address", "controller", "disk", "mac_addr"], + "title": "Node", + "type": "object" + }, + "Repository": { + "additionalProperties": false, + "properties": { + "url": { + "pattern": "^(https?://|ssh://git@)[^/]+/.+$", + "title": "Url", + "type": "string" + }, + "branch": { + "default": "main", + "minLength": 1, + "title": "Branch", + "type": "string" + }, + "webhook_provider": { + "default": "github", + "enum": ["github", "gitlab", "generic-hmac", "none"], + "title": "Webhook Provider", + "type": "string" + }, + "known_hosts": { + "default": "", + "title": "Known Hosts", + "type": "string" + } + }, + "required": ["url"], + "title": "Repository", + "type": "object" + }, + "Spegel": { + "additionalProperties": false, + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Enabled" + } + }, + "title": "Spegel", + "type": "object" + }, + "Talos": { + "additionalProperties": false, + "properties": { + "schematic_id": { + "anyOf": [ + { + "pattern": "^[a-z0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Schematic Id" + } + }, + "title": "Talos", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "network": { + "$ref": "#/$defs/Network" + }, + "kubernetes": { + "$ref": "#/$defs/Kubernetes" + }, + "gateways": { + "$ref": "#/$defs/Gateways" + }, + "repository": { + "$ref": "#/$defs/Repository" + }, + "domain": { + "$ref": "#/$defs/Domain" + }, + "dns": { + "$ref": "#/$defs/Dns" + }, + "ingress": { + "$ref": "#/$defs/Ingress" + }, + "cilium": { + "$ref": "#/$defs/Cilium", + "default": { + "loadbalancer_mode": "dsr", + "bgp": { + "node_asn": "", + "router_addr": "", + "router_asn": "" + } + } + }, + "talos": { + "$ref": "#/$defs/Talos", + "default": { + "schematic_id": null + } + }, + "spegel": { + "$ref": "#/$defs/Spegel", + "default": { + "enabled": null + } + }, + "nodes": { + "items": { + "$ref": "#/$defs/Node" + }, + "title": "Nodes", + "type": "array" + } + }, + "required": ["network", "kubernetes", "gateways", "repository", "domain", "dns", "nodes"], + "title": "cluster.toml", + "type": "object" +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..254358d --- /dev/null +++ b/justfile @@ -0,0 +1,34 @@ +set quiet +set minimum-version := '1.55.1' +set default-list +set default-script +set shell := ['bash', '-euo', 'pipefail', '-c'] +set script-interpreter := ['bash', '-euo', 'pipefail'] + +[group('bootstrap')] +mod? bootstrap 'bootstrap' + +[group('kubernetes')] +mod? kube 'kubernetes' + +[group('talos')] +mod? talos 'talos' + +[private] +log lvl msg *args: + gum log -t rfc3339 -s -l "{{ lvl }}" "{{ msg }}" {{ args }} + +# === template === + +[group('template')] +mod template 'template' + +[doc('Render and validate configuration files')] +[group('template')] +configure: + just template configure + +[doc('Initialize configuration files (cluster.toml, age key, deploy key, webhook token)')] +[group('template')] +init: + just template init diff --git a/makejinja.toml b/makejinja.toml new file mode 100644 index 0000000..d429f26 --- /dev/null +++ b/makejinja.toml @@ -0,0 +1,19 @@ +[makejinja] +inputs = ["./template/overrides","./template/config"] +output = "./" +exclude_patterns = ["*.partial.yaml.j2"] +data = ["./cluster.toml"] +import_paths = ["./template/scripts"] +loaders = ["plugin:Plugin"] +jinja_suffix = ".j2" +copy_metadata = true +force = true +undefined = "strict" + +[makejinja.delimiter] +block_start = "#%" +block_end = "%#" +comment_start = "#|" +comment_end = "#|" +variable_start = "#{" +variable_end = "}#" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4e55c6f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "cluster-template" +version = "0.0.0" +requires-python = ">=3.14" +dependencies = [ + "makejinja==2.8.3", + "pydantic==2.13.5", +] + +[dependency-groups] +dev = [ + "pytest>=8", +] + +[tool.uv] +package = false diff --git a/template/config/.sops.yaml.j2 b/template/config/.sops.yaml.j2 new file mode 100644 index 0000000..bf44c2c --- /dev/null +++ b/template/config/.sops.yaml.j2 @@ -0,0 +1,12 @@ +--- +creation_rules: + - path_regex: talos/.*\.sops\.ya?ml + mac_only_encrypted: true + age: "#{ age_key('public') }#" + - path_regex: (bootstrap|kubernetes)/.*\.sops\.ya?ml + encrypted_regex: "^(data|stringData)$" + mac_only_encrypted: true + age: "#{ age_key('public') }#" +stores: + yaml: + indent: 2 diff --git a/template/config/bootstrap/deploy-key.sops.yaml.j2 b/template/config/bootstrap/deploy-key.sops.yaml.j2 new file mode 100644 index 0000000..a5e4888 --- /dev/null +++ b/template/config/bootstrap/deploy-key.sops.yaml.j2 @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: deploy-key + namespace: flux-system +stringData: + identity: | + #% filter indent(width=4, first=False) %# + #{ deploy_key() }# + #% endfilter %# + known_hosts: | + #% filter indent(width=4, first=False) %# + #{ repository.known_hosts }# + #% endfilter %# diff --git a/template/config/bootstrap/helmfile/apps.yaml.j2 b/template/config/bootstrap/helmfile/apps.yaml.j2 new file mode 100644 index 0000000..ae02b9d --- /dev/null +++ b/template/config/bootstrap/helmfile/apps.yaml.j2 @@ -0,0 +1,60 @@ +--- +# yaml-language-server: $schema=https://json.schemastore.org/helmfile + +# Bootstraps core applications that provide a minimal runtime base for the +# cluster. These releases are installed first so the cluster has the resources +# Flux needs before its own reconciliation begins. +# +# After this bootstrap phase, Flux is ready to take over management of the +# application stack and continue reconciling downstream state. + +helmDefaults: + cleanupOnFail: true + forceConflicts: true + wait: true + waitForJobs: true + +bases: + - default.yaml + +releases: + - name: cilium + namespace: kube-system + inherit: + - template: default + + - name: coredns + namespace: kube-system + inherit: + - template: default + needs: ["kube-system/cilium"] + #% if spegel.enabled %# + + - name: spegel + namespace: kube-system + inherit: + - template: default + needs: ["kube-system/coredns"] + #% endif %# + + - name: cert-manager + namespace: cert-manager + inherit: + - template: default + #% if spegel.enabled %# + needs: ["kube-system/spegel"] + #% else %# + needs: ["kube-system/coredns"] + #% endif %# + + - name: flux-operator + namespace: flux-system + inherit: + - template: default + needs: ["cert-manager/cert-manager"] + + - name: flux-instance + namespace: flux-system + inherit: + - template: default + needs: ["flux-system/flux-operator"] diff --git a/template/config/bootstrap/helmfile/crds.yaml.j2 b/template/config/bootstrap/helmfile/crds.yaml.j2 new file mode 100644 index 0000000..91dd445 --- /dev/null +++ b/template/config/bootstrap/helmfile/crds.yaml.j2 @@ -0,0 +1,37 @@ +--- +# yaml-language-server: $schema=https://json.schemastore.org/helmfile + +# Bootstraps cluster-wide Custom Resource Definitions (CRDs) by extracting them +# from upstream Helm charts and applying them directly with kubectl. The releases +# below are never reconciled with helmfile apply or helmfile sync — only their +# CRDs are rendered (via --include-crds) and piped to the cluster. +# +# Installing CRDs out-of-band ensures they exist before Flux begins reconciling +# workloads that reference them, avoiding the need for dependsOn chains on nearly +# every Kustomization that consumes a CRD-backed resource. + +helmDefaults: + args: + - --include-crds + - --no-hooks + +bases: + - default.yaml + +releases: + #% if dns.provider == 'cloudflare' and ingress.mode != 'none' %# + - name: cloudflare-dns + namespace: network + inherit: + - template: default + + #% endif %# + - name: envoy-gateway + namespace: network + inherit: + - template: default + + - name: prometheus-operator-crds + namespace: observability + chart: oci://ghcr.io/prometheus-community/charts/prometheus-operator-crds + version: 29.0.0 diff --git a/template/config/bootstrap/helmfile/default.yaml.j2 b/template/config/bootstrap/helmfile/default.yaml.j2 new file mode 100644 index 0000000..575ff5d --- /dev/null +++ b/template/config/bootstrap/helmfile/default.yaml.j2 @@ -0,0 +1,8 @@ +--- +# yaml-language-server: $schema=https://json.schemastore.org/helmfile +templates: + default: + chart: '{{ (fromYaml (tpl (readFile "./templates/release.yaml.gotmpl") .)).chart }}' + version: '{{ (fromYaml (tpl (readFile "./templates/release.yaml.gotmpl") .)).version }}' + values: + - ./templates/values.yaml.gotmpl diff --git a/template/config/bootstrap/helmfile/templates/release.yaml.gotmpl.j2 b/template/config/bootstrap/helmfile/templates/release.yaml.gotmpl.j2 new file mode 100644 index 0000000..00cb5eb --- /dev/null +++ b/template/config/bootstrap/helmfile/templates/release.yaml.gotmpl.j2 @@ -0,0 +1,3 @@ +{{- $oci := fromYaml (readFile (printf "../../kubernetes/apps/%s/%s/app/ocirepository.yaml" .Release.Namespace .Release.Name)) -}} +chart: {{ $oci.spec.url }} +version: {{ $oci.spec.ref.tag }} diff --git a/template/config/bootstrap/helmfile/templates/values.yaml.gotmpl.j2 b/template/config/bootstrap/helmfile/templates/values.yaml.gotmpl.j2 new file mode 100644 index 0000000..2a046fa --- /dev/null +++ b/template/config/bootstrap/helmfile/templates/values.yaml.gotmpl.j2 @@ -0,0 +1 @@ +{{ (fromYaml (readFile (printf "../../../kubernetes/apps/%s/%s/app/helmrelease.yaml" .Release.Namespace .Release.Name))).spec.values | toYaml }} diff --git a/template/config/bootstrap/mod.just b/template/config/bootstrap/mod.just new file mode 100644 index 0000000..81d4106 --- /dev/null +++ b/template/config/bootstrap/mod.just @@ -0,0 +1,105 @@ +set no-exit-message +set quiet +set shell := ['bash', '-euo', 'pipefail', '-c'] +set script-interpreter := ['bash', '-euo', 'pipefail'] +set default-list +set default-script + +kubernetes_dir := justfile_dir() + '/kubernetes' + +[doc('Bootstrap the Talos cluster')] +[group('bootstrap')] +talos: talos-secret talos-apply talos-talosconfig talos-kubeconfig + +[doc('Bootstrap apps into the Talos cluster')] +[group('bootstrap')] +apps: apps-ready apps-namespaces apps-secrets apps-crds apps-helm + just log info "Cluster is bootstrapped — Flux will start syncing the Git repository" + +# No sops call or existence guard is needed: the bundle is stored already +# encrypted with the repo's age recipient, and existing bundles are left +# untouched. +[private] +[working-directory('../talos')] +talos-secret: + just log info "Generating secrets" stage "{{ recipe_name() }}" + topf secrets --confirm=false > /dev/null + +[private] +[working-directory('../talos')] +talos-apply: + just log info "Applying talos config and bootstrapping" stage "{{ recipe_name() }}" + topf apply --auto-bootstrap --confirm=false + +[private] +[working-directory('../talos')] +talos-talosconfig: + just log info "Generating talosconfig" stage "{{ recipe_name() }}" + topf talosconfig > talosconfig + +# topf issues short-lived admin certs by default; 8760h keeps the +# kubeconfig usable long-term. +[private] +[working-directory('../talos')] +talos-kubeconfig: + just log info "Fetching kubeconfig" stage "{{ recipe_name() }}" + topf kubeconfig --validity 8760h > "{{ justfile_dir() }}/kubeconfig" + +[private] +apps-crds: + just log info "Applying CRDs" stage "{{ recipe_name() }}" + if ! helmfile --file "{{ source_directory() }}/helmfile/crds.yaml" template --quiet | yq eval-all --exit-status 'select(.kind == "CustomResourceDefinition")' | kubectl apply --server-side --force-conflicts --filename -; then + just log fatal "Failed to apply crds" + fi + +[private] +apps-helm: + just log info "Syncing helmfile" stage "{{ recipe_name() }}" + if ! helmfile --file "{{ source_directory() }}/helmfile/apps.yaml" sync --hide-notes; then + just log fatal "Failed to sync helmfile" + fi + +[private] +apps-namespaces: + just log info "Applying namespaces for apps" stage "{{ recipe_name() }}" + for app in "{{ kubernetes_dir }}/apps"/*/; do + ns="$(basename "$app")" + if kubectl create namespace "$ns" --dry-run=client -o yaml \ + | kubectl apply --server-side --filename - &>/dev/null; then + just log info "Namespace applied" namespace "$ns" + else + just log fatal "Failed to apply namespace" namespace "$ns" + fi + done + +[private] +apps-secrets: + just log info "Applying secrets for apps" stage "{{ recipe_name() }}" + for secret in \ + "{{ source_directory() }}/deploy-key.sops.yaml" \ + "{{ source_directory() }}/sops-age.sops.yaml" \ + "{{ kubernetes_dir }}/components/sops/cluster-secrets.sops.yaml" + do + name="$(basename "$secret" .sops.yaml)" + if sops decrypt "$secret" \ + | kubectl --namespace flux-system apply --server-side --filename - &>/dev/null; then + just log info "Secret applied" resource "$name" + else + just log fatal "Failed to apply secret" resource "$name" + fi + done + +# Wait until nodes register as Ready=False. They only become Ready=True once the CNI is healthy. +[private] +apps-ready: + just log info "Waiting for nodes to register as Ready=False" stage "{{ recipe_name() }}" + if ! kubectl wait nodes --for=condition=Ready=True --all --timeout=10s &>/dev/null; then + deadline=$((SECONDS + 600)) + until kubectl wait nodes --for=condition=Ready=False --all --timeout=10s &>/dev/null; do + if (( SECONDS >= deadline )); then + just log fatal "Timed out waiting for nodes to register" + fi + just log info "Nodes not available, waiting for nodes to be available. Retrying in 5 seconds..." + sleep 5 + done + fi diff --git a/template/config/bootstrap/sops-age.sops.yaml.j2 b/template/config/bootstrap/sops-age.sops.yaml.j2 new file mode 100644 index 0000000..f555b4b --- /dev/null +++ b/template/config/bootstrap/sops-age.sops.yaml.j2 @@ -0,0 +1,8 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: sops-age + namespace: flux-system +stringData: + age.agekey: "#{ age_key('private') }#" diff --git a/template/config/kubernetes/apps/cert-manager/cert-manager/app/clusterissuer.yaml.j2 b/template/config/kubernetes/apps/cert-manager/cert-manager/app/clusterissuer.yaml.j2 new file mode 100644 index 0000000..66f1b1d --- /dev/null +++ b/template/config/kubernetes/apps/cert-manager/cert-manager/app/clusterissuer.yaml.j2 @@ -0,0 +1,52 @@ +#% if dns.provider == 'cloudflare' %# +--- +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-production +spec: + acme: + privateKeySecretRef: + name: letsencrypt-production + profile: shortlived + server: https://acme-v02.api.letsencrypt.org/directory + solvers: + - dns01: + cloudflare: + apiTokenSecretRef: + name: cert-manager-secret + key: api-token + selector: + dnsZones: ["${SECRET_DOMAIN}"] +#% else %# +--- +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: selfsigned +spec: + selfSigned: {} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: internal-ca +spec: + isCA: true + commonName: internal-ca + secretName: internal-ca + privateKey: + algorithm: ECDSA + size: 256 + issuerRef: + name: selfsigned + kind: ClusterIssuer +--- +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: internal-ca +spec: + ca: + secretName: internal-ca +#% endif %# diff --git a/template/config/kubernetes/apps/cert-manager/cert-manager/app/helmrelease.yaml.j2 b/template/config/kubernetes/apps/cert-manager/cert-manager/app/helmrelease.yaml.j2 new file mode 100644 index 0000000..bd038a3 --- /dev/null +++ b/template/config/kubernetes/apps/cert-manager/cert-manager/app/helmrelease.yaml.j2 @@ -0,0 +1,20 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: cert-manager +spec: + chartRef: + kind: OCIRepository + name: cert-manager + interval: 1h + values: + crds: + enabled: true + replicaCount: #{ 2 if nodes | length > 1 else 1 }# + dns01RecursiveNameservers: https://1.1.1.1:443/dns-query,https://1.0.0.1:443/dns-query + dns01RecursiveNameserversOnly: true + prometheus: + enabled: true + servicemonitor: + enabled: true diff --git a/template/config/kubernetes/apps/cert-manager/cert-manager/app/kustomization.yaml.j2 b/template/config/kubernetes/apps/cert-manager/cert-manager/app/kustomization.yaml.j2 new file mode 100644 index 0000000..16806ed --- /dev/null +++ b/template/config/kubernetes/apps/cert-manager/cert-manager/app/kustomization.yaml.j2 @@ -0,0 +1,10 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ./clusterissuer.yaml + - ./helmrelease.yaml + - ./ocirepository.yaml + #% if dns.provider == 'cloudflare' %# + - ./secret.sops.yaml + #% endif %# diff --git a/template/config/kubernetes/apps/cert-manager/cert-manager/app/ocirepository.yaml.j2 b/template/config/kubernetes/apps/cert-manager/cert-manager/app/ocirepository.yaml.j2 new file mode 100644 index 0000000..55a808f --- /dev/null +++ b/template/config/kubernetes/apps/cert-manager/cert-manager/app/ocirepository.yaml.j2 @@ -0,0 +1,13 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: cert-manager +spec: + interval: 15m + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + ref: + tag: v1.21.2 + url: oci://quay.io/jetstack/charts/cert-manager diff --git a/template/config/kubernetes/apps/cert-manager/cert-manager/app/secret.sops.yaml.j2 b/template/config/kubernetes/apps/cert-manager/cert-manager/app/secret.sops.yaml.j2 new file mode 100644 index 0000000..990f771 --- /dev/null +++ b/template/config/kubernetes/apps/cert-manager/cert-manager/app/secret.sops.yaml.j2 @@ -0,0 +1,9 @@ +#% if dns.provider == 'cloudflare' %# +--- +apiVersion: v1 +kind: Secret +metadata: + name: cert-manager-secret +stringData: + api-token: "#{ dns.token }#" +#% endif %# diff --git a/template/config/kubernetes/apps/cert-manager/cert-manager/ks.yaml.j2 b/template/config/kubernetes/apps/cert-manager/cert-manager/ks.yaml.j2 new file mode 100644 index 0000000..ba0c807 --- /dev/null +++ b/template/config/kubernetes/apps/cert-manager/cert-manager/ks.yaml.j2 @@ -0,0 +1,30 @@ +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: cert-manager +spec: + healthChecks: + - apiVersion: helm.toolkit.fluxcd.io/v2 + kind: HelmRelease + name: cert-manager + namespace: cert-manager + - apiVersion: cert-manager.io/v1 + kind: ClusterIssuer + name: #{ cluster_issuer }# + healthCheckExprs: + - apiVersion: cert-manager.io/v1 + kind: ClusterIssuer + current: status.conditions.exists(e, e.type == 'Ready' && e.status == 'True') + interval: 1h + path: ./kubernetes/apps/cert-manager/cert-manager/app + postBuild: + substituteFrom: + - name: cluster-secrets + kind: Secret + prune: true + sourceRef: + kind: GitRepository + name: flux-system + namespace: flux-system + targetNamespace: cert-manager diff --git a/template/config/kubernetes/apps/cert-manager/kustomization.yaml.j2 b/template/config/kubernetes/apps/cert-manager/kustomization.yaml.j2 new file mode 100644 index 0000000..e24fb0b --- /dev/null +++ b/template/config/kubernetes/apps/cert-manager/kustomization.yaml.j2 @@ -0,0 +1,11 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: cert-manager + +components: + - ../../components/sops + +resources: + - ./namespace.yaml + - ./cert-manager/ks.yaml diff --git a/template/config/kubernetes/apps/cert-manager/namespace.yaml.j2 b/template/config/kubernetes/apps/cert-manager/namespace.yaml.j2 new file mode 100644 index 0000000..6e34540 --- /dev/null +++ b/template/config/kubernetes/apps/cert-manager/namespace.yaml.j2 @@ -0,0 +1,7 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: cert-manager + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled diff --git a/template/config/kubernetes/apps/default/echo/app/helmrelease.yaml.j2 b/template/config/kubernetes/apps/default/echo/app/helmrelease.yaml.j2 new file mode 100644 index 0000000..c9abbc7 --- /dev/null +++ b/template/config/kubernetes/apps/default/echo/app/helmrelease.yaml.j2 @@ -0,0 +1,31 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: echo +spec: + chartRef: + kind: OCIRepository + name: echo + interval: 1h + values: + replicaCount: #{ 2 if nodes | length > 1 else 1 }# + config: + kubernetes: true + trustedProxies: + - "#{ kubernetes.pod_cidr }#" + httpRoute: + enabled: true + hostnames: + - "{{ .Release.Name }}.${SECRET_DOMAIN}" + parentRefs: + - name: envoy-#{ 'external' if ingress.mode != 'none' else 'internal' }# + namespace: network + monitoring: + serviceMonitor: + enabled: true + resources: + requests: + cpu: 10m + limits: + memory: 64Mi diff --git a/template/config/kubernetes/apps/default/echo/app/kustomization.yaml.j2 b/template/config/kubernetes/apps/default/echo/app/kustomization.yaml.j2 new file mode 100644 index 0000000..2ccd5a8 --- /dev/null +++ b/template/config/kubernetes/apps/default/echo/app/kustomization.yaml.j2 @@ -0,0 +1,6 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ./helmrelease.yaml + - ./ocirepository.yaml diff --git a/template/config/kubernetes/apps/default/echo/app/ocirepository.yaml.j2 b/template/config/kubernetes/apps/default/echo/app/ocirepository.yaml.j2 new file mode 100644 index 0000000..22e6205 --- /dev/null +++ b/template/config/kubernetes/apps/default/echo/app/ocirepository.yaml.j2 @@ -0,0 +1,13 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: echo +spec: + interval: 15m + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + ref: + tag: 0.2.5 + url: oci://ghcr.io/home-operations/charts/echo diff --git a/template/config/kubernetes/apps/default/echo/ks.yaml.j2 b/template/config/kubernetes/apps/default/echo/ks.yaml.j2 new file mode 100644 index 0000000..f942a81 --- /dev/null +++ b/template/config/kubernetes/apps/default/echo/ks.yaml.j2 @@ -0,0 +1,19 @@ +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: echo +spec: + interval: 1h + path: ./kubernetes/apps/default/echo/app + postBuild: + substituteFrom: + - name: cluster-secrets + kind: Secret + prune: true + sourceRef: + kind: GitRepository + name: flux-system + namespace: flux-system + targetNamespace: default + wait: false diff --git a/template/config/kubernetes/apps/default/kustomization.yaml.j2 b/template/config/kubernetes/apps/default/kustomization.yaml.j2 new file mode 100644 index 0000000..d7271b1 --- /dev/null +++ b/template/config/kubernetes/apps/default/kustomization.yaml.j2 @@ -0,0 +1,11 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: default + +components: + - ../../components/sops + +resources: + - ./namespace.yaml + - ./echo/ks.yaml diff --git a/template/config/kubernetes/apps/default/namespace.yaml.j2 b/template/config/kubernetes/apps/default/namespace.yaml.j2 new file mode 100644 index 0000000..e3f8a49 --- /dev/null +++ b/template/config/kubernetes/apps/default/namespace.yaml.j2 @@ -0,0 +1,7 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: default + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled diff --git a/template/config/kubernetes/apps/flux-system/flux-instance/app/helmrelease.yaml.j2 b/template/config/kubernetes/apps/flux-system/flux-instance/app/helmrelease.yaml.j2 new file mode 100644 index 0000000..bfd7588 --- /dev/null +++ b/template/config/kubernetes/apps/flux-system/flux-instance/app/helmrelease.yaml.j2 @@ -0,0 +1,136 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: flux-instance +spec: + chartRef: + kind: OCIRepository + name: flux-instance + interval: 1h + values: + commonAnnotations: + fluxcd.controlplane.io/reconcileArtifactEvery: 1h + instance: + cluster: + networkPolicy: false + components: + - source-controller + - kustomize-controller + - helm-controller + - notification-controller + sync: + kind: GitRepository + url: "#{ repository.url }#" + #% if repository.url.startswith('ssh://') %# + pullSecret: deploy-key + #% endif %# + ref: "refs/heads/#{ repository.branch }#" + path: kubernetes/flux/cluster + commonMetadata: + labels: + app.kubernetes.io/name: flux + kustomize: + patches: + - # Increase the number of workers + patch: | + - op: add + path: /spec/template/spec/containers/0/args/- + value: --concurrent=10 + - op: add + path: /spec/template/spec/containers/0/args/- + value: --requeue-dependency=5s + target: + kind: Deployment + name: (kustomize-controller|helm-controller|source-controller) + - # Increase the memory limits + patch: | + apiVersion: apps/v1 + kind: Deployment + metadata: + name: all + spec: + template: + spec: + containers: + - name: manager + resources: + limits: + memory: 1Gi + target: + kind: Deployment + name: (kustomize-controller|helm-controller|source-controller) + - # Enable in-memory kustomize builds + patch: | + - op: add + path: /spec/template/spec/containers/0/args/- + value: --concurrent=20 + - op: replace + path: /spec/template/spec/volumes/0 + value: + name: temp + emptyDir: + medium: Memory + target: + kind: Deployment + name: kustomize-controller + - # Enable Helm repositories caching + patch: | + - op: add + path: /spec/template/spec/containers/0/args/- + value: --helm-cache-max-size=10 + - op: add + path: /spec/template/spec/containers/0/args/- + value: --helm-cache-ttl=60m + - op: add + path: /spec/template/spec/containers/0/args/- + value: --helm-cache-purge-interval=5m + target: + kind: Deployment + name: source-controller + - # Flux near OOM detection for Helm + patch: | + - op: add + path: /spec/template/spec/containers/0/args/- + value: --feature-gates=OOMWatch=true + - op: add + path: /spec/template/spec/containers/0/args/- + value: --oom-watch-memory-threshold=95 + - op: add + path: /spec/template/spec/containers/0/args/- + value: --oom-watch-interval=500ms + target: + kind: Deployment + name: helm-controller + - # Disable chart digest tracking + patch: | + - op: add + path: /spec/template/spec/containers/0/args/- + value: --feature-gates=DisableChartDigestTracking=true + target: + kind: Deployment + name: helm-controller + - # Controller-level SOPS decryption + patch: | + - op: add + path: /spec/template/spec/containers/0/args/- + value: --sops-age-secret=sops-age + target: + kind: Deployment + name: kustomize-controller + - # Watch configmaps and secrets attached to HelmReleases and Kustomizations + patch: |- + - op: add + path: /spec/template/spec/containers/0/args/- + value: --watch-configs-label-selector=owner!=helm + target: + kind: Deployment + name: (helm-controller|kustomize-controller) + - # Cancel health checks on new Kustomizations revisions + patch: |- + - op: add + path: /spec/template/spec/containers/0/args/- + value: --feature-gates=CancelHealthCheckOnNewRevision=true + target: + kind: Deployment + name: kustomize-controller diff --git a/template/config/kubernetes/apps/flux-system/flux-instance/app/httproute.yaml.j2 b/template/config/kubernetes/apps/flux-system/flux-instance/app/httproute.yaml.j2 new file mode 100644 index 0000000..2685606 --- /dev/null +++ b/template/config/kubernetes/apps/flux-system/flux-instance/app/httproute.yaml.j2 @@ -0,0 +1,22 @@ +#% if repository.webhook_provider != 'none' %# +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: flux-webhook +spec: + hostnames: ["flux-webhook.${SECRET_DOMAIN}"] + parentRefs: + - name: envoy-#{ 'external' if ingress.mode != 'none' else 'internal' }# + namespace: network + sectionName: https + rules: + - backendRefs: + - name: webhook-receiver + namespace: flux-system + port: 80 + matches: + - path: + type: PathPrefix + value: /hook/ +#% endif %# diff --git a/template/config/kubernetes/apps/flux-system/flux-instance/app/kustomization.yaml.j2 b/template/config/kubernetes/apps/flux-system/flux-instance/app/kustomization.yaml.j2 new file mode 100644 index 0000000..b2fa265 --- /dev/null +++ b/template/config/kubernetes/apps/flux-system/flux-instance/app/kustomization.yaml.j2 @@ -0,0 +1,11 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ./helmrelease.yaml + - ./ocirepository.yaml + #% if repository.webhook_provider != 'none' %# + - ./secret.sops.yaml + - ./httproute.yaml + - ./receiver.yaml + #% endif %# diff --git a/template/config/kubernetes/apps/flux-system/flux-instance/app/ocirepository.yaml.j2 b/template/config/kubernetes/apps/flux-system/flux-instance/app/ocirepository.yaml.j2 new file mode 100644 index 0000000..8aa1733 --- /dev/null +++ b/template/config/kubernetes/apps/flux-system/flux-instance/app/ocirepository.yaml.j2 @@ -0,0 +1,13 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: flux-instance +spec: + interval: 15m + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + ref: + tag: 0.60.0 + url: oci://ghcr.io/controlplaneio-fluxcd/charts/flux-instance diff --git a/template/config/kubernetes/apps/flux-system/flux-instance/app/receiver.yaml.j2 b/template/config/kubernetes/apps/flux-system/flux-instance/app/receiver.yaml.j2 new file mode 100644 index 0000000..58866ac --- /dev/null +++ b/template/config/kubernetes/apps/flux-system/flux-instance/app/receiver.yaml.j2 @@ -0,0 +1,21 @@ +#% if repository.webhook_provider != 'none' %# +--- +apiVersion: notification.toolkit.fluxcd.io/v1 +kind: Receiver +metadata: + name: flux-webhook +spec: + type: #{ repository.webhook_provider }# + events: ["ping", "push"] + secretRef: + name: flux-webhook-token + resources: + - apiVersion: source.toolkit.fluxcd.io/v1 + kind: GitRepository + name: flux-system + namespace: flux-system + - apiVersion: kustomize.toolkit.fluxcd.io/v1 + kind: Kustomization + name: flux-system + namespace: flux-system +#% endif %# diff --git a/template/config/kubernetes/apps/flux-system/flux-instance/app/secret.sops.yaml.j2 b/template/config/kubernetes/apps/flux-system/flux-instance/app/secret.sops.yaml.j2 new file mode 100644 index 0000000..bbdb8cd --- /dev/null +++ b/template/config/kubernetes/apps/flux-system/flux-instance/app/secret.sops.yaml.j2 @@ -0,0 +1,9 @@ +#% if repository.webhook_provider != 'none' %# +--- +apiVersion: v1 +kind: Secret +metadata: + name: flux-webhook-token +stringData: + token: "#{ webhook_token() }#" +#% endif %# diff --git a/template/config/kubernetes/apps/flux-system/flux-instance/ks.yaml.j2 b/template/config/kubernetes/apps/flux-system/flux-instance/ks.yaml.j2 new file mode 100644 index 0000000..1a35461 --- /dev/null +++ b/template/config/kubernetes/apps/flux-system/flux-instance/ks.yaml.j2 @@ -0,0 +1,21 @@ +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: flux-instance +spec: + dependsOn: + - name: flux-operator + interval: 1h + path: ./kubernetes/apps/flux-system/flux-instance/app + postBuild: + substituteFrom: + - name: cluster-secrets + kind: Secret + prune: true + sourceRef: + kind: GitRepository + name: flux-system + namespace: flux-system + targetNamespace: flux-system + wait: false diff --git a/template/config/kubernetes/apps/flux-system/flux-operator/app/helmrelease.yaml.j2 b/template/config/kubernetes/apps/flux-system/flux-operator/app/helmrelease.yaml.j2 new file mode 100644 index 0000000..08fba38 --- /dev/null +++ b/template/config/kubernetes/apps/flux-system/flux-operator/app/helmrelease.yaml.j2 @@ -0,0 +1,13 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: flux-operator +spec: + chartRef: + kind: OCIRepository + name: flux-operator + interval: 1h + values: + serviceMonitor: + create: true diff --git a/template/config/kubernetes/apps/flux-system/flux-operator/app/kustomization.yaml.j2 b/template/config/kubernetes/apps/flux-system/flux-operator/app/kustomization.yaml.j2 new file mode 100644 index 0000000..2ccd5a8 --- /dev/null +++ b/template/config/kubernetes/apps/flux-system/flux-operator/app/kustomization.yaml.j2 @@ -0,0 +1,6 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ./helmrelease.yaml + - ./ocirepository.yaml diff --git a/template/config/kubernetes/apps/flux-system/flux-operator/app/ocirepository.yaml.j2 b/template/config/kubernetes/apps/flux-system/flux-operator/app/ocirepository.yaml.j2 new file mode 100644 index 0000000..dc939db --- /dev/null +++ b/template/config/kubernetes/apps/flux-system/flux-operator/app/ocirepository.yaml.j2 @@ -0,0 +1,13 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: flux-operator +spec: + interval: 15m + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + ref: + tag: 0.60.0 + url: oci://ghcr.io/controlplaneio-fluxcd/charts/flux-operator diff --git a/template/config/kubernetes/apps/flux-system/flux-operator/ks.yaml.j2 b/template/config/kubernetes/apps/flux-system/flux-operator/ks.yaml.j2 new file mode 100644 index 0000000..2e39824 --- /dev/null +++ b/template/config/kubernetes/apps/flux-system/flux-operator/ks.yaml.j2 @@ -0,0 +1,19 @@ +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: flux-operator +spec: + interval: 1h + path: ./kubernetes/apps/flux-system/flux-operator/app + postBuild: + substituteFrom: + - name: cluster-secrets + kind: Secret + prune: true + sourceRef: + kind: GitRepository + name: flux-system + namespace: flux-system + targetNamespace: flux-system + wait: true diff --git a/template/config/kubernetes/apps/flux-system/kustomization.yaml.j2 b/template/config/kubernetes/apps/flux-system/kustomization.yaml.j2 new file mode 100644 index 0000000..4b0dd43 --- /dev/null +++ b/template/config/kubernetes/apps/flux-system/kustomization.yaml.j2 @@ -0,0 +1,12 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: flux-system + +components: + - ../../components/sops + +resources: + - ./namespace.yaml + - ./flux-instance/ks.yaml + - ./flux-operator/ks.yaml diff --git a/template/config/kubernetes/apps/flux-system/namespace.yaml.j2 b/template/config/kubernetes/apps/flux-system/namespace.yaml.j2 new file mode 100644 index 0000000..120760a --- /dev/null +++ b/template/config/kubernetes/apps/flux-system/namespace.yaml.j2 @@ -0,0 +1,7 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: flux-system + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled diff --git a/template/config/kubernetes/apps/kube-system/cilium/app/helmrelease.yaml.j2 b/template/config/kubernetes/apps/kube-system/cilium/app/helmrelease.yaml.j2 new file mode 100644 index 0000000..c21ffae --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/cilium/app/helmrelease.yaml.j2 @@ -0,0 +1,91 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: cilium +spec: + chartRef: + kind: OCIRepository + name: cilium + interval: 1h + values: + autoDirectNodeRoutes: true + bpf: + masquerade: true + # Ref: https://github.com/siderolabs/talos/issues/10002 + hostLegacyRouting: true + #% if cilium_bgp_enabled %# + bgpControlPlane: + enabled: true + #% endif %# + cni: + # Required for pairing with Multus CNI + exclusive: false + cgroup: + automount: + enabled: false + hostRoot: /sys/fs/cgroup + # The stable bond name is defined in talos/all/20-network-links.yaml.tpl + devices: bond0+ + dashboards: + enabled: true + endpointRoutes: + enabled: true + envoy: + enabled: false + gatewayAPI: + enabled: false + hubble: + enabled: false + ipam: + mode: kubernetes + ipv4NativeRoutingCIDR: "#{ kubernetes.pod_cidr }#" + k8sServiceHost: 127.0.0.1 + k8sServicePort: 7445 + kubeProxyReplacement: true + kubeProxyReplacementHealthzBindAddr: 0.0.0.0:10256 + l2announcements: + enabled: true + loadBalancer: + algorithm: maglev + mode: "#{ cilium.loadbalancer_mode }#" + localRedirectPolicies: + enabled: true + operator: + dashboards: + enabled: true + prometheus: + enabled: true + serviceMonitor: + enabled: true + replicas: #{ 2 if nodes | length > 1 else 1 }# + rollOutPods: true + prometheus: + enabled: true + serviceMonitor: + enabled: true + trustCRDsExist: true + rollOutCiliumPods: true + routingMode: native + securityContext: + capabilities: + ciliumAgent: + - CHOWN + - KILL + - NET_ADMIN + - NET_RAW + - IPC_LOCK + - SYS_ADMIN + - SYS_RESOURCE + - PERFMON + - BPF + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID + cleanCiliumState: + - NET_ADMIN + - SYS_ADMIN + - SYS_RESOURCE + socketLB: + enabled: true diff --git a/template/config/kubernetes/apps/kube-system/cilium/app/kustomization.yaml.j2 b/template/config/kubernetes/apps/kube-system/cilium/app/kustomization.yaml.j2 new file mode 100644 index 0000000..791e352 --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/cilium/app/kustomization.yaml.j2 @@ -0,0 +1,7 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ./helmrelease.yaml + - ./ocirepository.yaml + - ./networks.yaml diff --git a/template/config/kubernetes/apps/kube-system/cilium/app/networks.yaml.j2 b/template/config/kubernetes/apps/kube-system/cilium/app/networks.yaml.j2 new file mode 100644 index 0000000..6a8d330 --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/cilium/app/networks.yaml.j2 @@ -0,0 +1,71 @@ +--- +apiVersion: cilium.io/v2alpha1 +kind: CiliumLoadBalancerIPPool +metadata: + name: pool +spec: + allowFirstLastIPs: "No" + blocks: + - cidr: "#{ network.node_cidr }#" +--- +apiVersion: cilium.io/v2alpha1 +kind: CiliumL2AnnouncementPolicy +metadata: + name: l2-policy +spec: + loadBalancerIPs: true + # NOTE: interfaces might need to be set if you have more than one active NIC on your hosts + # interfaces: + # - ^eno[0-9]+ + # - ^eth[0-9]+ + nodeSelector: + matchLabels: + kubernetes.io/os: linux +#% if cilium_bgp_enabled %# +--- +apiVersion: cilium.io/v2alpha1 +kind: CiliumBGPAdvertisement +metadata: + name: bgp-advertisement-config + labels: + advertise: bgp +spec: + advertisements: + - advertisementType: Service + service: + addresses: + - LoadBalancerIP + selector: + matchExpressions: + - { key: somekey, operator: NotIn, values: ["never-used-value"] } +--- +apiVersion: cilium.io/v2alpha1 +kind: CiliumBGPPeerConfig +metadata: + name: bgp-peer-config-v4 +spec: + families: + - afi: ipv4 + safi: unicast + advertisements: + matchLabels: + advertise: bgp +--- +apiVersion: cilium.io/v2alpha1 +kind: CiliumBGPClusterConfig +metadata: + name: bgp-cluster-config +spec: + nodeSelector: + matchLabels: + kubernetes.io/os: linux + bgpInstances: + - name: instance-#{ cilium.bgp.node_asn }# + localASN: #{ cilium.bgp.node_asn }# + peers: + - name: peer-#{ cilium.bgp.router_asn }#-v4 + peerASN: #{ cilium.bgp.router_asn }# + peerAddress: #{ cilium.bgp.router_addr }# + peerConfigRef: + name: bgp-peer-config-v4 +#% endif %# diff --git a/template/config/kubernetes/apps/kube-system/cilium/app/ocirepository.yaml.j2 b/template/config/kubernetes/apps/kube-system/cilium/app/ocirepository.yaml.j2 new file mode 100644 index 0000000..b95952e --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/cilium/app/ocirepository.yaml.j2 @@ -0,0 +1,13 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: cilium +spec: + interval: 15m + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + ref: + tag: 1.20.1 + url: oci://quay.io/cilium/charts/cilium diff --git a/template/config/kubernetes/apps/kube-system/cilium/ks.yaml.j2 b/template/config/kubernetes/apps/kube-system/cilium/ks.yaml.j2 new file mode 100644 index 0000000..ea0a835 --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/cilium/ks.yaml.j2 @@ -0,0 +1,19 @@ +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: cilium +spec: + interval: 1h + path: ./kubernetes/apps/kube-system/cilium/app + postBuild: + substituteFrom: + - name: cluster-secrets + kind: Secret + prune: true + sourceRef: + kind: GitRepository + name: flux-system + namespace: flux-system + targetNamespace: kube-system + wait: false diff --git a/template/config/kubernetes/apps/kube-system/coredns/app/helmrelease.yaml.j2 b/template/config/kubernetes/apps/kube-system/coredns/app/helmrelease.yaml.j2 new file mode 100644 index 0000000..3406a2c --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/coredns/app/helmrelease.yaml.j2 @@ -0,0 +1,69 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: coredns +spec: + chartRef: + kind: OCIRepository + name: coredns + interval: 1h + values: + fullnameOverride: coredns + image: + repository: mirror.gcr.io/coredns/coredns + k8sAppLabelOverride: kube-dns + serviceAccount: + create: true + service: + name: kube-dns + clusterIP: "#{ kubernetes.coredns_addr }#" + replicaCount: #{ 2 if controller_count > 1 else 1 }# + priorityClassName: system-cluster-critical + servers: + - zones: + - zone: . + scheme: dns:// + use_tcp: true + port: 53 + plugins: + - name: errors + - name: health + configBlock: |- + lameduck 5s + - name: ready + - name: kubernetes + parameters: cluster.local in-addr.arpa ip6.arpa + configBlock: |- + pods verified + fallthrough in-addr.arpa ip6.arpa + - name: autopath + parameters: "@kubernetes" + - name: forward + parameters: . /etc/resolv.conf + - name: cache + configBlock: |- + prefetch 20 + serve_stale + servfail 0 + - name: loop + - name: reload + - name: loadbalance + - name: prometheus + parameters: 0.0.0.0:9153 + - name: log + configBlock: |- + class error + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: node-role.kubernetes.io/control-plane + operator: Exists + tolerations: + - key: CriticalAddonsOnly + operator: Exists + - key: node-role.kubernetes.io/control-plane + operator: Exists + effect: NoSchedule diff --git a/template/config/kubernetes/apps/kube-system/coredns/app/kustomization.yaml.j2 b/template/config/kubernetes/apps/kube-system/coredns/app/kustomization.yaml.j2 new file mode 100644 index 0000000..2ccd5a8 --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/coredns/app/kustomization.yaml.j2 @@ -0,0 +1,6 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ./helmrelease.yaml + - ./ocirepository.yaml diff --git a/template/config/kubernetes/apps/kube-system/coredns/app/ocirepository.yaml.j2 b/template/config/kubernetes/apps/kube-system/coredns/app/ocirepository.yaml.j2 new file mode 100644 index 0000000..21e7231 --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/coredns/app/ocirepository.yaml.j2 @@ -0,0 +1,13 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: coredns +spec: + interval: 15m + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + url: oci://ghcr.io/coredns/charts/coredns + ref: + tag: 1.47.1 diff --git a/template/config/kubernetes/apps/kube-system/coredns/ks.yaml.j2 b/template/config/kubernetes/apps/kube-system/coredns/ks.yaml.j2 new file mode 100644 index 0000000..5ac2a87 --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/coredns/ks.yaml.j2 @@ -0,0 +1,19 @@ +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: coredns +spec: + interval: 1h + path: ./kubernetes/apps/kube-system/coredns/app + postBuild: + substituteFrom: + - name: cluster-secrets + kind: Secret + prune: true + sourceRef: + kind: GitRepository + name: flux-system + namespace: flux-system + targetNamespace: kube-system + wait: false diff --git a/template/config/kubernetes/apps/kube-system/kustomization.yaml.j2 b/template/config/kubernetes/apps/kube-system/kustomization.yaml.j2 new file mode 100644 index 0000000..a1d78a9 --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/kustomization.yaml.j2 @@ -0,0 +1,17 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: kube-system + +components: + - ../../components/sops + +resources: + - ./namespace.yaml + - ./cilium/ks.yaml + - ./coredns/ks.yaml + - ./metrics-server/ks.yaml + - ./reloader/ks.yaml + #% if spegel.enabled %# + - ./spegel/ks.yaml + #% endif %# diff --git a/template/config/kubernetes/apps/kube-system/metrics-server/app/helmrelease.yaml.j2 b/template/config/kubernetes/apps/kube-system/metrics-server/app/helmrelease.yaml.j2 new file mode 100644 index 0000000..befc886 --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/metrics-server/app/helmrelease.yaml.j2 @@ -0,0 +1,21 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: metrics-server +spec: + chartRef: + kind: OCIRepository + name: metrics-server + interval: 1h + values: + args: + - --kubelet-insecure-tls + - --kubelet-preferred-address-types=InternalIP,ExternalIP,Hostname + - --kubelet-use-node-status-port + - --metric-resolution=10s + - --kubelet-request-timeout=2s + metrics: + enabled: true + serviceMonitor: + enabled: true diff --git a/template/config/kubernetes/apps/kube-system/metrics-server/app/kustomization.yaml.j2 b/template/config/kubernetes/apps/kube-system/metrics-server/app/kustomization.yaml.j2 new file mode 100644 index 0000000..2ccd5a8 --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/metrics-server/app/kustomization.yaml.j2 @@ -0,0 +1,6 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ./helmrelease.yaml + - ./ocirepository.yaml diff --git a/template/config/kubernetes/apps/kube-system/metrics-server/app/ocirepository.yaml.j2 b/template/config/kubernetes/apps/kube-system/metrics-server/app/ocirepository.yaml.j2 new file mode 100644 index 0000000..cf8351d --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/metrics-server/app/ocirepository.yaml.j2 @@ -0,0 +1,13 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: metrics-server +spec: + interval: 15m + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + ref: + tag: 3.14.0 + url: oci://ghcr.io/home-operations/charts-mirror/metrics-server diff --git a/template/config/kubernetes/apps/kube-system/metrics-server/ks.yaml.j2 b/template/config/kubernetes/apps/kube-system/metrics-server/ks.yaml.j2 new file mode 100644 index 0000000..60042b7 --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/metrics-server/ks.yaml.j2 @@ -0,0 +1,19 @@ +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: metrics-server +spec: + interval: 1h + path: ./kubernetes/apps/kube-system/metrics-server/app + postBuild: + substituteFrom: + - name: cluster-secrets + kind: Secret + prune: true + sourceRef: + kind: GitRepository + name: flux-system + namespace: flux-system + targetNamespace: kube-system + wait: false diff --git a/template/config/kubernetes/apps/kube-system/namespace.yaml.j2 b/template/config/kubernetes/apps/kube-system/namespace.yaml.j2 new file mode 100644 index 0000000..7f6e64f --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/namespace.yaml.j2 @@ -0,0 +1,7 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: kube-system + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled diff --git a/template/config/kubernetes/apps/kube-system/reloader/app/helmrelease.yaml.j2 b/template/config/kubernetes/apps/kube-system/reloader/app/helmrelease.yaml.j2 new file mode 100644 index 0000000..100b095 --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/reloader/app/helmrelease.yaml.j2 @@ -0,0 +1,17 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: reloader +spec: + chartRef: + kind: OCIRepository + name: reloader + interval: 1h + values: + fullnameOverride: reloader + reloader: + readOnlyRootFileSystem: true + podMonitor: + enabled: true + namespace: "{{ .Release.Namespace }}" diff --git a/template/config/kubernetes/apps/kube-system/reloader/app/kustomization.yaml.j2 b/template/config/kubernetes/apps/kube-system/reloader/app/kustomization.yaml.j2 new file mode 100644 index 0000000..2ccd5a8 --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/reloader/app/kustomization.yaml.j2 @@ -0,0 +1,6 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ./helmrelease.yaml + - ./ocirepository.yaml diff --git a/template/config/kubernetes/apps/kube-system/reloader/app/ocirepository.yaml.j2 b/template/config/kubernetes/apps/kube-system/reloader/app/ocirepository.yaml.j2 new file mode 100644 index 0000000..98a2f4d --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/reloader/app/ocirepository.yaml.j2 @@ -0,0 +1,13 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: reloader +spec: + interval: 15m + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + ref: + tag: 2.2.17 + url: oci://ghcr.io/stakater/charts/reloader diff --git a/template/config/kubernetes/apps/kube-system/reloader/ks.yaml.j2 b/template/config/kubernetes/apps/kube-system/reloader/ks.yaml.j2 new file mode 100644 index 0000000..5f5c65a --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/reloader/ks.yaml.j2 @@ -0,0 +1,19 @@ +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: reloader +spec: + interval: 1h + path: ./kubernetes/apps/kube-system/reloader/app + postBuild: + substituteFrom: + - name: cluster-secrets + kind: Secret + prune: true + sourceRef: + kind: GitRepository + name: flux-system + namespace: flux-system + targetNamespace: kube-system + wait: false diff --git a/template/config/kubernetes/apps/kube-system/spegel/app/helmrelease.yaml.j2 b/template/config/kubernetes/apps/kube-system/spegel/app/helmrelease.yaml.j2 new file mode 100644 index 0000000..fb1cfa4 --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/spegel/app/helmrelease.yaml.j2 @@ -0,0 +1,21 @@ +#% if spegel.enabled %# +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: spegel +spec: + chartRef: + kind: OCIRepository + name: spegel + interval: 1h + values: + spegel: + containerdSock: /run/containerd/containerd.sock + containerdRegistryConfigPath: /etc/cri/conf.d/hosts + service: + registry: + hostPort: 29999 + serviceMonitor: + enabled: true +#% endif %# diff --git a/template/config/kubernetes/apps/kube-system/spegel/app/kustomization.yaml.j2 b/template/config/kubernetes/apps/kube-system/spegel/app/kustomization.yaml.j2 new file mode 100644 index 0000000..f61cc97 --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/spegel/app/kustomization.yaml.j2 @@ -0,0 +1,8 @@ +#% if spegel.enabled %# +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ./helmrelease.yaml + - ./ocirepository.yaml +#% endif %# diff --git a/template/config/kubernetes/apps/kube-system/spegel/app/ocirepository.yaml.j2 b/template/config/kubernetes/apps/kube-system/spegel/app/ocirepository.yaml.j2 new file mode 100644 index 0000000..867a7f9 --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/spegel/app/ocirepository.yaml.j2 @@ -0,0 +1,15 @@ +#% if spegel.enabled %# +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: spegel +spec: + interval: 15m + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + ref: + tag: 0.7.4 + url: oci://ghcr.io/spegel-org/helm-charts/spegel +#% endif %# diff --git a/template/config/kubernetes/apps/kube-system/spegel/ks.yaml.j2 b/template/config/kubernetes/apps/kube-system/spegel/ks.yaml.j2 new file mode 100644 index 0000000..4158fb1 --- /dev/null +++ b/template/config/kubernetes/apps/kube-system/spegel/ks.yaml.j2 @@ -0,0 +1,21 @@ +#% if spegel.enabled %# +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: spegel +spec: + interval: 1h + path: ./kubernetes/apps/kube-system/spegel/app + postBuild: + substituteFrom: + - name: cluster-secrets + kind: Secret + prune: true + sourceRef: + kind: GitRepository + name: flux-system + namespace: flux-system + targetNamespace: kube-system + wait: false +#% endif %# diff --git a/template/config/kubernetes/apps/network/cloudflare-dns/app/helmrelease.yaml.j2 b/template/config/kubernetes/apps/network/cloudflare-dns/app/helmrelease.yaml.j2 new file mode 100644 index 0000000..19714a4 --- /dev/null +++ b/template/config/kubernetes/apps/network/cloudflare-dns/app/helmrelease.yaml.j2 @@ -0,0 +1,37 @@ +#% if dns.provider == 'cloudflare' and ingress.mode != 'none' %# +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: &app cloudflare-dns +spec: + chartRef: + kind: OCIRepository + name: cloudflare-dns + interval: 1h + values: + fullnameOverride: *app + provider: cloudflare + env: + - name: CF_API_TOKEN + valueFrom: + secretKeyRef: + name: &secret cloudflare-dns-secret + key: api-token + extraArgs: + - --cloudflare-dns-records-per-page=1000 + - --cloudflare-proxied + - --crd-source-apiversion=externaldns.k8s.io/v1alpha1 + - --crd-source-kind=DNSEndpoint + - --gateway-name=envoy-external + triggerLoopOnEvent: true + policy: sync + sources: ["crd", "gateway-httproute"] + txtPrefix: k8s. + txtOwnerId: default + domainFilters: ["${SECRET_DOMAIN}"] + serviceMonitor: + enabled: true + podAnnotations: + secret.reloader.stakater.com/reload: *secret +#% endif %# diff --git a/template/config/kubernetes/apps/network/cloudflare-dns/app/kustomization.yaml.j2 b/template/config/kubernetes/apps/network/cloudflare-dns/app/kustomization.yaml.j2 new file mode 100644 index 0000000..6aa29bb --- /dev/null +++ b/template/config/kubernetes/apps/network/cloudflare-dns/app/kustomization.yaml.j2 @@ -0,0 +1,9 @@ +#% if dns.provider == 'cloudflare' and ingress.mode != 'none' %# +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ./secret.sops.yaml + - ./helmrelease.yaml + - ./ocirepository.yaml +#% endif %# diff --git a/template/config/kubernetes/apps/network/cloudflare-dns/app/ocirepository.yaml.j2 b/template/config/kubernetes/apps/network/cloudflare-dns/app/ocirepository.yaml.j2 new file mode 100644 index 0000000..126d418 --- /dev/null +++ b/template/config/kubernetes/apps/network/cloudflare-dns/app/ocirepository.yaml.j2 @@ -0,0 +1,15 @@ +#% if dns.provider == 'cloudflare' and ingress.mode != 'none' %# +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: cloudflare-dns +spec: + interval: 15m + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + ref: + tag: 1.22.0 + url: oci://ghcr.io/home-operations/charts-mirror/external-dns +#% endif %# diff --git a/template/config/kubernetes/apps/network/cloudflare-dns/app/secret.sops.yaml.j2 b/template/config/kubernetes/apps/network/cloudflare-dns/app/secret.sops.yaml.j2 new file mode 100644 index 0000000..1e95e0a --- /dev/null +++ b/template/config/kubernetes/apps/network/cloudflare-dns/app/secret.sops.yaml.j2 @@ -0,0 +1,9 @@ +#% if dns.provider == 'cloudflare' and ingress.mode != 'none' %# +--- +apiVersion: v1 +kind: Secret +metadata: + name: cloudflare-dns-secret +stringData: + api-token: "#{ dns.token }#" +#% endif %# diff --git a/template/config/kubernetes/apps/network/cloudflare-dns/ks.yaml.j2 b/template/config/kubernetes/apps/network/cloudflare-dns/ks.yaml.j2 new file mode 100644 index 0000000..56c3c2f --- /dev/null +++ b/template/config/kubernetes/apps/network/cloudflare-dns/ks.yaml.j2 @@ -0,0 +1,21 @@ +#% if dns.provider == 'cloudflare' and ingress.mode != 'none' %# +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: cloudflare-dns +spec: + interval: 1h + path: ./kubernetes/apps/network/cloudflare-dns/app + postBuild: + substituteFrom: + - name: cluster-secrets + kind: Secret + prune: true + sourceRef: + kind: GitRepository + name: flux-system + namespace: flux-system + targetNamespace: network + wait: true +#% endif %# diff --git a/template/config/kubernetes/apps/network/cloudflare-tunnel/app/dnsendpoint.yaml.j2 b/template/config/kubernetes/apps/network/cloudflare-tunnel/app/dnsendpoint.yaml.j2 new file mode 100644 index 0000000..0e0683e --- /dev/null +++ b/template/config/kubernetes/apps/network/cloudflare-tunnel/app/dnsendpoint.yaml.j2 @@ -0,0 +1,12 @@ +#% if ingress.mode == 'cloudflare-tunnel' %# +--- +apiVersion: externaldns.k8s.io/v1alpha1 +kind: DNSEndpoint +metadata: + name: cloudflare-tunnel +spec: + endpoints: + - dnsName: "external.${SECRET_DOMAIN}" + recordType: CNAME + targets: ["#{ cloudflare_tunnel_id() }#.cfargotunnel.com"] +#% endif %# diff --git a/template/config/kubernetes/apps/network/cloudflare-tunnel/app/helmrelease.yaml.j2 b/template/config/kubernetes/apps/network/cloudflare-tunnel/app/helmrelease.yaml.j2 new file mode 100644 index 0000000..ec8d1a2 --- /dev/null +++ b/template/config/kubernetes/apps/network/cloudflare-tunnel/app/helmrelease.yaml.j2 @@ -0,0 +1,86 @@ +#% if ingress.mode == 'cloudflare-tunnel' %# +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: cloudflare-tunnel +spec: + chartRef: + kind: OCIRepository + name: cloudflare-tunnel + interval: 1h + values: + controllers: + cloudflare-tunnel: + strategy: RollingUpdate + annotations: + reloader.stakater.com/auto: "true" + containers: + app: + image: + repository: docker.io/cloudflare/cloudflared + tag: 2026.9.1 + env: + NO_AUTOUPDATE: true + TUNNEL_METRICS: 0.0.0.0:8080 + TUNNEL_POST_QUANTUM: true # disable when using http2 + TUNNEL_TRANSPORT_PROTOCOL: quic # or http2 + envFrom: + - secretRef: + name: cloudflare-tunnel-secret + args: ["tunnel", "run"] + probes: + liveness: &probes + enabled: true + custom: true + spec: + httpGet: + path: /ready + port: &port 8080 + initialDelaySeconds: 0 + periodSeconds: 10 + timeoutSeconds: 1 + failureThreshold: 3 + readiness: *probes + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: ["ALL"] } + resources: + requests: + cpu: 10m + limits: + memory: 256Mi + defaultPodOptions: + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + service: + app: + ports: + http: + port: *port + serviceMonitor: + app: + endpoints: + - port: http + configMaps: + config: + data: + config.yaml: |- + ingress: + - hostname: "*.${SECRET_DOMAIN}" + originRequest: + http2Origin: true + originServerName: external.${SECRET_DOMAIN} + service: https://envoy-external.{{ .Release.Namespace }}.svc.cluster.local:443 + - service: http_status:404 + persistence: + config-file: + type: configMap + identifier: config + globalMounts: + - path: /etc/cloudflared/config.yaml + subPath: config.yaml +#% endif %# diff --git a/template/config/kubernetes/apps/network/cloudflare-tunnel/app/kustomization.yaml.j2 b/template/config/kubernetes/apps/network/cloudflare-tunnel/app/kustomization.yaml.j2 new file mode 100644 index 0000000..4defe8e --- /dev/null +++ b/template/config/kubernetes/apps/network/cloudflare-tunnel/app/kustomization.yaml.j2 @@ -0,0 +1,10 @@ +#% if ingress.mode == 'cloudflare-tunnel' %# +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ./dnsendpoint.yaml + - ./secret.sops.yaml + - ./helmrelease.yaml + - ./ocirepository.yaml +#% endif %# diff --git a/template/config/kubernetes/apps/network/cloudflare-tunnel/app/ocirepository.yaml.j2 b/template/config/kubernetes/apps/network/cloudflare-tunnel/app/ocirepository.yaml.j2 new file mode 100644 index 0000000..915c541 --- /dev/null +++ b/template/config/kubernetes/apps/network/cloudflare-tunnel/app/ocirepository.yaml.j2 @@ -0,0 +1,15 @@ +#% if ingress.mode == 'cloudflare-tunnel' %# +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: cloudflare-tunnel +spec: + interval: 15m + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + ref: + tag: 5.1.0 + url: oci://ghcr.io/bjw-s-labs/helm/app-template +#% endif %# diff --git a/template/config/kubernetes/apps/network/cloudflare-tunnel/app/secret.sops.yaml.j2 b/template/config/kubernetes/apps/network/cloudflare-tunnel/app/secret.sops.yaml.j2 new file mode 100644 index 0000000..7b1088f --- /dev/null +++ b/template/config/kubernetes/apps/network/cloudflare-tunnel/app/secret.sops.yaml.j2 @@ -0,0 +1,9 @@ +#% if ingress.mode == 'cloudflare-tunnel' %# +--- +apiVersion: v1 +kind: Secret +metadata: + name: cloudflare-tunnel-secret +stringData: + TUNNEL_TOKEN: "#{ cloudflare_tunnel_secret() }#" +#% endif %# diff --git a/template/config/kubernetes/apps/network/cloudflare-tunnel/ks.yaml.j2 b/template/config/kubernetes/apps/network/cloudflare-tunnel/ks.yaml.j2 new file mode 100644 index 0000000..eb2dfa9 --- /dev/null +++ b/template/config/kubernetes/apps/network/cloudflare-tunnel/ks.yaml.j2 @@ -0,0 +1,21 @@ +#% if ingress.mode == 'cloudflare-tunnel' %# +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: cloudflare-tunnel +spec: + interval: 1h + path: ./kubernetes/apps/network/cloudflare-tunnel/app + postBuild: + substituteFrom: + - name: cluster-secrets + kind: Secret + prune: true + sourceRef: + kind: GitRepository + name: flux-system + namespace: flux-system + targetNamespace: network + wait: false +#% endif %# diff --git a/template/config/kubernetes/apps/network/envoy-gateway/app/certificate.yaml.j2 b/template/config/kubernetes/apps/network/envoy-gateway/app/certificate.yaml.j2 new file mode 100644 index 0000000..2c6b27b --- /dev/null +++ b/template/config/kubernetes/apps/network/envoy-gateway/app/certificate.yaml.j2 @@ -0,0 +1,18 @@ +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: "${SECRET_DOMAIN/./-}-production" +spec: + dnsNames: + - "${SECRET_DOMAIN}" + - "*.${SECRET_DOMAIN}" + duration: 160h + issuerRef: + name: #{ cluster_issuer }# + kind: ClusterIssuer + privateKey: + algorithm: ECDSA + secretName: "${SECRET_DOMAIN/./-}-production-tls" + usages: + - digital signature diff --git a/template/config/kubernetes/apps/network/envoy-gateway/app/envoy.yaml.j2 b/template/config/kubernetes/apps/network/envoy-gateway/app/envoy.yaml.j2 new file mode 100644 index 0000000..134217f --- /dev/null +++ b/template/config/kubernetes/apps/network/envoy-gateway/app/envoy.yaml.j2 @@ -0,0 +1,174 @@ +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyProxy +metadata: + name: envoy +spec: + logging: + level: + default: info + provider: + type: Kubernetes + kubernetes: + envoyDeployment: + replicas: #{ 2 if nodes | length > 1 else 1 }# + container: + imageRepository: mirror.gcr.io/envoyproxy/envoy + resources: + requests: + cpu: 100m + limits: + memory: 1Gi + envoyService: + externalTrafficPolicy: Cluster + shutdown: + drainTimeout: 180s + telemetry: + metrics: + prometheus: + compression: + type: Zstd +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: envoy +spec: + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parametersRef: + group: gateway.envoyproxy.io + kind: EnvoyProxy + name: envoy + namespace: network +#% if ingress.mode != 'none' %# +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: envoy-external + annotations: + external-dns.kubernetes.io/target: external.${SECRET_DOMAIN} +spec: + gatewayClassName: envoy + infrastructure: + annotations: + external-dns.kubernetes.io/hostname: external.${SECRET_DOMAIN} + lbipam.cilium.io/ips: "#{ gateways.external }#" + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: Same + - name: https + protocol: HTTPS + port: 443 + allowedRoutes: + namespaces: + from: All + tls: + certificateRefs: + - kind: Secret + name: ${SECRET_DOMAIN/./-}-production-tls +#% endif %# +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: envoy-internal + annotations: + external-dns.kubernetes.io/target: internal.${SECRET_DOMAIN} +spec: + gatewayClassName: envoy + infrastructure: + annotations: + external-dns.kubernetes.io/hostname: internal.${SECRET_DOMAIN} + lbipam.cilium.io/ips: "#{ gateways.internal }#" + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: Same + - name: https + protocol: HTTPS + port: 443 + allowedRoutes: + namespaces: + from: All + tls: + certificateRefs: + - kind: Secret + name: ${SECRET_DOMAIN/./-}-production-tls +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: BackendTrafficPolicy +metadata: + name: envoy +spec: + compressor: + - type: Zstd + zstd: {} + - type: Brotli + brotli: {} + - type: Gzip + gzip: {} + retry: + numRetries: 2 + retryOn: + triggers: + - reset + targetSelectors: + - group: gateway.networking.k8s.io + kind: Gateway + tcpKeepalive: {} + timeout: + http: + requestTimeout: 0s +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: ClientTrafficPolicy +metadata: + name: envoy +spec: + clientIPDetection: + xForwardedFor: + trustedCIDRs: + - "#{ kubernetes.pod_cidr }#" + http2: + onInvalidMessage: TerminateStream + http3: {} + targetSelectors: + - group: gateway.networking.k8s.io + kind: Gateway + tcpKeepalive: {} + tls: + minVersion: "1.2" + alpnProtocols: + - h2 + - http/1.1 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: https-redirect + annotations: + external-dns.kubernetes.io/controller: none +spec: + parentRefs: + #% if ingress.mode != 'none' %# + - name: envoy-external + namespace: network + sectionName: http + #% endif %# + - name: envoy-internal + namespace: network + sectionName: http + rules: + - filters: + - type: RequestRedirect + requestRedirect: + scheme: https + statusCode: 301 diff --git a/template/config/kubernetes/apps/network/envoy-gateway/app/helmrelease.yaml.j2 b/template/config/kubernetes/apps/network/envoy-gateway/app/helmrelease.yaml.j2 new file mode 100644 index 0000000..55f3dea --- /dev/null +++ b/template/config/kubernetes/apps/network/envoy-gateway/app/helmrelease.yaml.j2 @@ -0,0 +1,20 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: envoy-gateway +spec: + chartRef: + kind: OCIRepository + name: envoy-gateway + interval: 1h + values: + global: + imageRegistry: mirror.gcr.io + config: + envoyGateway: + provider: + type: Kubernetes + kubernetes: + deploy: + type: GatewayNamespace diff --git a/template/config/kubernetes/apps/network/envoy-gateway/app/kustomization.yaml.j2 b/template/config/kubernetes/apps/network/envoy-gateway/app/kustomization.yaml.j2 new file mode 100644 index 0000000..545ef59 --- /dev/null +++ b/template/config/kubernetes/apps/network/envoy-gateway/app/kustomization.yaml.j2 @@ -0,0 +1,9 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ./certificate.yaml + - ./envoy.yaml + - ./helmrelease.yaml + - ./ocirepository.yaml + - ./podmonitor.yaml diff --git a/template/config/kubernetes/apps/network/envoy-gateway/app/ocirepository.yaml.j2 b/template/config/kubernetes/apps/network/envoy-gateway/app/ocirepository.yaml.j2 new file mode 100644 index 0000000..de028ab --- /dev/null +++ b/template/config/kubernetes/apps/network/envoy-gateway/app/ocirepository.yaml.j2 @@ -0,0 +1,13 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: envoy-gateway +spec: + interval: 15m + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + ref: + tag: 1.9.1 + url: oci://mirror.gcr.io/envoyproxy/gateway-helm diff --git a/template/config/kubernetes/apps/network/envoy-gateway/app/podmonitor.yaml.j2 b/template/config/kubernetes/apps/network/envoy-gateway/app/podmonitor.yaml.j2 new file mode 100644 index 0000000..b199aa8 --- /dev/null +++ b/template/config/kubernetes/apps/network/envoy-gateway/app/podmonitor.yaml.j2 @@ -0,0 +1,18 @@ +--- +apiVersion: monitoring.coreos.com/v1 +kind: PodMonitor +metadata: + name: envoy-proxy +spec: + jobLabel: envoy-proxy + namespaceSelector: + matchNames: + - network + podMetricsEndpoints: + - port: metrics + path: /stats/prometheus + honorLabels: true + selector: + matchLabels: + app.kubernetes.io/component: proxy + app.kubernetes.io/name: envoy diff --git a/template/config/kubernetes/apps/network/envoy-gateway/ks.yaml.j2 b/template/config/kubernetes/apps/network/envoy-gateway/ks.yaml.j2 new file mode 100644 index 0000000..78a3ce9 --- /dev/null +++ b/template/config/kubernetes/apps/network/envoy-gateway/ks.yaml.j2 @@ -0,0 +1,19 @@ +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: envoy-gateway +spec: + interval: 1h + path: ./kubernetes/apps/network/envoy-gateway/app + postBuild: + substituteFrom: + - name: cluster-secrets + kind: Secret + prune: true + sourceRef: + kind: GitRepository + name: flux-system + namespace: flux-system + targetNamespace: network + wait: false diff --git a/template/config/kubernetes/apps/network/k8s-gateway/app/helmrelease.yaml.j2 b/template/config/kubernetes/apps/network/k8s-gateway/app/helmrelease.yaml.j2 new file mode 100644 index 0000000..b3c6a32 --- /dev/null +++ b/template/config/kubernetes/apps/network/k8s-gateway/app/helmrelease.yaml.j2 @@ -0,0 +1,21 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: k8s-gateway +spec: + chartRef: + kind: OCIRepository + name: k8s-gateway + interval: 1h + values: + fullnameOverride: k8s-gateway + domain: "${SECRET_DOMAIN}" + ttl: 1 + service: + type: LoadBalancer + port: 53 + annotations: + lbipam.cilium.io/ips: "#{ gateways.dns }#" + externalTrafficPolicy: Cluster + watchedResources: ["HTTPRoute", "Service"] diff --git a/template/config/kubernetes/apps/network/k8s-gateway/app/kustomization.yaml.j2 b/template/config/kubernetes/apps/network/k8s-gateway/app/kustomization.yaml.j2 new file mode 100644 index 0000000..2ccd5a8 --- /dev/null +++ b/template/config/kubernetes/apps/network/k8s-gateway/app/kustomization.yaml.j2 @@ -0,0 +1,6 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ./helmrelease.yaml + - ./ocirepository.yaml diff --git a/template/config/kubernetes/apps/network/k8s-gateway/app/ocirepository.yaml.j2 b/template/config/kubernetes/apps/network/k8s-gateway/app/ocirepository.yaml.j2 new file mode 100644 index 0000000..29e381d --- /dev/null +++ b/template/config/kubernetes/apps/network/k8s-gateway/app/ocirepository.yaml.j2 @@ -0,0 +1,13 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: k8s-gateway +spec: + interval: 1h + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + ref: + tag: 3.7.3 + url: oci://codeberg.org/k8s-gateway/charts/k8s-gateway diff --git a/template/config/kubernetes/apps/network/k8s-gateway/ks.yaml.j2 b/template/config/kubernetes/apps/network/k8s-gateway/ks.yaml.j2 new file mode 100644 index 0000000..844476f --- /dev/null +++ b/template/config/kubernetes/apps/network/k8s-gateway/ks.yaml.j2 @@ -0,0 +1,19 @@ +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: k8s-gateway +spec: + interval: 1h + path: ./kubernetes/apps/network/k8s-gateway/app + postBuild: + substituteFrom: + - name: cluster-secrets + kind: Secret + prune: true + sourceRef: + kind: GitRepository + name: flux-system + namespace: flux-system + targetNamespace: network + wait: false diff --git a/template/config/kubernetes/apps/network/kustomization.yaml.j2 b/template/config/kubernetes/apps/network/kustomization.yaml.j2 new file mode 100644 index 0000000..dd63d5f --- /dev/null +++ b/template/config/kubernetes/apps/network/kustomization.yaml.j2 @@ -0,0 +1,18 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: network + +components: + - ../../components/sops + +resources: + - ./namespace.yaml + #% if dns.provider == 'cloudflare' and ingress.mode != 'none' %# + - ./cloudflare-dns/ks.yaml + #% endif %# + #% if ingress.mode == 'cloudflare-tunnel' %# + - ./cloudflare-tunnel/ks.yaml + #% endif %# + - ./envoy-gateway/ks.yaml + - ./k8s-gateway/ks.yaml diff --git a/template/config/kubernetes/apps/network/namespace.yaml.j2 b/template/config/kubernetes/apps/network/namespace.yaml.j2 new file mode 100644 index 0000000..17866ba --- /dev/null +++ b/template/config/kubernetes/apps/network/namespace.yaml.j2 @@ -0,0 +1,7 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: network + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled diff --git a/template/config/kubernetes/components/sops/cluster-secrets.sops.yaml.j2 b/template/config/kubernetes/components/sops/cluster-secrets.sops.yaml.j2 new file mode 100644 index 0000000..5c7e4c4 --- /dev/null +++ b/template/config/kubernetes/components/sops/cluster-secrets.sops.yaml.j2 @@ -0,0 +1,7 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: cluster-secrets +stringData: + SECRET_DOMAIN: "#{ domain.name }#" diff --git a/template/config/kubernetes/components/sops/kustomization.yaml.j2 b/template/config/kubernetes/components/sops/kustomization.yaml.j2 new file mode 100644 index 0000000..1948b44 --- /dev/null +++ b/template/config/kubernetes/components/sops/kustomization.yaml.j2 @@ -0,0 +1,5 @@ +--- +apiVersion: kustomize.config.k8s.io/v1alpha1 +kind: Component +resources: + - ./cluster-secrets.sops.yaml diff --git a/template/config/kubernetes/flux/cluster/ks.yaml.j2 b/template/config/kubernetes/flux/cluster/ks.yaml.j2 new file mode 100644 index 0000000..77d76e8 --- /dev/null +++ b/template/config/kubernetes/flux/cluster/ks.yaml.j2 @@ -0,0 +1,57 @@ +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: cluster-apps + namespace: flux-system +spec: + decryption: + provider: sops + deletionPolicy: WaitForTermination + interval: 1h + path: ./kubernetes/apps + prune: true + sourceRef: + kind: GitRepository + name: flux-system + namespace: flux-system + wait: false + patches: + - # Add Kustomization defaults for all child Kustomizations + patch: |- + apiVersion: kustomize.toolkit.fluxcd.io/v1 + kind: Kustomization + metadata: + name: _ + spec: + decryption: + provider: sops + deletionPolicy: WaitForTermination + patches: + - patch: |- + apiVersion: helm.toolkit.fluxcd.io/v2 + kind: HelmRelease + metadata: + name: _ + spec: + install: + crds: CreateReplace + strategy: + name: RetryOnFailure + rollback: + cleanupOnFail: true + recreate: true + upgrade: + cleanupOnFail: true + crds: CreateReplace + strategy: + name: RemediateOnFailure + remediation: + remediateLastFailure: true + retries: 2 + target: + group: helm.toolkit.fluxcd.io + kind: HelmRelease + target: + group: kustomize.toolkit.fluxcd.io + kind: Kustomization diff --git a/template/config/kubernetes/mod.just b/template/config/kubernetes/mod.just new file mode 100644 index 0000000..b1de2b1 --- /dev/null +++ b/template/config/kubernetes/mod.just @@ -0,0 +1,10 @@ +set quiet +set shell := ['bash', '-euo', 'pipefail', '-c'] +set script-interpreter := ['bash', '-euo', 'pipefail'] +set default-list +set default-script + +[doc('Force Flux to pull in changes from your Git repository')] +[group('kube')] +reconcile: + flux --namespace flux-system reconcile kustomization flux-system --with-source diff --git a/template/config/talos/README.md.j2 b/template/config/talos/README.md.j2 new file mode 100644 index 0000000..b0359f7 --- /dev/null +++ b/template/config/talos/README.md.j2 @@ -0,0 +1,20 @@ +# Talos Patching + +Machine configs are assembled by [topf](https://postfinance.github.io/topf/) from +`topf.yaml` plus the strategic merge patches in this directory. + + + +## Patch Directories + +Patches merge in this order, alphabetically within each directory, with later +patches taking precedence: + +- `all/`: applied to every node +- `control-plane/`: applied to control-plane nodes +- `worker/`: applied to worker nodes +- `node/${hostname}/`: applied to the node with the specified name + +Files ending in `.yaml.tpl` are Go-templated per node; see the +[topf configuration model](https://postfinance.github.io/topf/main/configuration-model/) +for the available template variables. diff --git a/template/config/talos/all/00-install.yaml.tpl.j2 b/template/config/talos/all/00-install.yaml.tpl.j2 new file mode 100644 index 0000000..1a38a86 --- /dev/null +++ b/template/config/talos/all/00-install.yaml.tpl.j2 @@ -0,0 +1,9 @@ +apiVersion: v1alpha1 +kind: UnattendedInstallConfig +provisioning: + diskSelector: + {{- if .Node.Data.installDisk }} + match: disk.dev_path == "{{ .Node.Data.installDisk }}" || "{{ .Node.Data.installDisk }}" in disk.symlinks + {{- else }} + match: disk.serial == "{{ .Node.Data.installDiskSerial }}" + {{- end }} diff --git a/template/config/talos/all/01-hostname.yaml.tpl.j2 b/template/config/talos/all/01-hostname.yaml.tpl.j2 new file mode 100644 index 0000000..a6ac043 --- /dev/null +++ b/template/config/talos/all/01-hostname.yaml.tpl.j2 @@ -0,0 +1,4 @@ +apiVersion: v1alpha1 +kind: HostnameConfig +auto: "off" +hostname: "{{ .Node.Host }}" diff --git a/template/config/talos/all/10-cluster.yaml.j2 b/template/config/talos/all/10-cluster.yaml.j2 new file mode 100644 index 0000000..4a9292f --- /dev/null +++ b/template/config/talos/all/10-cluster.yaml.j2 @@ -0,0 +1,10 @@ +machine: + certSANs: + #% for item in cert_sans %# + - "#{ item }#" + #% endfor %# +--- +apiVersion: v1alpha1 +kind: KubeNetworkConfig +podSubnets: ["#{ kubernetes.pod_cidr }#"] +serviceSubnets: ["#{ kubernetes.svc_cidr }#"] diff --git a/template/config/talos/all/20-network-links.yaml.tpl.j2 b/template/config/talos/all/20-network-links.yaml.tpl.j2 new file mode 100644 index 0000000..aa81fc4 --- /dev/null +++ b/template/config/talos/all/20-network-links.yaml.tpl.j2 @@ -0,0 +1,41 @@ +#| Node networking as Talos 1.13+ typed network documents. The MAC-matched + link is enslaved to a single-link active-backup bond so every consumer + (Cilium devices, VLANs, the VIP, metrics) sees a stable interface name, + bond0, regardless of kernel NIC naming; a second NIC can join the bond + later without renaming anything. The bond name is also referenced by + `devices` in the cilium HelmRelease. #| +#% set link = 'bond0.' ~ network.vlan_tag if network.vlan_tag else 'bond0' %# +--- +apiVersion: v1alpha1 +kind: LinkAliasConfig +name: ethSel0 +selector: + match: glob("{{ .Node.Data.macAddr }}", mac(link.hardware_addr)) +--- +apiVersion: v1alpha1 +kind: BondConfig +name: bond0 +links: + - ethSel0 +bondMode: active-backup +mtu: {{ .Node.Data.mtu }} +#% if network.vlan_tag %# +--- +apiVersion: v1alpha1 +kind: VLANConfig +name: #{ link }# +parent: bond0 +vlanID: #{ network.vlan_tag }# +mtu: {{ .Node.Data.mtu }} +#% endif %# +addresses: + - address: "{{ .Node.IP }}/#{ network.node_cidr.split('/') | last }#" +routes: + - gateway: "#{ network.default_gateway }#" +{{- if eq .Node.Role "control-plane" }} +--- +apiVersion: v1alpha1 +kind: Layer2VIPConfig +link: #{ link }# +name: "#{ kubernetes.api.addr }#" +{{- end }} diff --git a/template/config/talos/all/21-network.yaml.j2 b/template/config/talos/all/21-network.yaml.j2 new file mode 100644 index 0000000..adbf330 --- /dev/null +++ b/template/config/talos/all/21-network.yaml.j2 @@ -0,0 +1,8 @@ +apiVersion: v1alpha1 +kind: ResolverConfig +nameservers: + #% for item in network.dns_servers %# + - address: #{ item }# + #% endfor %# +searchDomains: + disableDefault: true diff --git a/template/config/talos/all/22-time.yaml.j2 b/template/config/talos/all/22-time.yaml.j2 new file mode 100644 index 0000000..b34f2f2 --- /dev/null +++ b/template/config/talos/all/22-time.yaml.j2 @@ -0,0 +1,7 @@ +apiVersion: v1alpha1 +kind: TimeSyncConfig +ntp: + servers: + #% for item in network.ntp_servers %# + - #{ item }# + #% endfor %# diff --git a/template/config/talos/all/30-kubelet.yaml.j2 b/template/config/talos/all/30-kubelet.yaml.j2 new file mode 100644 index 0000000..38c8e06 --- /dev/null +++ b/template/config/talos/all/30-kubelet.yaml.j2 @@ -0,0 +1,16 @@ +apiVersion: v1alpha1 +kind: KubeletConfig +config: + crashLoopBackOff: + maxContainerRestartPeriod: 60s + imageMaximumGCAge: 168h + maxParallelImagePulls: 3 + serializeImagePulls: false + shutdownGracePeriod: 90s + shutdownGracePeriodCriticalPods: 60s +--- +apiVersion: v1alpha1 +kind: KubeNodeConfig +nodeIP: + validSubnets: + - #{ network.node_cidr }# diff --git a/template/config/talos/all/40-sysctls.yaml.j2 b/template/config/talos/all/40-sysctls.yaml.j2 new file mode 100644 index 0000000..4bda500 --- /dev/null +++ b/template/config/talos/all/40-sysctls.yaml.j2 @@ -0,0 +1,12 @@ +apiVersion: v1alpha1 +kind: SysctlConfig +params: + fs.inotify.max_user_watches: "1048576" # Watchdog + fs.inotify.max_user_instances: "8192" # Watchdog + net.core.rmem_max: "7500000" # Cloudflared | QUIC + net.core.wmem_max: "7500000" # Cloudflared | QUIC + net.ipv4.neigh.default.gc_thresh1: "4096" # Prevent ARP cache overflows + net.ipv4.neigh.default.gc_thresh2: "8192" # Prevent ARP cache overflows + net.ipv4.neigh.default.gc_thresh3: "16384" # Prevent ARP cache overflows + net.ipv4.tcp_slow_start_after_idle: "0" # Preserve congestion window after idle + user.max_user_namespaces: "11255" # User Namespaces diff --git a/template/config/talos/all/50-files.yaml.j2 b/template/config/talos/all/50-files.yaml.j2 new file mode 100644 index 0000000..7208111 --- /dev/null +++ b/template/config/talos/all/50-files.yaml.j2 @@ -0,0 +1,8 @@ +apiVersion: v1alpha1 +kind: CRICustomizationConfig +name: containerd +content: | + [plugins."io.containerd.cri.v1.images"] + discard_unpacked_layers = false + [plugins."io.containerd.cri.v1.runtime"] + device_ownership_from_security_context = true diff --git a/template/config/talos/all/60-encryption.yaml.tpl.j2 b/template/config/talos/all/60-encryption.yaml.tpl.j2 new file mode 100644 index 0000000..c478f5b --- /dev/null +++ b/template/config/talos/all/60-encryption.yaml.tpl.j2 @@ -0,0 +1,20 @@ +{{- if .Node.Data.encryptDisk }} +# Encrypt system disk with TPM +apiVersion: v1alpha1 +kind: VolumeConfig +name: STATE +encryption: + provider: luks2 + keys: + - slot: 0 + tpm: {} +--- +apiVersion: v1alpha1 +kind: VolumeConfig +name: EPHEMERAL +encryption: + provider: luks2 + keys: + - slot: 0 + tpm: {} +{{- end }} diff --git a/template/config/talos/all/61-kernel-modules.yaml.tpl.j2 b/template/config/talos/all/61-kernel-modules.yaml.tpl.j2 new file mode 100644 index 0000000..5e39729 --- /dev/null +++ b/template/config/talos/all/61-kernel-modules.yaml.tpl.j2 @@ -0,0 +1,6 @@ +{{- range .Node.Data.kernelModules }} +--- +apiVersion: v1alpha1 +kind: KernelModuleConfig +name: {{ . }} +{{- end }} diff --git a/template/config/talos/all/70-security.yaml.j2 b/template/config/talos/all/70-security.yaml.j2 new file mode 100644 index 0000000..bc5dfb2 --- /dev/null +++ b/template/config/talos/all/70-security.yaml.j2 @@ -0,0 +1,7 @@ +# Talos 1.14 defaults to running kubelet and pods in a sandboxed PID/mount +# namespace, which breaks workloads that rely on hostPID or host mounts +# (node-exporter, GPU plugins, in-tree iSCSI). Set to true to opt in; on +# encrypted disks expect a second reboot the first time it is enabled. +apiVersion: v1alpha1 +kind: SecurityProfileConfig +workloadIsolation: false diff --git a/template/config/talos/all/71-filesystem.yaml.j2 b/template/config/talos/all/71-filesystem.yaml.j2 new file mode 100644 index 0000000..e55ba22 --- /dev/null +++ b/template/config/talos/all/71-filesystem.yaml.j2 @@ -0,0 +1,9 @@ +# Weekly online maintenance for XFS volumes: trim discards unused blocks +# (SSDs, thin provisioning) and scrub checks filesystem metadata. +apiVersion: v1alpha1 +kind: FilesystemTrimConfig +interval: 168h0m0s +--- +apiVersion: v1alpha1 +kind: FilesystemScrubConfig +interval: 168h0m0s diff --git a/template/config/talos/control-plane/00-cluster.yaml.j2 b/template/config/talos/control-plane/00-cluster.yaml.j2 new file mode 100644 index 0000000..fdd4d04 --- /dev/null +++ b/template/config/talos/control-plane/00-cluster.yaml.j2 @@ -0,0 +1,62 @@ +cluster: + etcd: + extraArgs: + listen-metrics-urls: http://0.0.0.0:2381 + advertisedSubnets: + - #{ network.node_cidr }# +--- +# Allow scheduling on control-plane nodes +apiVersion: v1alpha1 +kind: KubeNodeConfig +taints: + node-role.kubernetes.io/control-plane: + $patch: delete +--- +apiVersion: v1alpha1 +kind: KubeAdmissionControlConfig +name: PodSecurity +$patch: delete +--- +apiVersion: v1alpha1 +kind: KubeAPIServerConfig +certExtraSANs: + #% for item in cert_sans %# + - "#{ item }#" + #% endfor %# +extraArgs: + # https://kubernetes.io/docs/tasks/extend-kubernetes/configure-aggregation-layer/ + enable-aggregator-routing: "true" +--- +apiVersion: v1alpha1 +kind: KubeControllerManagerConfig +extraArgs: + bind-address: 0.0.0.0 +--- +apiVersion: v1alpha1 +kind: KubeCoreDNSConfig +enabled: false +--- +# Disable built-in CNI and kube-proxy to use Cilium +apiVersion: v1alpha1 +kind: KubeFlannelCNIConfig +$patch: delete +--- +apiVersion: v1alpha1 +kind: KubeProxyConfig +enabled: false +--- +apiVersion: v1alpha1 +kind: KubeSchedulerConfig +extraArgs: + bind-address: 0.0.0.0 +config: + profiles: + - schedulerName: default-scheduler + pluginConfig: + - name: PodTopologySpread + args: + defaultingType: List + defaultConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway diff --git a/template/config/talos/mod.just b/template/config/talos/mod.just new file mode 100644 index 0000000..64b6b5a --- /dev/null +++ b/template/config/talos/mod.just @@ -0,0 +1,64 @@ +set quiet +set shell := ['bash', '-euo', 'pipefail', '-c'] +set script-interpreter := ['bash', '-euo', 'pipefail'] +set default-list +set default-script + +# Day-2 apply/upgrade recipes rely on topf's built-in diff-and-confirm +# prompt; only the destructive reset recipes disable it in favour of just's +# own confirmation. + +[doc('Apply Talos config to all nodes (shows a diff and asks first)')] +[group('talos')] +apply: + topf apply + +[arg('mode', pattern='auto|reboot|no-reboot|staged|try')] +[doc('Apply Talos config to a node (shows a diff and asks first)')] +[group('talos')] +apply-node node mode='auto': + topf apply --nodes-filter "^{{ node }}$" --mode "{{ mode }}" + +[doc('Show pending config changes without applying them')] +[group('talos')] +diff: + topf apply --dry-run + +[doc('List all nodes and their current state')] +[group('talos')] +nodes: + topf nodes + +[doc('Render Talos machine configs to ./rendered')] +[group('talos')] +render: + topf render --output ./rendered + +[confirm("This will destroy your cluster and reset all nodes to maintenance mode — continue? [y/N]")] +[doc('Reset all nodes back to maintenance mode (DESTRUCTIVE)')] +[group('talos')] +reset: + topf reset --confirm=false + +[confirm("This will reset node " + node + " to maintenance mode — continue? [y/N]")] +[doc('Reset a single node back to maintenance mode (DESTRUCTIVE)')] +[group('talos')] +reset-node node: + topf reset --nodes-filter "^{{ node }}$" --confirm=false + +# topf intentionally does not manage Kubernetes upgrades +[doc('Upgrade Kubernetes')] +[group('talos')] +upgrade-k8s: + talosctl --nodes "$(yq '[.nodes[] | select(.role == "control-plane")][0].ip' topf.yaml)" \ + upgrade-k8s --to "$(yq '.kubernetesVersion' topf.yaml)" + +[doc('Upgrade Talos on all nodes, one at a time (asks first)')] +[group('talos')] +upgrade: + topf upgrade + +[doc('Upgrade Talos on a single node (asks first)')] +[group('talos')] +upgrade-node node: + topf upgrade --nodes-filter "^{{ node }}$" diff --git a/template/config/talos/topf.yaml.j2 b/template/config/talos/topf.yaml.j2 new file mode 100644 index 0000000..16f684f --- /dev/null +++ b/template/config/talos/topf.yaml.j2 @@ -0,0 +1,33 @@ +--- +clusterName: kubernetes +clusterEndpoint: https://#{ kubernetes.api.addr }#:6443 + +# renovate: datasource=github-releases depName=siderolabs/talos +talosVersion: v1.14.0 +# renovate: datasource=docker depName=ghcr.io/siderolabs/kubelet +kubernetesVersion: v1.37.0 + +secretsPath: secrets.sops.yaml + +nodes: + #% for item in nodes %# + - host: "#{ item.name }#" + ip: "#{ item.address }#" + role: #{ 'control-plane' if item.controller else 'worker' }# + schematicId: "#{ item.schematic_id }#" + #% if item.secureboot %# + secureboot: true + #% endif %# + data: + #% if item.disk.startswith('/') %# + installDisk: "#{ item.disk }#" + installDiskSerial: "" + #% else %# + installDisk: "" + installDiskSerial: "#{ item.disk }#" + #% endif %# + macAddr: "#{ item.mac_addr | lower }#" + mtu: #{ item.mtu }# + encryptDisk: #{ item.encrypt_disk | string | lower }# + kernelModules: [#{ item.kernel_modules | join(', ') }#] + #% endfor %# diff --git a/template/mod.just b/template/mod.just new file mode 100644 index 0000000..c05cd0e --- /dev/null +++ b/template/mod.just @@ -0,0 +1,182 @@ +set quiet +set default-list +set default-script +set shell := ['bash', '-euo', 'pipefail', '-c'] +set script-interpreter := ['bash', '-euo', 'pipefail'] +set working-directory := '..' + +bootstrap_dir := justfile_dir() + '/bootstrap' +kubernetes_dir := justfile_dir() + '/kubernetes' +talos_dir := justfile_dir() + '/talos' +private_dir := justfile_dir() + '/.private' + +template_dir := justfile_dir() + '/template' +template_resources := template_dir + '/resources' +makejinja_config := justfile_dir() + '/makejinja.toml' +mise_fragment := justfile_dir() + '/.mise/conf.d/template.toml' + +config_file := justfile_dir() + '/cluster.toml' +schema_file := justfile_dir() + '/cluster.schema.json' +sample_config_file := justfile_dir() + '/cluster.sample.toml' +validate_script := template_dir + '/scripts/validate.py' +deploy_key := justfile_dir() + '/deploy.key' +webhook_token_file := justfile_dir() + '/flux-webhook-token.txt' +cloudflare_tunnel := justfile_dir() + '/cloudflare-tunnel.json' + +[doc('Render and validate configuration files')] +[group('template')] +configure: render encrypt-secrets validate-kubernetes validate-talos + +[doc('Check that all prerequisite files exist and cluster.toml validates against the schema')] +[group('template')] +[no-exit-message] +doctor: + rc=0 + just log info "just version" version "{{ just_version() }}" + just template doctor-check "cluster.toml" "{{ config_file }}" || rc=1 + just template doctor-check "cluster.sample.toml" "{{ sample_config_file }}" || rc=1 + config_json="$(just template config-json 2>/dev/null || true)" + if [ "$(jq -r '.ingress.mode' <<< "$config_json" 2>/dev/null)" = "cloudflare-tunnel" ]; then + just template doctor-check "cloudflare-tunnel.json" "{{ cloudflare_tunnel }}" || rc=1 + fi + just template doctor-check "age.key" "$SOPS_AGE_KEY_FILE" || rc=1 + just template doctor-check "deploy.key" "{{ deploy_key }}" || rc=1 + just template doctor-check "flux-webhook-token.txt" "{{ webhook_token_file }}" || rc=1 + just template doctor-check "template/" "{{ template_dir }}" || rc=1 + just template doctor-check "makejinja.toml" "{{ makejinja_config }}" || rc=1 + just template doctor-check "validate.py" "{{ validate_script }}" || rc=1 + if [ -f "{{ config_file }}" ] && [ -f "{{ validate_script }}" ]; then + if [ -n "$config_json" ]; then + just log info "ok" check schema + else + just log error "fail" check schema + just log info "diagnose with: uv run --locked --no-dev {{ validate_script }} {{ config_file }}" + rc=1 + fi + fi + [ "$rc" = 0 ] && just log info "all good" + exit "$rc" + +[doc('Initialize configuration files (cluster.toml, age key, deploy key, webhook token)')] +[group('template')] +init: + [ -f "{{ config_file }}" ] || cp "{{ sample_config_file }}" "{{ config_file }}" + [ -f "$SOPS_AGE_KEY_FILE" ] || age-keygen -pq --output "$SOPS_AGE_KEY_FILE" + [ -f "{{ deploy_key }}" ] || ssh-keygen -t ed25519 -C "deploy-key" -f "{{ deploy_key }}" -q -P "" + [ -f "{{ webhook_token_file }}" ] || openssl rand -hex 16 > "{{ webhook_token_file }}" + +# Editors resolve the #:schema directive in cluster.toml against this file; +# the validator tests fail when it drifts from the pydantic model. +[doc('Regenerate the cluster.toml JSON Schema from the validator')] +[group('template')] +schema: + uv run --quiet --locked --no-dev "{{ validate_script }}" --schema > "{{ schema_file }}" + oxfmt "{{ schema_file }}" > /dev/null + +[confirm("Remove all templated files and directories — continue? [y/N]")] +[doc('Remove rendered files (bootstrap/, kubernetes/, talos/, .sops.yaml)')] +[group('template')] +reset: + rm -rf "{{ bootstrap_dir }}" "{{ kubernetes_dir }}" "{{ talos_dir }}" "{{ justfile_dir() }}/.sops.yaml" + +# tidy-archive moves template/ (this module's source) away, so it runs as a +# subsequent dependency: the tidy script itself must spawn while the module +# directory still exists. +[confirm("All template related config will be archived — continue? [y/N]")] +[doc('Archive all template tooling under .private// (one-way, run after configure works)')] +[group('template')] +tidy: tidy-preconditions tidy-strip && tidy-archive + +# Validated, defaulted cluster config as JSON on stdout; fails when the +# config is invalid. +[private] +config-json: + uv run --quiet --locked --no-dev "{{ validate_script }}" "{{ config_file }}" + +[no-exit-message] +[private] +doctor-check label path: + if [ -e "{{ path }}" ]; then + just log info "ok" file "{{ label }}" + else + just log error "missing" file "{{ label }}" + exit 1 + fi + +[private] +encrypt-secrets: + find "{{ bootstrap_dir }}" "{{ kubernetes_dir }}" "{{ talos_dir }}" -type f -name '*.sops.*' -print0 \ + | while IFS= read -r -d '' f; do + if ! status="$(sops filestatus "$f" | jq -r '.encrypted')"; then + just log fatal "could not read encryption status" file "$f" + fi + case "$status" in + false) sops encrypt --in-place "$f" ;; + true) : ;; + *) just log fatal "could not determine encryption status" file "$f" ;; + esac + done + +[private] +render: + PYTHONDONTWRITEBYTECODE=1 uv run --locked --no-dev makejinja + +[private] +tidy-archive: + TIDY_FOLDER="{{ private_dir }}/$(date +%s)" + mkdir -p "$TIDY_FOLDER" + rm -rf "{{ justfile_dir() }}/.github/template-tests" "{{ justfile_dir() }}/.github/workflows"/template-*.yaml + mv \ + "{{ template_dir }}" \ + "{{ makejinja_config }}" \ + "{{ mise_fragment }}" \ + "{{ schema_file }}" \ + "{{ config_file }}" \ + "{{ sample_config_file }}" \ + "{{ justfile_dir() }}/pyproject.toml" \ + "{{ justfile_dir() }}/uv.lock" \ + "$TIDY_FOLDER/" + rmdir "$(dirname "{{ mise_fragment }}")" 2>/dev/null || true + rm -rf "{{ justfile_dir() }}/.venv" + +[private] +tidy-preconditions: + test -d "{{ template_dir }}" + test -d "{{ justfile_dir() }}/.github/template-tests" + test -f "{{ makejinja_config }}" + test -f "{{ config_file }}" + test -f "{{ sample_config_file }}" + test -f "{{ justfile_dir() }}/pyproject.toml" + test -f "{{ justfile_dir() }}/uv.lock" + test -f "{{ justfile_dir() }}/justfile" + test -f "{{ mise_fragment }}" + test -f "{{ schema_file }}" + test -f "{{ justfile_dir() }}/.renovaterc.json5" + compgen -G "{{ justfile_dir() }}/.github/workflows/template-*.yaml" > /dev/null + +[private] +tidy-strip: + sd '\..\.j2' '' "{{ justfile_dir() }}/.renovaterc.json5" + sd -A '(?ms)^# === template ===$.*' '' "{{ justfile_dir() }}/justfile" + +# Renders every bootstrap chart with the rendered values, catching chart or +# values drift that dry runs cannot; needs network access to pull charts. +[doc('Render the bootstrap helmfile charts against the rendered values')] +[group('template')] +test-helmfile: + helmfile --file "{{ bootstrap_dir }}/helmfile/apps.yaml" template --quiet > /dev/null + just log info "bootstrap charts rendered cleanly" + +[private] +validate-kubernetes: + bash "{{ template_resources }}/kubeconform.sh" "{{ kubernetes_dir }}" + +# Rendering needs the secrets bundle; topf generates and sops-encrypts one +# on first run (--confirm=false so it never prompts mid-pipeline). +[private] +[working-directory('talos')] +validate-talos: + topf render --confirm=false --output "$(mktemp -d)" >/dev/null + if ! sops filestatus secrets.sops.yaml | jq --exit-status '.encrypted == true' >/dev/null; then + just log fatal "Talos secrets bundle is not encrypted" + fi diff --git a/template/overrides/readme.partial.yaml.j2 b/template/overrides/readme.partial.yaml.j2 new file mode 100644 index 0000000..b73f753 --- /dev/null +++ b/template/overrides/readme.partial.yaml.j2 @@ -0,0 +1,5 @@ +#| Place user jinja template overrides in this file's directory |# +#| Docs: https://mirkolenz.github.io/makejinja/makejinja.html |# +#| Example: https://github.com/mirkolenz/makejinja/blob/main/tests/data/makejinja.toml |# +#| Example: https://github.com/mirkolenz/makejinja/blob/main/tests/data/input1/not-empty.yaml.jinja |# +#| Example: https://github.com/mirkolenz/makejinja/blob/main/tests/data/input2/not-empty.yaml.jinja |# diff --git a/template/resources/kubeconform.sh b/template/resources/kubeconform.sh new file mode 100644 index 0000000..821e569 --- /dev/null +++ b/template/resources/kubeconform.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +set -euo pipefail + +KUBERNETES_DIR=$1 + +[[ -z "${KUBERNETES_DIR}" ]] && echo "Kubernetes location not specified" && exit 1 + +kustomize_args=("--load-restrictor=LoadRestrictionsNone") +kustomize_config="kustomization.yaml" +kubeconform_args=( + "-strict" + "-ignore-missing-schemas" + "-skip" + "Gateway,HTTPRoute,Secret" + "-schema-location" + "default" + "-schema-location" + "https://k8s-schemas.home-operations.com/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json" + "-verbose" +) + +echo "=== Validating standalone manifests in ${KUBERNETES_DIR}/flux ===" +find "${KUBERNETES_DIR}/flux" -maxdepth 1 -type f -name '*.yaml' -print0 | while IFS= read -r -d $'\0' file; +do + kubeconform "${kubeconform_args[@]}" "${file}" + if [[ ${PIPESTATUS[0]} != 0 ]]; then + exit 1 + fi +done + +echo "=== Validating kustomizations in ${KUBERNETES_DIR}/flux ===" +find "${KUBERNETES_DIR}/flux" -type f -name $kustomize_config -print0 | while IFS= read -r -d $'\0' file; +do + echo "=== Validating kustomizations in ${file/%$kustomize_config} ===" + kustomize build "${file/%$kustomize_config}" "${kustomize_args[@]}" | kubeconform "${kubeconform_args[@]}" + if [[ ${PIPESTATUS[0]} != 0 ]]; then + exit 1 + fi +done + +echo "=== Validating kustomizations in ${KUBERNETES_DIR}/apps ===" +find "${KUBERNETES_DIR}/apps" -type f -name $kustomize_config -print0 | while IFS= read -r -d $'\0' file; +do + echo "=== Validating kustomizations in ${file/%$kustomize_config} ===" + kustomize build "${file/%$kustomize_config}" "${kustomize_args[@]}" | kubeconform "${kubeconform_args[@]}" + if [[ ${PIPESTATUS[0]} != 0 ]]; then + exit 1 + fi +done diff --git a/template/scripts/plugin.py b/template/scripts/plugin.py new file mode 100644 index 0000000..40341eb --- /dev/null +++ b/template/scripts/plugin.py @@ -0,0 +1,150 @@ +from pathlib import Path +from typing import Any + +import base64 +import json +import makejinja +import re +import validate + + +# Return the stripped contents of file_path, rejecting a missing or empty file +def _read_stripped(file_path: str) -> str: + try: + content = Path(file_path).read_text().strip() + except FileNotFoundError: + raise FileNotFoundError(f"File not found: {file_path}") from None + if not content: + raise ValueError(f"{file_path} is empty") + return content + + +# Return the parsed contents of a JSON file +def _read_json(file_path: str) -> dict[str, Any]: + try: + return json.loads(_read_stripped(file_path)) + except json.JSONDecodeError: + raise ValueError(f"Could not decode JSON file: {file_path}") from None + + +# Return the age public or private key from age.key +def age_key(key_type: str, file_path: str = 'age.key') -> str: + file_content = _read_stripped(file_path) + if key_type == 'public': + # Matches both classic (age1...) and post-quantum (age1pq1...) recipients + key_match = re.search(r"# public key: (age1[\w]+)", file_content) + if not key_match: + raise ValueError("Could not find public key in the age key file.") + return key_match.group(1) + elif key_type == 'private': + # (?:PQ-)? matches post-quantum identities (AGE-SECRET-KEY-PQ-1...) as well as classic ones + key_match = re.search(r"(AGE-SECRET-KEY-(?:PQ-)?1[\w]+)", file_content) + if not key_match: + raise ValueError("Could not find private key in the age key file.") + return key_match.group(1) + else: + raise ValueError("Invalid key type. Use 'public' or 'private'.") + + +# Return cloudflare tunnel fields from cloudflare-tunnel.json +def cloudflare_tunnel_id(file_path: str = 'cloudflare-tunnel.json') -> str: + data = _read_json(file_path) + tunnel_id = data.get("TunnelID") + if tunnel_id is None: + raise KeyError(f"Missing 'TunnelID' key in {file_path}") + if not tunnel_id: + raise ValueError(f"'TunnelID' is empty in {file_path}") + return tunnel_id + + +# Return cloudflare tunnel fields from cloudflare-tunnel.json in TUNNEL_TOKEN format +def cloudflare_tunnel_secret(file_path: str = 'cloudflare-tunnel.json') -> str: + data = _read_json(file_path) + for field in ("AccountTag", "TunnelID", "TunnelSecret"): + if field not in data: + raise KeyError(f"Missing '{field}' key in {file_path}") + if not data[field]: + raise ValueError(f"'{field}' is empty in {file_path}") + transformed_data = { + "a": data["AccountTag"], + "t": data["TunnelID"], + "s": data["TunnelSecret"] + } + json_string = json.dumps(transformed_data, separators=(',', ':')) + return base64.b64encode(json_string.encode('utf-8')).decode('utf-8') + + +# Return the Flux deploy key from deploy.key +def deploy_key(file_path: str = 'deploy.key') -> str: + return _read_stripped(file_path) + + +# Return the Flux webhook token from flux-webhook-token.txt +def webhook_token(file_path: str = 'flux-webhook-token.txt') -> str: + return _read_stripped(file_path) + + +CONFIG_FILE = 'cluster.toml' + +# SSH host keys as published by each provider. Must cover every host in +# KNOWN_SSH_HOSTS in validate.py; any other host requires the user to set +# repository.known_hosts in cluster.toml. +KNOWN_HOSTS = { + 'github.com': ( + 'github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl\n' + 'github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=\n' + 'github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=' + ), + 'gitlab.com': ( + 'gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf\n' + 'gitlab.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY=\n' + 'gitlab.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9' + ), + 'codeberg.org': ( + 'codeberg.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIVIC02vnjFyL+I4RHfvIGNtOgJMe769VTF1VR4EB3ZB\n' + 'codeberg.org ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBL2pDxWr18SoiDJCGZ5LmxPygTlPu+cCKSkpqkvCyQzl5xmIMeKNdfdBpfbCGDPoZQghePzFZkKJNR/v9Win3Sc=\n' + 'codeberg.org ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC8hZi7K1/2E2uBX8gwPRJAHvRAob+3Sn+y2hxiEhN0buv1igjYFTgFO2qQD8vLfU/HT/P/rqvEeTvaDfY1y/vcvQ8+YuUYyTwE2UaVU5aJv89y6PEZBYycaJCPdGIfZlLMmjilh/Sk8IWSEK6dQr+g686lu5cSWrFW60ixWpHpEVB26eRWin3lKYWSQGMwwKv4LwmW3ouqqs4Z4vsqRFqXJ/eCi3yhpT+nOjljXvZKiYTpYajqUC48IHAxTWugrKe1vXWOPxVXXMQEPsaIRc2hpK+v1LmfB7GnEGvF1UAKnEZbUuiD9PBEeD5a1MZQIzcoPWCrTxipEpuXQ5Tni4mN' + ), +} + + +# makejinja adds import_paths (this directory) to sys.path, and both +# makejinja and pydantic come from the uv project environment, so the +# validator runs in-process. +def validate_config() -> dict[str, Any]: + try: + return validate.load(CONFIG_FILE) + except validate.ConfigError as e: + raise RuntimeError(f"config validation failed:\n{e}") from None + + +class Plugin(makejinja.plugin.Plugin): + def __init__(self, data: dict[str, Any]): + self._data = data + + + def data(self) -> makejinja.plugin.Data: + data = validate_config() + if ( + data['ingress']['mode'] == 'cloudflare-tunnel' + and not Path('cloudflare-tunnel.json').is_file() + ): + raise RuntimeError('cloudflare-tunnel.json not found — see README') + # The deploy key secret always pins every bundled provider host key + # (entries are matched per-hostname, so unused ones are inert); + # user-supplied entries for self-hosted git servers are appended. + repository = data['repository'] + repository['known_hosts'] = '\n'.join( + [*KNOWN_HOSTS.values(), repository['known_hosts']] + ).strip() + return data + + + def functions(self) -> makejinja.plugin.Functions: + return [ + age_key, + cloudflare_tunnel_id, + cloudflare_tunnel_secret, + deploy_key, + webhook_token + ] diff --git a/template/scripts/test_validate.py b/template/scripts/test_validate.py new file mode 100644 index 0000000..490c395 --- /dev/null +++ b/template/scripts/test_validate.py @@ -0,0 +1,173 @@ +"""Unit tests for the cluster.toml validator. + +Run from the repo root: + uv run --locked pytest template/scripts/test_validate.py -q +""" + +from pathlib import Path + +import json +import sys +import tomllib + +import pytest + +sys.path.insert(0, str(Path(__file__).parent)) + +from pydantic import ValidationError # noqa: E402 +from validate import Config, ConfigError, format_errors, load, schema # noqa: E402 + +REPO_ROOT = Path(__file__).parents[2] +VALID = sorted((REPO_ROOT / ".github/template-tests/valid").glob("*.toml")) +INVALID = sorted((REPO_ROOT / ".github/template-tests/invalid").glob("*.toml")) + + +def config_from(fixture: str, **overrides) -> dict: + raw = tomllib.loads((REPO_ROOT / ".github/template-tests/valid" / fixture).read_text()) + for dotted, value in overrides.items(): + target = raw + *parents, leaf = dotted.split(".") + for key in parents: + target = target.setdefault(key, {}) + if value is None: + target.pop(leaf, None) + else: + target[leaf] = value + return raw + + +@pytest.mark.parametrize("fixture", VALID, ids=lambda p: p.stem) +def test_valid_fixture_accepted(fixture: Path): + load(str(fixture)) + + +@pytest.mark.parametrize("fixture", INVALID, ids=lambda p: p.stem) +def test_invalid_fixture_rejected(fixture: Path): + with pytest.raises(ConfigError): + load(str(fixture)) + + +def _load_raw(raw: dict) -> Config: + try: + return Config.model_validate(raw) + except ValidationError as e: + raise ConfigError(format_errors(e)) from None + + +def test_host_bits_error_suggests_network_address(): + raw = config_from("public.toml", **{"network.node_cidr": "10.10.10.5/24"}) + with pytest.raises(ConfigError, match=r"did you mean 10\.10\.10\.0/24"): + _load_raw(raw) + + +def test_duplicate_address_names_both_owners(): + raw = config_from("public.toml") + raw["nodes"][0]["address"] = raw["gateways"]["internal"] + with pytest.raises(ConfigError, match=r"gateways\.internal and nodes\[0\]\.address"): + _load_raw(raw) + + +def test_default_gateway_derived_from_node_cidr(): + raw = config_from("private.toml") + assert "default_gateway" not in raw["network"] + data = _load_raw(raw).model_dump(mode="json") + assert data["network"]["default_gateway"] == "10.10.10.1" + + +def test_coredns_addr_default_and_override(): + raw = config_from("private.toml") + assert _load_raw(raw).model_dump(mode="json")["kubernetes"]["coredns_addr"] == "10.43.0.10" + raw = config_from("private.toml", **{"kubernetes.coredns_addr": "10.43.0.53"}) + assert _load_raw(raw).model_dump(mode="json")["kubernetes"]["coredns_addr"] == "10.43.0.53" + raw = config_from("private.toml", **{"kubernetes.coredns_addr": "192.168.9.9"}) + with pytest.raises(ConfigError, match="not inside svc_cidr"): + _load_raw(raw) + + +def test_spegel_enabled_follows_node_count(): + two_nodes = config_from("private.toml") + assert _load_raw(two_nodes).spegel.enabled is True + one_node = config_from("private.toml") + one_node["nodes"] = one_node["nodes"][:1] + assert _load_raw(one_node).spegel.enabled is False + empty_section = config_from("private.toml", spegel={}) + assert _load_raw(empty_section).spegel.enabled is True + explicit = config_from("private.toml", **{"spegel.enabled": False}) + assert _load_raw(explicit).spegel.enabled is False + + +def test_controller_count_ignores_workers(): + raw = config_from("private.toml") + assert [n["controller"] for n in raw["nodes"]] == [True, False] + assert _load_raw(raw).controller_count == 1 + raw["nodes"][1]["controller"] = True + assert _load_raw(raw).controller_count == 2 + + +def test_derived_fields_are_not_settable(): + raw = config_from("public.toml", cilium_bgp_enabled=True) + with pytest.raises(ConfigError, match="cilium_bgp_enabled"): + _load_raw(raw) + raw = config_from("public.toml", controller_count=3) + with pytest.raises(ConfigError, match="controller_count"): + _load_raw(raw) + + +def test_cert_sans_single_source(): + raw = config_from("public.toml") + assert _load_raw(raw).cert_sans == ["127.0.0.1", "10.10.10.254", "example.com"] + raw = config_from("private.toml") + assert _load_raw(raw).cert_sans == ["127.0.0.1", "10.10.10.254"] + + +def test_ingress_mode_follows_dns_provider(): + cloudflare = config_from("public.toml", ingress=None) + assert _load_raw(cloudflare).ingress.mode == "cloudflare-tunnel" + internal = config_from("internal.toml") + assert (REPO_ROOT / ".github/template-tests/valid/internal.toml").exists() + assert "ingress" not in internal + assert _load_raw(internal).ingress.mode == "none" + + +def test_direct_mode_requires_cloudflare_dns(): + raw = config_from("internal.toml", **{"ingress.mode": "direct"}) + with pytest.raises(ConfigError, match="requires dns.provider 'cloudflare'"): + _load_raw(raw) + + +def test_schematic_id_inherits_from_talos_section(): + raw = config_from("public.toml") + cfg = _load_raw(raw) + assert cfg.nodes[0].schematic_id == cfg.talos.schematic_id + assert cfg.nodes[1].schematic_id is not None + + +def test_partial_bgp_rejected(): + raw = config_from("private.toml", **{"cilium.bgp.router_addr": "10.10.1.1", "cilium.bgp.router_asn": "64513"}) + with pytest.raises(ConfigError, match="partially configured"): + _load_raw(raw) + + +def test_node_defaults_exported(): + data = _load_raw(config_from("private.toml")).model_dump(mode="json") + node = data["nodes"][0] + assert node["mtu"] == 1500 + assert node["secureboot"] is False + assert node["kernel_modules"] == [] + + +def test_gateways_may_leave_node_cidr_only_with_bgp(): + with_bgp = config_from("public.toml", **{"gateways.external": "192.168.50.1"}) + _load_raw(with_bgp) # public.toml enables BGP + without_bgp = config_from("private.toml", **{"gateways.external": "192.168.50.1"}) + with pytest.raises(ConfigError, match="required unless BGP is enabled"): + _load_raw(without_bgp) + + +def test_schema_file_matches_model(): + committed = json.loads((REPO_ROOT / "cluster.schema.json").read_text()) + assert committed == schema(), "cluster.schema.json is stale: run `just template schema`" + + +def test_schema_omits_computed_fields(): + assert "cluster_issuer" not in schema()["properties"] diff --git a/template/scripts/validate.py b/template/scripts/validate.py new file mode 100644 index 0000000..adb0098 --- /dev/null +++ b/template/scripts/validate.py @@ -0,0 +1,402 @@ +"""Validate cluster.toml, apply defaults, and emit the config as JSON. + +Standalone usage (doctor, CI): uv run --locked --no-dev template/scripts/validate.py [cluster.toml] +Schema export (just template schema): uv run --locked --no-dev template/scripts/validate.py --schema +In-process usage (makejinja plugin): from validate import load + +Exits non-zero with one human-readable error per line on stderr when the +config is invalid. +""" + +from ipaddress import IPv4Address, IPv4Network +from pathlib import Path +from typing import Annotated, Any, Literal, Self + +import json +import re +import sys +import tomllib + +from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + ValidationError, + computed_field, + model_validator, +) + +# Git hosts whose SSH host keys are bundled with the template; ssh:// URLs +# pointing anywhere else must provide repository.known_hosts. +KNOWN_SSH_HOSTS = ["github.com", "gitlab.com", "codeberg.org"] + +REPO_URL_PATTERN = r"^(https?://|ssh://git@)[^/]+/.+$" +FQDN_PATTERN = r"^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$" + + +def _network(value: Any) -> Any: + """Parse a CIDR, requiring network-address form (no host bits set).""" + if not isinstance(value, str): + return value + try: + return IPv4Network(value) + except ValueError: + try: + fixed = IPv4Network(value, strict=False) + except ValueError: + raise ValueError(f"{value!r} is not a valid IPv4 CIDR") from None + raise ValueError( + f"{value!r} has host bits set; did you mean {fixed}?" + ) from None + + +def _asn(value: str) -> str: + if value == "": + return value + if not re.fullmatch(r"[0-9]+", value): + raise ValueError(f"{value!r} must be a decimal ASN") + if not 1 <= int(value) <= 4294967295: + raise ValueError(f"{value!r} must be in the range 1-4294967295") + return value + + +type Cidr = Annotated[IPv4Network, BeforeValidator(_network)] +type Asn = Annotated[str, AfterValidator(_asn)] +type Fqdn = Annotated[str, Field(pattern=FQDN_PATTERN)] + + +class Model(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class Network(Model): + node_cidr: Cidr + dns_servers: list[IPv4Address] = [IPv4Address("1.1.1.1"), IPv4Address("1.0.0.1")] + ntp_servers: list[IPv4Address] = [IPv4Address("162.159.200.1"), IPv4Address("162.159.200.123")] + # The first IP in node_cidr unless set explicitly. + default_gateway: IPv4Address = Field( + default_factory=lambda data: data["node_cidr"].network_address + 1 + ) + vlan_tag: str | None = Field(default=None, pattern=r"^[0-9]+$") + + @model_validator(mode="after") + def check(self) -> Self: + if self.default_gateway not in self.node_cidr: + raise ValueError( + f"default_gateway {self.default_gateway} is not inside node_cidr {self.node_cidr}" + ) + if self.vlan_tag is not None and not 1 <= int(self.vlan_tag) <= 4094: + raise ValueError(f"vlan_tag {self.vlan_tag} must be in the range 1-4094") + return self + + +class Api(Model): + addr: IPv4Address + tls_sans: list[Fqdn] | None = None + + +class Kubernetes(Model): + pod_cidr: Cidr = IPv4Network("10.42.0.0/16") + svc_cidr: Cidr = IPv4Network("10.43.0.0/16") + # The 10th IP in svc_cidr unless set explicitly. + coredns_addr: IPv4Address = Field( + default_factory=lambda data: data["svc_cidr"].network_address + 10 + ) + api: Api + + @model_validator(mode="after") + def check(self) -> Self: + if self.coredns_addr not in self.svc_cidr: + raise ValueError( + f"coredns_addr {self.coredns_addr} is not inside svc_cidr {self.svc_cidr}" + ) + return self + + +class Gateways(Model): + internal: IPv4Address + dns: IPv4Address + # Required when ingress.mode is not "none". + external: IPv4Address | None = None + + +class Repository(Model): + url: str = Field(pattern=REPO_URL_PATTERN) + branch: str = Field(default="main", min_length=1) + webhook_provider: Literal["github", "gitlab", "generic-hmac", "none"] = "github" + known_hosts: str = "" + + @model_validator(mode="after") + def check(self) -> Self: + if self.url.startswith("ssh://"): + host = self.url.removeprefix("ssh://git@").split("/", 1)[0].split(":", 1)[0] + if host not in KNOWN_SSH_HOSTS and not self.known_hosts: + raise ValueError( + f"known_hosts is required for ssh:// URLs to {host!r} " + f"(host keys are only bundled for {', '.join(KNOWN_SSH_HOSTS)})" + ) + return self + + +class Domain(Model): + name: Fqdn + + +class Dns(Model): + provider: Literal["cloudflare", "none"] = "cloudflare" + token: str = "" + + @model_validator(mode="after") + def check(self) -> Self: + if self.provider == "cloudflare" and not self.token: + raise ValueError("token is required when dns.provider is 'cloudflare'") + if self.provider == "none" and self.token: + raise ValueError("token must be empty when dns.provider is 'none'") + return self + + +class Ingress(Model): + mode: Literal["cloudflare-tunnel", "direct", "none"] = "cloudflare-tunnel" + + +class Bgp(Model): + router_addr: IPv4Address | Literal[""] = "" + router_asn: Asn = "" + node_asn: Asn = "" + + @model_validator(mode="after") + def check(self) -> Self: + unset = [name for name in ("router_addr", "router_asn", "node_asn") if getattr(self, name) == ""] + if unset and len(unset) < 3: + raise ValueError( + "bgp is partially configured: set router_addr, router_asn and " + f"node_asn together (missing: {', '.join(unset)})" + ) + return self + + +class Talos(Model): + # Default Image Factory schematic for nodes that don't set their own. + schematic_id: str | None = Field(default=None, pattern=r"^[a-z0-9]{64}$") + + +class Spegel(Model): + # True when the cluster has more than one node, unless set explicitly. + enabled: bool | None = None + + +class Cilium(Model): + loadbalancer_mode: Literal["dsr", "snat"] = "dsr" + bgp: Bgp = Bgp() + + +class Node(Model): + name: str = Field(pattern=r"^[a-z0-9][a-z0-9\-]{0,61}[a-z0-9]$|^[a-z0-9]$") + address: IPv4Address + controller: bool + disk: str + mac_addr: str = Field(pattern=r"^([0-9a-f]{2}:){5}[0-9a-f]{2}$") + # Falls back to talos.schematic_id when unset. + schematic_id: str | None = Field(default=None, pattern=r"^[a-z0-9]{64}$") + mtu: int = Field(default=1500, ge=1450, le=9000) + secureboot: bool = False + encrypt_disk: bool = False + kernel_modules: list[str] = [] + + @model_validator(mode="after") + def check(self) -> Self: + if self.name in ("global", "controller", "worker"): + raise ValueError(f"node name {self.name!r} is reserved") + return self + + +class Config(Model): + model_config = ConfigDict(extra="forbid", title="cluster.toml") + + network: Network + kubernetes: Kubernetes + gateways: Gateways + repository: Repository + domain: Domain + dns: Dns + # Defaults to "cloudflare-tunnel" when dns.provider is "cloudflare", + # otherwise "none". + ingress: Ingress = Field( + default_factory=lambda data: Ingress( + mode="cloudflare-tunnel" if data["dns"].provider == "cloudflare" else "none" + ) + ) + cilium: Cilium = Cilium() + talos: Talos = Talos() + spegel: Spegel = Spegel() + nodes: list[Node] + + @computed_field + @property + def cilium_bgp_enabled(self) -> bool: + bgp = self.cilium.bgp + return bgp.router_addr != "" and bgp.router_asn != "" and bgp.node_asn != "" + + # Replica counts for control-plane-only workloads key off this rather + # than len(nodes); a cluster can have many workers but one controller. + @computed_field + @property + def controller_count(self) -> int: + return sum(1 for node in self.nodes if node.controller) + + @computed_field + @property + def cluster_issuer(self) -> str: + if self.dns.provider == "cloudflare": + return "letsencrypt-production" + return "internal-ca" + + # Single source for the machine and apiServer certificate SAN lists, + # which live in separate patch files. + @computed_field + @property + def cert_sans(self) -> list[str]: + return ["127.0.0.1", str(self.kubernetes.api.addr), *(self.kubernetes.api.tls_sans or [])] + + @model_validator(mode="after") + def check(self) -> Self: + if self.spegel.enabled is None: + self.spegel.enabled = len(self.nodes) > 1 + for i, node in enumerate(self.nodes): + if node.schematic_id is None: + node.schematic_id = self.talos.schematic_id + if node.schematic_id is None: + raise ValueError( + f"nodes[{i}].schematic_id is required: set it on the node " + "or set a cluster-wide default in [talos]" + ) + if self.ingress.mode != "none" and self.dns.provider != "cloudflare": + raise ValueError( + f"ingress.mode {self.ingress.mode!r} requires dns.provider 'cloudflare'" + ) + if self.ingress.mode != "none" and self.gateways.external is None: + raise ValueError( + f"gateways.external is required when ingress.mode is {self.ingress.mode!r}" + ) + + cidrs = { + "network.node_cidr": self.network.node_cidr, + "kubernetes.pod_cidr": self.kubernetes.pod_cidr, + "kubernetes.svc_cidr": self.kubernetes.svc_cidr, + } + names = list(cidrs) + for i, a in enumerate(names): + for b in names[i + 1:]: + if cidrs[a].overlaps(cidrs[b]): + raise ValueError(f"{a} {cidrs[a]} overlaps {b} {cidrs[b]}") + + addresses = { + "kubernetes.api.addr": self.kubernetes.api.addr, + "gateways.internal": self.gateways.internal, + "gateways.dns": self.gateways.dns, + "network.default_gateway": self.network.default_gateway, + } | {f"nodes[{i}].address": n.address for i, n in enumerate(self.nodes)} + if self.gateways.external is not None: + addresses["gateways.external"] = self.gateways.external + seen: dict[IPv4Address, str] = {} + for owner, addr in addresses.items(): + if addr in seen: + raise ValueError(f"address {addr} is used by both {seen[addr]} and {owner}") + seen[addr] = owner + + node_cidr = self.network.node_cidr + for i, node in enumerate(self.nodes): + if node.address not in node_cidr: + raise ValueError( + f"nodes[{i}].address {node.address} is not inside node_cidr {node_cidr}" + ) + if self.kubernetes.api.addr not in node_cidr: + raise ValueError( + f"kubernetes.api.addr {self.kubernetes.api.addr} is not inside node_cidr {node_cidr}" + ) + # Without BGP the gateway VIPs are announced over L2 and must live in + # the node network. + if not self.cilium_bgp_enabled: + for name in ("internal", "dns", "external"): + addr = getattr(self.gateways, name) + if addr is not None and addr not in node_cidr: + raise ValueError( + f"gateways.{name} {addr} is not inside node_cidr {node_cidr} " + "(required unless BGP is enabled)" + ) + + for field, label in (("name", "name"), ("mac_addr", "MAC address")): + values: dict[str, int] = {} + for i, node in enumerate(self.nodes): + value = getattr(node, field) + if value in values: + raise ValueError( + f"duplicate node {label} {value!r} on nodes[{values[value]}] and nodes[{i}]" + ) + values[value] = i + return self + + +def format_errors(error: ValidationError) -> str: + lines = [] + for err in error.errors(): + loc = ".".join(str(part) for part in err["loc"]) + msg = err["msg"].removeprefix("Value error, ") + lines.append(f"{loc}: {msg}" if loc else msg) + return "\n".join(lines) + + +class ConfigError(Exception): + pass + + +# Validate config_file and return the defaulted config as a plain dict. +# Raises ConfigError with a human-readable message. +def load(config_file: str = "cluster.toml") -> dict[str, Any]: + path = Path(config_file) + try: + raw = tomllib.loads(path.read_text()) + except FileNotFoundError: + raise ConfigError(f"{path}: file not found") from None + except tomllib.TOMLDecodeError as e: + raise ConfigError(f"{path}: invalid TOML: {e}") from None + + try: + config = Config.model_validate(raw) + except ValidationError as e: + raise ConfigError(format_errors(e)) from None + + # Unset optionals stay in the dump as None rather than being dropped: + # makejinja renders with StrictUndefined, so a template testing + # network.vlan_tag needs the key to exist. + return config.model_dump(mode="json") + + +# JSON Schema for editor completion and validation of cluster.toml (taplo's +# #:schema directive). Cross-field rules and data-aware defaults only exist in +# the model validators, so the schema is an editing aid, not the gate. +def schema() -> dict[str, Any]: + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + **Config.model_json_schema(), + } + + +def main() -> int: + if sys.argv[1:] == ["--schema"]: + json.dump(schema(), sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + try: + data = load(sys.argv[1] if len(sys.argv) > 1 else "cluster.toml") + except ConfigError as e: + print(e, file=sys.stderr) + return 1 + json.dump(data, sys.stdout, indent=2) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ed5f922 --- /dev/null +++ b/uv.lock @@ -0,0 +1,365 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "cattrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "cluster-template" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "makejinja" }, + { name = "pydantic" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "makejinja", specifier = "==2.8.3" }, + { name = "pydantic", specifier = "==2.13.5" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "frozendict" +version = "2.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/b2/2a3d1374b7780999d3184e171e25439a8358c47b481f68be883c14086b4c/frozendict-2.4.7.tar.gz", hash = "sha256:e478fb2a1391a56c8a6e10cc97c4a9002b410ecd1ac28c18d780661762e271bd", size = 317082, upload-time = "2025-11-11T22:40:14.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/74/f94141b38a51a553efef7f510fc213894161ae49b88bffd037f8d2a7cb2f/frozendict-2.4.7-py3-none-any.whl", hash = "sha256:972af65924ea25cf5b4d9326d549e69a9a4918d8a76a9d3a7cd174d98b237550", size = 16264, upload-time = "2025-11-11T22:40:12.836Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "makejinja" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozendict" }, + { name = "jinja2" }, + { name = "pyyaml" }, + { name = "rich-click" }, + { name = "typed-settings", extra = ["attrs", "cattrs", "click"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/94/313ff9b192ee37fad7c7b65bfd62888a7e3d7dfe394e93a7a986bc8d3710/makejinja-2.8.3.tar.gz", hash = "sha256:3462e7718f4deba02beecbcb4a77b5f60c1df32391c5f9f5e9fc9985320b3236", size = 13238, upload-time = "2026-09-07T11:10:03.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/fe/ff26698949c66d86888a0ae2605c2a94c599527cfaf83306718efaaca0b5/makejinja-2.8.3-py3-none-any.whl", hash = "sha256:564e5191fc75e7d8b83fb4d478f89ef6bc7a0395ae296ee56eb9b58a91de43fa", size = 15519, upload-time = "2026-09-07T11:10:01.845Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-click" +version = "1.9.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/ea/21e4867ea0ef881ffd4c0550fc21a061435e50d6324bcd034396633cbc18/rich_click-1.9.8.tar.gz", hash = "sha256:4008f921da88b5d91646c134ec881c1500e5a6b3f093e90e8f29400e09608371", size = 75363, upload-time = "2026-05-28T19:54:59.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/97/a87901aef6b7e7e4a34c6dd6cc17dca8594a592ef9d9dd765fca2b7facf7/rich_click-1.9.8-py3-none-any.whl", hash = "sha256:12873865396e6927835d4eabb1cc3996edcd65b7ac9b2391a29eca4f335a2f93", size = 72189, upload-time = "2026-05-28T19:54:57.867Z" }, +] + +[[package]] +name = "typed-settings" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/19/a8155d9f411ed7cb4a5f510a6c37ac09ea6065542c597c23d0a88ec2fbed/typed_settings-25.3.0.tar.gz", hash = "sha256:865eb52c3184f467705645a1e58db9d719e08b9d7957448a2ad8cbbc22ed21a6", size = 3492969, upload-time = "2025-11-29T22:10:25.768Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/9e/d01ef2bed8e995bbdeeb5307a571729a10545de4a6f8f06d9175fc7a1978/typed_settings-25.3.0-py3-none-any.whl", hash = "sha256:8fe578c84ae2e44f6e8bdde256d2449fbff45f47dca3c4092aeee03900efc12c", size = 63962, upload-time = "2025-11-29T22:10:23.77Z" }, +] + +[package.optional-dependencies] +attrs = [ + { name = "attrs" }, +] +cattrs = [ + { name = "cattrs" }, +] +click = [ + { name = "click" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +]