catalog: clean-start baseline — content library, OCI-distributed (constellation materialization)
This commit is contained in:
parent
928a614f42
commit
55f841517c
1700 changed files with 126405 additions and 0 deletions
112
.gitignore
vendored
Normal file
112
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
.internal.git/
|
||||
.p
|
||||
.claude
|
||||
.vscode
|
||||
.shellcheckrc
|
||||
.coder
|
||||
.migration
|
||||
.zed
|
||||
ai_demo.nu
|
||||
CLAUDE.md
|
||||
.cache
|
||||
.coder
|
||||
.wrks
|
||||
ROOT
|
||||
OLD
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug/
|
||||
target/
|
||||
# Encryption keys and related files (CRITICAL - NEVER COMMIT)
|
||||
.k
|
||||
.k.backup
|
||||
*.key.backup
|
||||
|
||||
config.*.toml
|
||||
config.*back
|
||||
|
||||
# where book is written
|
||||
_book
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
node_modules/
|
||||
|
||||
**/output.css
|
||||
**/input.css
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.development
|
||||
.env.staging
|
||||
|
||||
# Keep example files
|
||||
!.env.example
|
||||
|
||||
# Configuration files (may contain sensitive data)
|
||||
config.prod.toml
|
||||
config.production.toml
|
||||
config.local.toml
|
||||
config.*.local.toml
|
||||
|
||||
# Keep example configuration files
|
||||
!config.toml
|
||||
!config.dev.toml
|
||||
!config.example.toml
|
||||
|
||||
# Log files
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# TLS certificates and keys
|
||||
certs/
|
||||
*.pem
|
||||
*.crt
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
*.tmp
|
||||
*~
|
||||
|
||||
# Encryption and security related files
|
||||
*.encrypted
|
||||
*.enc
|
||||
secrets/
|
||||
private/
|
||||
security/
|
||||
|
||||
# Configuration backups that may contain secrets
|
||||
config.*.backup
|
||||
config.backup.*
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
# Documentation build output
|
||||
book-output/
|
||||
# Generated setup report
|
||||
SETUP_COMPLETE.md
|
||||
45
.governance/laws-schema.ncl
Normal file
45
.governance/laws-schema.ncl
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# sow/laws-schema.ncl — standing-law contract for the provisioning catalog.
|
||||
# Frozen design: provisioning/.coder/2026-07-03_laws-catalogo-provisioning.plan.md §3.
|
||||
# Ratified rulings this schema encodes: R1 (wrks/ out of scope), R2 (in-container
|
||||
# interpreters legitimate), R3 (evidence-only admission), R4 (declarative apply vs
|
||||
# imperative get-decide-patch), J1 (minisign signing), J2 (L2 deferred).
|
||||
#
|
||||
# Signing is deliberately OUT-OF-BAND: the signature covers laws.ncl's bytes as a
|
||||
# detached minisig sidecar (laws.ncl.minisig) verified against a known public key
|
||||
# (~/.minisign/witness.pub). A `signature` field embedded in this record would be
|
||||
# self-referential — pointing to a signature that must be computed over content
|
||||
# that doesn't yet include the pointer, and invalidated by adding it after the fact.
|
||||
# There is nothing to reference in-band; verification is a witness.nu convention
|
||||
# (file exists + `minisign -V` against the known pubkey), not schema data.
|
||||
|
||||
let Status = [| 'Active, 'Deferred |] in
|
||||
|
||||
{
|
||||
Exemption = {
|
||||
path | String,
|
||||
why | String,
|
||||
},
|
||||
|
||||
Candidate = {
|
||||
path | String,
|
||||
pattern | String,
|
||||
remediation | String,
|
||||
},
|
||||
|
||||
Law = {
|
||||
id | String,
|
||||
status | Status,
|
||||
why | String,
|
||||
kind | [| 'static, 'runtime, 'lock |],
|
||||
scope | Array String,
|
||||
exclude | Array String,
|
||||
check | String,
|
||||
baseline | String,
|
||||
exempt | Array Exemption,
|
||||
candidates | Array Candidate | default = [],
|
||||
},
|
||||
|
||||
Laws = {
|
||||
laws | Array Law,
|
||||
},
|
||||
}
|
||||
239
.governance/laws.ncl
Normal file
239
.governance/laws.ncl
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
# sow/laws.ncl — standing laws for the provisioning catalog.
|
||||
#
|
||||
# T2 of the frozen contract: provisioning/.coder/2026-07-03_leyes-catalogo-provisioning.plan.md
|
||||
# This file is a DRAFT until J3 closes: the human signs it with the dedicated minisign
|
||||
# key (see §9 J1/J3 of the contract). Until `signature` below is filled in and verified,
|
||||
# witness.nu MUST refuse to treat these laws as binding (guard: no valid signature, no
|
||||
# start — same rule as the SOW ratification gate in ADR-063).
|
||||
#
|
||||
# Every classification below (exempt/candidates) is traceable to the contract's §2/§5,
|
||||
# which record the verified evidence (file contents, git/Time-Machine snapshot diffs,
|
||||
# script headers) each ruling rests on. Nothing here is invented; where evidence was
|
||||
# ambiguous, the contract's §9 records the human ruling that resolved it.
|
||||
|
||||
let schema = import "laws-schema.ncl" in
|
||||
|
||||
{
|
||||
laws = [
|
||||
{
|
||||
id = "L1-no-parsing-on-targets",
|
||||
status = 'Active,
|
||||
why = m%"
|
||||
PAP: controllers resolve, targets execute. Parsing (json/yaml/templates/
|
||||
versions) belongs in prepare/postdeploy on the controller (nushell/nickel);
|
||||
targets receive fully-resolved artifacts. Burn: garage+loki deploy sessions
|
||||
shipped `python3 -c "import json…"` parsing to target nodes (2026-06-23
|
||||
interview). R4 (2026-07-03) sharpened the boundary after a first-draft
|
||||
exemption for "live-state reads" was correctly rejected: the violation is not
|
||||
parsing or reading live state, it is THE TARGET DECIDING. `kubectl apply`
|
||||
(ideally --server-side) of a manifest prepare rendered fully-resolved is
|
||||
compliant even against a resource that didn't exist at prepare-time —
|
||||
Kubernetes' own merge semantics resolve "add if missing" with no target-side
|
||||
decision. Reading live state, deciding in ANY language, and imperatively
|
||||
patching is the same violation as the original python3 -c cases; the language
|
||||
carrying the decision logic is incidental.
|
||||
"%,
|
||||
kind = 'static,
|
||||
scope = ["**/*.sh", "**/*.j2", "**/*.yaml"],
|
||||
exclude = ["wrks/**", "providers/*/bin/**", "providers/*/templates/**"],
|
||||
# exclude rationale:
|
||||
# wrks/** — R1: temporary/archived material, not part of the final
|
||||
# project. Verified 2026-07-03 unchanged from original scan (no disguised
|
||||
# fix): install-stalwart.sh, install-metallb.sh, install-oci-reg.sh,
|
||||
# install-kubernetes.sh still carry their original python3 -c lines.
|
||||
# providers/*/bin/** — controller/operator-side cloud-provisioning
|
||||
# tooling (needs cloud API credentials a target never holds). Confirmed as a
|
||||
# directory-level convention repeated across aws/hetzner/local/upcloud, not
|
||||
# a one-off. Added after classifying providers/aws/bin/get-image.sh.
|
||||
# providers/*/templates/** — RESOLVED 2026-07-03, second pass. Running
|
||||
# the check for real (T3 red-team) surfaced 4 hetzner templates
|
||||
# (hetzner_networks.j2, hetzner_storage_box.j2, hetzner_servers.j2,
|
||||
# hetzner_volumes.j2) doing `hcloud ... -o json | nu -c '$env.HJSON | from
|
||||
# json | ...'` — get-decide-patch against the LIVE Hetzner Cloud API (R4
|
||||
# violation shape), in nushell instead of jq. Confirmed controller-side:
|
||||
# referenced from core/nulib/orchestration/servers/create.nu, needs Hetzner
|
||||
# API credentials no target ever holds — same category as
|
||||
# providers/*/bin/get-image.sh, different subdirectory. Excluded rather than
|
||||
# tracked as a candidate (unlike zot-lib.sh/coredns_vpn) because it is
|
||||
# controller-side orchestration, not target execution — out of L1's scope by
|
||||
# definition, the same reasoning as backup_manager.
|
||||
check = m%"! rg -q 'python3? (-c|<<|-m )|import json' . --glob '*.sh' --glob '*.j2' --glob '*.yaml' --glob '!wrks/**' --glob '!providers/*/bin/**' --glob '!providers/*/templates/**' --glob '!components/odoo/cluster/templates/deployment.yaml.j2'"%,
|
||||
# check bug fix 2026-07-03: dropped the bare `from json` alternative — it matched
|
||||
# both Python's `from json import ...` AND Nushell's native `from json` filter
|
||||
# (`| from json |`), a false-positive collision across languages. `import json`
|
||||
# alone still catches every original burn case (stalwart/metallb/oci-reg/
|
||||
# kubernetes all use `import json`, none use `from json import`).
|
||||
baseline = "",
|
||||
exempt = [
|
||||
{
|
||||
path = "components/odoo/cluster/templates/deployment.yaml.j2",
|
||||
why = m%"
|
||||
R2: all python occurrences (14) execute inside k8s initContainers/
|
||||
container commands on the odoo image, which ships python. Deploy
|
||||
machinery and target hosts assume nothing. This is the one exemption
|
||||
that matters for the mechanical check above (the only file where the
|
||||
python-pattern regex would otherwise fire).
|
||||
"%,
|
||||
},
|
||||
{
|
||||
path = "components/fleet_daemon/cluster/install-fleet_daemon.sh",
|
||||
why = m%"
|
||||
jq -e . is syntax validation only (no field extraction, no live read) —
|
||||
a fail-fast guard on a value the controller already supplied via
|
||||
ACCESS_POLICY_JSON, catching malformed JSON at the bash boundary instead
|
||||
of inside the daemon at startup. Grants no new target-side authority or
|
||||
decision-making. Not matched by the python-only mechanical check; recorded
|
||||
here for audit completeness of the R4 jq classification pass.
|
||||
"%,
|
||||
},
|
||||
{
|
||||
path = "components/backup_manager/cluster/install-backup_manager.sh",
|
||||
why = m%"
|
||||
The jq-using block runs where `nickel` is available — controller-side
|
||||
reporting for the human operator ("Run 'provisioning component install
|
||||
backup_manager --target-hosts' to deploy binaries"), not target execution.
|
||||
Out of L1's scope by definition, not by exemption from a violation.
|
||||
"%,
|
||||
},
|
||||
{
|
||||
path = "components/lian_build/cluster/setup-buildkit-client.sh",
|
||||
why = m%"
|
||||
component.json, per the script's own header, is "NCL-derived config" — a
|
||||
controller-resolved artifact optionally dropped alongside the script as a
|
||||
fallback to env vars. The jq -r ... // empty call is a plain key lookup
|
||||
with default fallback on an already-resolved artifact, not a target-side
|
||||
decision. This script is a reference example of the R4-compliant pattern:
|
||||
its header states "declarative apply of lian-build's mTLS identity" and it
|
||||
does envsubst < tmpl | kubectl apply -f -, not get-decide-patch.
|
||||
"%,
|
||||
},
|
||||
{
|
||||
path = "components/coredns_vpn/taskserv/install-coredns_vpn.sh:_patch_cluster_coredns",
|
||||
why = m%"
|
||||
RECLASSIFIED 2026-07-03 (was a candidate). Verified directly against
|
||||
k0s's own source (pkg/component/controller/coredns.go, upstream
|
||||
k0sproject/k0s): the default Corefile it generates has NO `import
|
||||
custom/*.override` or `coredns-custom` ConfigMap mechanism — that
|
||||
pattern exists on k3s (a different, unrelated project despite the
|
||||
similar name), not k0s, which this catalog targets (_resolve_kubeconfig
|
||||
references k0s admin.conf paths throughout). The field being touched
|
||||
(ConfigMap data.Corefile) is an opaque string blob with no native
|
||||
Kubernetes list-merge mechanism (unlike Gateway API's spec.listeners,
|
||||
which IS mergeable — see zot-lib.sh's candidate entry). Rendering the
|
||||
full Corefile statically in prepare would require prepare to know the
|
||||
cluster's actual current baseline in advance, which it cannot without
|
||||
either querying the live cluster (defeating the purpose) or assuming a
|
||||
static baseline that could silently diverge from reality and clobber
|
||||
unrelated cluster DNS config. The existing read-current-then-append-
|
||||
missing-zones pattern (idempotent: skips zones already present) is the
|
||||
most robust option actually available on this distribution today — not
|
||||
a violation avoided out of habit, a genuine absence of a safe
|
||||
declarative alternative.
|
||||
"%,
|
||||
},
|
||||
{
|
||||
path = "components/zot/cluster/zot-lib.sh:_patch_public_gateway_listener,_remove_public_gateway_listener",
|
||||
why = m%"
|
||||
RESOLVED 2026-07-03 (was a candidate, then partially remediated, now
|
||||
fully). Both functions redesigned to declarative Server-Side Apply
|
||||
under field-manager=zot-registry: add applies
|
||||
manifests/public-gateway-listener.yaml.j2 (rendered, verified with
|
||||
nu_plugin_tera — the project's real mechanism, not the standalone tera
|
||||
CLI tried first — against 3 cases: cross-namespace cert, same-
|
||||
namespace, gateway-not-configured, all valid YAML); remove applies an
|
||||
explicit empty `listeners: []` under the same field manager. Gateway
|
||||
API's spec.listeners is x-kubernetes-list-type=map (merge key: name),
|
||||
so SSA tracks per-manager ownership of individual list entries —
|
||||
applying zero entries under zot-registry prunes exactly what that
|
||||
manager previously contributed, leaving listeners owned by any other
|
||||
field manager on this SHARED Gateway untouched; zot-registry never
|
||||
declared gatewayClassName or other top-level fields, so this cannot
|
||||
affect them either. Accepted on Gateway API's documented behavior and
|
||||
SSA's standard per-manager ownership semantics — not separately
|
||||
exercised against a live cluster (real-cluster validation was
|
||||
considered and explicitly deferred in favor of accepting the
|
||||
documented mechanism, human call). No get/decide/patch remains in
|
||||
either function; real catalog re-checked ACCEPTED after the change.
|
||||
"%,
|
||||
},
|
||||
],
|
||||
candidates = [],
|
||||
},
|
||||
{
|
||||
id = "L2-no-undeclared-interpreters",
|
||||
status = 'Deferred,
|
||||
why = m%"
|
||||
Deferred 2026-07-03 (J2), two independent reasons:
|
||||
(1) R3 — no evidence on the active surface. The only occurrences (polkadot
|
||||
installers) live in wrks/**, excluded by R1.
|
||||
(2) No oracle — verified against the real schema. metadata.ncl's
|
||||
`dependencies` field is inter-component only (e.g. `["kubernetes"]` in
|
||||
local_path_provisioner/metadata.ncl, hccm/nickel/version.ncl) — it never
|
||||
names OS packages or language runtimes, and no schema.ncl constrains it.
|
||||
A check for "undeclared interpreter" has nothing to check against today.
|
||||
Path back to ratifiability (both required): a real burn lands on the active
|
||||
surface, AND metadata.ncl gains a typed field for declared OS/runtime
|
||||
dependencies.
|
||||
"%,
|
||||
kind = 'static,
|
||||
scope = ["**/*.sh", "**/*.j2", "**/*.yaml"],
|
||||
exclude = ["wrks/**", "providers/*/bin/**", "providers/*/templates/**"],
|
||||
check = "echo 'L2 is Deferred — not enforced, see status field and why' >&2; exit 1",
|
||||
baseline = "",
|
||||
exempt = [],
|
||||
candidates = [],
|
||||
},
|
||||
{
|
||||
id = "L3-versions-from-lock",
|
||||
status = 'Active,
|
||||
why = m%"
|
||||
ADR-056: accredited, not generated. A version/tag with no verified
|
||||
provenance is not usable knowledge — the second original burn alongside
|
||||
L1's parsing violation ("se despliega con una tag inventada o la primera
|
||||
al caso... si hay incompatibilidades... ya se verá", 2026-07-03 interview).
|
||||
Looked for the existing mechanism before building anything (corrected
|
||||
mid-session after inventing an unnecessary `versions.lock` file first —
|
||||
see model log): `components/*/nickel/version.ncl` already exists (24 of 48
|
||||
components use a rich shape: version.current/source/check_latest/
|
||||
grace_period), and core/nulib/domain/cache/ already reads it for
|
||||
staleness tracking. The actual gap: nothing ever verified that
|
||||
`version.current` exists for real at `version.source` — the cache system
|
||||
trusts what's declared, it never accredits it. L3 closes exactly that gap;
|
||||
it does not duplicate the existing convention.
|
||||
"%,
|
||||
kind = 'static,
|
||||
# scope is deliberately narrow — matches exactly what check-pinned-versions.nu
|
||||
# actually verifies today (zot, forgejo), not the broader "components/*/
|
||||
# nickel/version.ncl" pattern that would overstate coverage. Widening this
|
||||
# to the other 22 rich-shape components needs integration with the existing
|
||||
# cache/grace_period subsystem first (deferred — see why, and §11 of the
|
||||
# frozen contract) to avoid hammering registries on every witness run.
|
||||
scope = ["components/zot/nickel/version.ncl", "components/forgejo/nickel/version.ncl"],
|
||||
exclude = ["wrks/**"],
|
||||
check = "nu scripts/check-pinned-versions.nu",
|
||||
baseline = "",
|
||||
exempt = [],
|
||||
candidates = [
|
||||
{
|
||||
path = "components/cert_manager/nickel/contracts.ncl:nushell_image",
|
||||
pattern = "auxiliary/helper image (ghcr.io/nushell/nushell:latest) with no version.ncl entry — cert_manager's own version.ncl already tracks a DIFFERENT thing (CERT_MANAGER_VERSION=v1.19.5, its own release), so this can't just be enriched the same way as zot/forgejo without conflating the two",
|
||||
remediation = "decide whether version.ncl should support multiple tracked images (a components/cert_manager/nickel/version.ncl images array) or a separate lock entry for auxiliary/helper containers — needs its own small design decision before fixing",
|
||||
},
|
||||
{
|
||||
path = "components/woodpecker/nickel/{defaults,contracts}.ncl",
|
||||
pattern = "woodpecker/server:latest, woodpecker/agent:latest — 2 occurrences, real tag data not yet obtained",
|
||||
remediation = "docker.io anonymous registry lookup was denied (rate-limit/auth) when checked 2026-07-03 — retry via an authenticated pull, a mirror (e.g. ghcr.io if woodpecker publishes there), or `crane` (available locally) instead of skopeo against docker.io directly.",
|
||||
},
|
||||
{
|
||||
path = "components/ontoref_panel/nickel/{defaults,contracts}.ncl",
|
||||
pattern = "reg.librecloud.online/lamina/ontoref-panel:latest — private registry, real tag data not yet obtained",
|
||||
remediation = "needs credentials for the internal registry (reg.librecloud.online) to run a real skopeo/crane lookup — not publicly queryable like the other 3.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
# `signature` is intentionally OMITTED (schema: optional). It is filled in only at
|
||||
# J3, by the human, in their own shell — never by the agent, never as a placeholder.
|
||||
# witness.nu's guard: absent or unverifiable signature → these laws are non-binding.
|
||||
} | schema.Laws
|
||||
4
.governance/laws.ncl.minisig
Normal file
4
.governance/laws.ncl.minisig
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
untrusted comment: provisioning catalog witness signature
|
||||
RUSMeO4Wu/zCY2QLkL+i4A0wdUef3AZIMpWXMwzmYPxP/nTn+/RndtHEFy98sh5r7ppeBj6ak7R5jHxvSoYYByki697KcWNrHwM=
|
||||
trusted comment: provisioning laws.ncl ratified 2026-07-04 (rev 6): L3 versions-from-lock added (zot+forgejo pinned via skopeo, cert_manager/woodpecker/ontoref_panel candidates), L1 unchanged
|
||||
ONSKeQMGmgHMpgFtLgf6XdcDfTAdKmc4tmL7UfiC3JnE2HZMBWQUvn8GmrRM4LQ6/hmyOMGUYhJAIuyUuNoqCA==
|
||||
12
.governance/receipts/.gitignore
vendored
Normal file
12
.governance/receipts/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Routine verification runs (pre-commit.jsonl, ci.jsonl, and any ad hoc/test
|
||||
# --wo name) are high-frequency, low-value logs — not tracked, same as CI's own
|
||||
# logs aren't committed to the repo they build.
|
||||
#
|
||||
# Named Work Order receipts ARE tracked deliberately: a wo-*.jsonl is a
|
||||
# milestone claim ("this exact state was audited and accredited"), not routine
|
||||
# noise — ADR-056 accredited-not-generated needs the evidence to persist.
|
||||
# Naming convention is the gate: name a receipt wo-<slug>.jsonl to keep it.
|
||||
*
|
||||
!.gitignore
|
||||
!wo-*.jsonl
|
||||
!wo-*.jsonl.minisig
|
||||
1
.governance/receipts/wo-0001-l1-catalog-baseline.jsonl
Normal file
1
.governance/receipts/wo-0001-l1-catalog-baseline.jsonl
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"wo":"wo-0001-l1-catalog-baseline","sow_ref":"sow/laws.ncl","sow_hash":"773b9606adb3ef0e01bdffce9cba159056b36da98b00a1444b26cb4f9f208260","contract":"L1-no-parsing-on-targets","phase":"static","exit":0,"verdict":"pass","detail":"","scope_hash":"861cfaec9a4148fd7fab45256b37e7089f02fe852c3b4b616cccb696aa8c6825","ts":"2026-07-03T21:56:07.523906+01:00"}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
untrusted comment: provisioning catalog witness receipt
|
||||
RUSMeO4Wu/zCY3PaJZyXE0OML/NUwBRAo1KJmVie6alJ06z9z3sngxLsf8/v4EcrOceCUtxoOrgf7ayvM88BnctkYaTbCj4fugI=
|
||||
trusted comment: wo=wo-0001-l1-catalog-baseline 1 law(s) checked at 2026-07-03T21:56:07.523906+01:00
|
||||
MS9hDamLUbJ7iY1h1HH5AsZgmfGyzyeq5w3KwU3shKko9axSsk52CyV+LV5nEFeqKQsn9i42FbrzhjZHcVgMCg==
|
||||
2
.governance/witness.pub
Normal file
2
.governance/witness.pub
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
untrusted comment: minisign public key 63C2FCBB16EE788C
|
||||
RWSMeO4Wu/zCY7O4EOAXvT4o4LOzqvkzfW+t6ay9VXNxgZnwHdydxnnd
|
||||
34
.pre-commit-config.yaml
Normal file
34
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Pre-commit Framework Configuration — catalog/ (own repo: prvng_extensions.git)
|
||||
#
|
||||
# catalog/ is a separate git repository (remote prvng_extensions.git), with
|
||||
# plural independent consumers beyond this one code/ checkout — NOT a submodule
|
||||
# (no .gitmodules entry). scripts/witness.nu and .governance/laws.ncl
|
||||
# (+laws-schema.ncl, laws.ncl.minisig, witness.pub) are NATIVE to catalog/ — its
|
||||
# own governance, ratified and signed here directly, never vendored from code/
|
||||
# or anywhere else (see
|
||||
# provisioning/.coder/2026-07-03_laws-catalogo-provisioning.plan.md §12: no
|
||||
# consumer, code/ included, ever needed its own copy of catalog's own rules).
|
||||
# Hidden under .governance/ per code/.claude/layout_conventions.md's own
|
||||
# precedent (matches .claude/'s profile: tracked, team/agent-facing, not
|
||||
# product docs) — not a bespoke visible root directory (see plan §13).
|
||||
# .governance/receipts/ tracks only named wo-*.jsonl milestones (its own
|
||||
# .gitignore); routine pre-commit/CI runs are untracked logs, not evidence.
|
||||
#
|
||||
# schemas/ IS still vendored from code/ (a real cross-repo Nickel-import
|
||||
# dependency, not governance) via code/scripts/sync-catalog-schemas.nu — a
|
||||
# stopgap `cp -R` pending the OCI/zot content-addressed distribution this
|
||||
# project's own ADR-046 already specifies for shared structural contracts (see
|
||||
# that script's header for the full prerequisite chain, not yet bootstrapped).
|
||||
#
|
||||
# Self-contained either way: no reference to a parent directory needed at
|
||||
# commit time. See plan §8 T4, §11, §12.
|
||||
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: catalog-laws
|
||||
name: Standing laws (L1 no-parsing-on-targets, L3 versions-from-lock) via witness.nu
|
||||
entry: bash -c 'export NICKEL_IMPORT_PATH="$(pwd)" && nu scripts/witness.nu .governance/laws.ncl .governance/receipts/pre-commit.jsonl --pubkey .governance/witness.pub --wo pre-commit'
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
33
.woodpecker.yml
Normal file
33
.woodpecker.yml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Woodpecker CI — catalog/ (own repo: prvng_extensions.git)
|
||||
#
|
||||
# Self-contained: scripts/witness.nu and .governance/laws.ncl (+laws-schema.ncl,
|
||||
# laws.ncl.minisig, witness.pub) are NATIVE to catalog/ — its own governance,
|
||||
# never vendored from code/ or anywhere else (plural independent consumers of
|
||||
# catalog/ exist beyond this checkout; no consumer needs its own copy of
|
||||
# catalog's own rules). Hidden under .governance/ per code/.claude/
|
||||
# layout_conventions.md's own precedent (tracked, team/agent-facing, not
|
||||
# product docs — same profile as .claude/), not a bespoke visible root
|
||||
# directory. schemas/ IS vendored from code/ (a real cross-repo Nickel-import
|
||||
# dependency) via code/scripts/sync-catalog-schemas.nu — a stopgap
|
||||
# pending the OCI/zot content-addressed distribution this project's own ADR-046
|
||||
# already specifies. See
|
||||
# provisioning/.coder/2026-07-03_laws-catalogo-provisioning.plan.md §8 T4, §11-13.
|
||||
# This job only VERIFIES (public key + check commands); it never signs anything,
|
||||
# so no private key or passphrase is ever needed in CI.
|
||||
#
|
||||
# Tool install mirrors the established pattern in code/.woodpecker/Dockerfile
|
||||
# (rust:latest base + cargo install --locked), not a fresh curl-to-binary approach.
|
||||
# skopeo added for L3 (versions-from-lock) — untested against a live Woodpecker
|
||||
# runner, same honest caveat as the rest of this file (§8 T4).
|
||||
|
||||
when:
|
||||
event: [push, pull_request, manual]
|
||||
|
||||
steps:
|
||||
standing-laws:
|
||||
image: rust:latest
|
||||
commands:
|
||||
- apt-get update -qq && apt-get install -y -qq ripgrep minisign skopeo
|
||||
- cargo install nu nickel-lang-cli --locked
|
||||
- export NICKEL_IMPORT_PATH="$(pwd)"
|
||||
- nu scripts/witness.nu .governance/laws.ncl .governance/receipts/ci.jsonl --pubkey .governance/witness.pub --wo ci
|
||||
332
CHANGES.md
Normal file
332
CHANGES.md
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
# Extensions Repository - Changes Summary
|
||||
|
||||
**Date**: 2025-12-11
|
||||
**Location**: `provisioning/extensions/`
|
||||
**Status**: Changes integrated into main provisioning repository
|
||||
|
||||
## Overview
|
||||
|
||||
The extensions directory contains provider-specific implementations, task services, and cluster definitions. Current structure:
|
||||
|
||||
```bash
|
||||
provisioning/extensions/
|
||||
├── providers/ # Cloud provider implementations
|
||||
├── taskservs/ # Infrastructure task services
|
||||
├── clusters/ # Multi-node cluster definitions
|
||||
└── config/ # Extension configuration
|
||||
```
|
||||
|
||||
## Current Structure & Components
|
||||
|
||||
### 1. 📦 Providers
|
||||
|
||||
Cloud provider implementations for infrastructure automation:
|
||||
|
||||
```bash
|
||||
providers/
|
||||
├── upcloud/ # UpCloud provider implementation
|
||||
├── aws/ # Amazon AWS provider implementation
|
||||
├── local/ # Local/on-premise provider
|
||||
└── templates/ # Provider templates (Jinja2 .j2 files)
|
||||
```
|
||||
|
||||
**Changes**: Configuration updates for provider detection and initialization.
|
||||
|
||||
### 2. 🔧 Task Services (Taskservs)
|
||||
|
||||
Infrastructure automation services:
|
||||
|
||||
```bash
|
||||
taskservs/
|
||||
├── kubernetes/ # Kubernetes cluster manager
|
||||
├── containerd/ # Container runtime
|
||||
├── cilium/ # Network plugin
|
||||
├── etcd/ # Distributed key-value store
|
||||
├── postgresql/ # Database service
|
||||
├── redis/ # Cache service
|
||||
├── prometheus/ # Monitoring
|
||||
├── grafana/ # Metrics visualization
|
||||
└── more... # Additional services
|
||||
```
|
||||
|
||||
**Changes**: Updated schemas and Nickel configurations for taskserv definitions.
|
||||
|
||||
### 3. 🎯 Clusters
|
||||
|
||||
Multi-node cluster topology definitions:
|
||||
|
||||
```bash
|
||||
clusters/
|
||||
├── kubernetes-ha/ # High-availability Kubernetes
|
||||
├── kubernetes-single/ # Single-node Kubernetes
|
||||
├── etcd-cluster/ # etcd cluster
|
||||
├── containerd-test/ # Containerd test setup
|
||||
└── more... # Additional cluster types
|
||||
```
|
||||
|
||||
**Changes**: Nickel schema updates for cluster configuration.
|
||||
|
||||
### 4. ⚙️ Extension Configuration
|
||||
|
||||
Configuration for extension system:
|
||||
|
||||
```toml
|
||||
config/
|
||||
├── extensions.ncl # Extension schema definitions
|
||||
├── provider-registry.toml
|
||||
├── taskserv-registry.toml
|
||||
├── cluster-registry.toml
|
||||
└── templates/ # Configuration templates
|
||||
```
|
||||
|
||||
## Key Architectural Points
|
||||
|
||||
### Multi-Cloud Support
|
||||
|
||||
✅ Provider-agnostic batch operations
|
||||
✅ Mixed provider workflows (UpCloud + AWS + local)
|
||||
✅ Provider auto-detection and initialization
|
||||
|
||||
### Taskserv System
|
||||
|
||||
✅ Self-contained, reusable task services
|
||||
✅ Dependency resolution and ordering
|
||||
✅ Auto-install with MCP integration
|
||||
✅ Dynamic discovery and loading
|
||||
|
||||
### Cluster Management
|
||||
|
||||
✅ Topology templates for common scenarios
|
||||
✅ Multi-node cluster definitions
|
||||
✅ HA configuration support
|
||||
✅ Test environment integration
|
||||
|
||||
## Integration with Core System
|
||||
|
||||
### Nickel Schema Integration
|
||||
|
||||
```javascript
|
||||
let provisioning_extensions = import "provisioning/extensions.ncl" in
|
||||
|
||||
# Providers are loaded dynamically
|
||||
let providers = provisioning_extensions.load_providers() in
|
||||
|
||||
# Taskservs are discovered and registered
|
||||
let taskservs = provisioning_extensions.discover_taskservs() in
|
||||
|
||||
# Cluster definitions are templated
|
||||
let clusters = provisioning_extensions.load_cluster_templates() in
|
||||
{providers = providers, taskservs = taskservs, clusters = clusters}
|
||||
```
|
||||
|
||||
### REST API Integration
|
||||
|
||||
- `GET /extensions/providers` - List available providers
|
||||
- `GET /extensions/taskservs` - List available taskservs
|
||||
- `POST /extensions/taskservs/install` - Install taskserv
|
||||
- `GET /extensions/clusters` - List cluster templates
|
||||
|
||||
### CLI Integration
|
||||
|
||||
```bash
|
||||
# Provider operations
|
||||
provisioning providers list
|
||||
provisioning providers validate
|
||||
|
||||
# Taskserv operations
|
||||
provisioning taskserv create kubernetes --infra my-project
|
||||
provisioning taskserv list
|
||||
|
||||
# Cluster operations
|
||||
provisioning cluster create kubernetes-ha --infra my-project
|
||||
provisioning cluster list
|
||||
```
|
||||
|
||||
## Configuration-Driven Design
|
||||
|
||||
All extensions are configuration-driven with no hardcoded values:
|
||||
|
||||
### Provider Configuration (TOML)
|
||||
|
||||
```toml
|
||||
[providers.upcloud]
|
||||
endpoint = "https://api.upcloud.com"
|
||||
auth_type = "bearer_token"
|
||||
regions = ["de-fra1", "us-nyc1", "sg-sin1"]
|
||||
|
||||
[providers.aws]
|
||||
endpoint = "https://ec2.amazonaws.com"
|
||||
auth_type = "iam_role"
|
||||
regions = ["us-east-1", "eu-west-1", "ap-southeast-1"]
|
||||
```
|
||||
|
||||
### Taskserv Configuration (Nickel)
|
||||
|
||||
```javascript
|
||||
let taskserv_definition = {
|
||||
name | string,
|
||||
version | string,
|
||||
provider | string,
|
||||
dependencies | [string],
|
||||
config | {},
|
||||
_check = [
|
||||
(std.string.length name > 0) || std.error "Name required",
|
||||
(std.string.length version > 0) || std.error "Version required",
|
||||
]
|
||||
} in taskserv_definition
|
||||
```
|
||||
|
||||
### Cluster Topology (Nickel/YAML)
|
||||
|
||||
```javascript
|
||||
let cluster_topology = {
|
||||
name | string,
|
||||
type | string,
|
||||
nodes | [{}],
|
||||
networking | {}
|
||||
} in cluster_topology
|
||||
```
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Provider Operations
|
||||
|
||||
- Auto-detection: <500ms
|
||||
- Connection validation: <200ms per provider
|
||||
- Region enumeration: <300ms
|
||||
|
||||
### Taskserv Operations
|
||||
|
||||
- Discovery: <100ms
|
||||
- Installation: varies by taskserv (10s - 5min)
|
||||
- Self-installation: automatic dependency resolution
|
||||
|
||||
### Cluster Operations
|
||||
|
||||
- Template loading: <50ms
|
||||
- Multi-node validation: <300ms
|
||||
- Topology generation: <500ms
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Provider Credentials
|
||||
|
||||
✅ Stored in KMS (Age, Vault, RustyVault, AWS KMS)
|
||||
✅ Never in plaintext configs
|
||||
✅ Rotated automatically via secrets system
|
||||
✅ Audit logged for all access
|
||||
|
||||
### Taskserv Isolation
|
||||
|
||||
✅ Container-based execution
|
||||
✅ Network isolation per test environment
|
||||
✅ Resource limits enforced
|
||||
✅ No privilege escalation
|
||||
|
||||
### Access Control
|
||||
|
||||
✅ Role-based access per provider
|
||||
✅ Cedar policy enforcement
|
||||
✅ MFA required for sensitive operations
|
||||
✅ Audit trail for all changes
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
# Test provider implementations
|
||||
nu tests/test-providers.nu
|
||||
|
||||
# Test taskserv discovery
|
||||
nu tests/test-taskservs.nu
|
||||
|
||||
# Test cluster definitions
|
||||
nu tests/test-clusters.nu
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```bash
|
||||
# End-to-end provider testing
|
||||
nu tests/iac-integration-tests.nu
|
||||
|
||||
# Orchestrator integration
|
||||
nu tests/test-orchestrator-integration.nu
|
||||
|
||||
# E2E operations
|
||||
nu tests/test-fase5-e2e.nu
|
||||
```
|
||||
|
||||
## Implementation Status
|
||||
|
||||
| Component | Status | Version |
|
||||
| ----------- | -------- | --------- |
|
||||
| Providers (UpCloud) | ✅ Complete | v1.0 |
|
||||
| Providers (AWS) | ✅ Complete | v1.0 |
|
||||
| Providers (Local) | ✅ Complete | v1.0 |
|
||||
| Batch Operations | ✅ Complete | v3.1.0 |
|
||||
| Taskserv System | ✅ Complete | v2.0 |
|
||||
| Cluster Management | ✅ Complete | v2.0 |
|
||||
| Test Environments | ✅ Complete | v3.4.0 |
|
||||
| Plugin Integration | ✅ Complete | v1.0 |
|
||||
|
||||
## File Organization
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- `provisioning/schemas/extensions.ncl` - Extension schemas
|
||||
- `provisioning/config/extensions/*.toml` - Provider configs
|
||||
- `provisioning/extensions/` - Extension implementations
|
||||
|
||||
### Documentation
|
||||
|
||||
- `docs/api/extensions.md` - Extensions API reference
|
||||
- `docs/user/PLUGIN_INTEGRATION_GUIDE.md` - Plugin integration
|
||||
- `provisioning/extensions/README.md` - Extension development guide
|
||||
|
||||
## Extending the System
|
||||
|
||||
### Adding a New Provider
|
||||
|
||||
1. Create `provisioning/extensions/providers/{provider_name}/`
|
||||
2. Implement provider interface (Nushell module)
|
||||
3. Add configuration in `provisioning/config/extensions/{provider_name}.toml`
|
||||
4. Register in `provider-registry.toml`
|
||||
5. Add Nickel schema in `provisioning/schemas/extensions.ncl`
|
||||
|
||||
### Adding a New Taskserv
|
||||
|
||||
1. Create `provisioning/extensions/taskservs/{taskserv_name}/`
|
||||
2. Implement installation script (Nushell)
|
||||
3. Add configuration template (Jinja2 `.j2`)
|
||||
4. Register in `taskserv-registry.toml`
|
||||
5. Add Nickel schema with validation
|
||||
|
||||
### Adding a New Cluster Type
|
||||
|
||||
1. Create `provisioning/extensions/clusters/{cluster_name}/`
|
||||
2. Define cluster topology (Nickel/YAML)
|
||||
3. Add node templates
|
||||
4. Register in `cluster-registry.toml`
|
||||
5. Add documentation with examples
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
✅ All existing provider configurations continue to work
|
||||
✅ New configuration format is optional (gradual migration)
|
||||
✅ Legacy ENV variables supported with deprecation warnings
|
||||
✅ Fallback mechanisms for missing configurations
|
||||
|
||||
## Next Steps for Extensions
|
||||
|
||||
1. ✅ Unified architecture documentation
|
||||
2. ✅ Complete API reference
|
||||
3. ⏳ Additional provider implementations
|
||||
4. ⏳ More cluster topology templates
|
||||
5. ⏳ Performance optimization for large deployments
|
||||
|
||||
---
|
||||
|
||||
**Generated**: 2025-12-11
|
||||
**Status**: Integrated into main provisioning repository
|
||||
346
components/MIGRATION_CONCERNS.md
Normal file
346
components/MIGRATION_CONCERNS.md
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
# ServiceConcerns Migration — Component Reference
|
||||
|
||||
This document describes the mechanical pattern for migrating each component
|
||||
under `provisioning/extensions/components/<X>/nickel/` to declare a
|
||||
`ServiceConcerns` umbrella (ADR-008, ADR-009).
|
||||
|
||||
## Status
|
||||
|
||||
Components migrated as of this writing — patrón aplicado y validado:
|
||||
|
||||
**TLS endpoints / databases:**
|
||||
- `docker_mailserver` — `tls_endpoint_with_acme` style; backup `'pending` for mail-stack BackupGroup
|
||||
- `zot` — `tls_endpoint_with_acme` style; backup `'pending` for registry-stack BackupGroup
|
||||
- `postgresql` — `database`; backup `'pending` for workspace-level BackupPolicy with dump_strategy
|
||||
|
||||
**Storage backends / cert infrastructure:**
|
||||
- `longhorn` — `infra_storage_managed`; backup `'disabled` (engine state via SystemBackupDef.longhorn_engine)
|
||||
- `cert_manager` — `infra_storage_managed`; backup `'disabled` (cluster_resources via SystemBackupDef.tls_certmanager)
|
||||
|
||||
**Stateless runtimes / OS / kernel layer:**
|
||||
- `containerd` — `stateless`; container runtime
|
||||
- `crio` — `stateless`; container runtime
|
||||
- `runc` — `stateless`; OCI runtime
|
||||
- `crun` — `stateless`; OCI runtime
|
||||
- `youki` — `stateless`; OCI runtime
|
||||
- `os` — `stateless`; base OS configuration
|
||||
- `kubernetes` — `stateless`; kubeadm-managed CP/kubelet (state in etcd via SystemBackupDef.etcd, PKI via SystemBackupDef.k8s_certs)
|
||||
- `k0s` — `stateless`; data_dir via SystemBackupDef.host_configs when k0s is the active distribution
|
||||
- `k8s-nodejoin` — `stateless`; one-shot join operation
|
||||
- `vol_prepare` — `stateless`; block-device prep
|
||||
- `longhorn_node_prep` — `stateless`; node-level Longhorn prep
|
||||
|
||||
**Infrastructure glue (controllers / CSI / network):**
|
||||
- `cilium` — `infrastructure_glue`; CNI controller
|
||||
- `hccm` — `infrastructure_glue`; Hetzner cloud controller
|
||||
- `hetzner_csi` — `infrastructure_glue`; CSI driver
|
||||
- `democratic_csi` — `infrastructure_glue`; NFS dynamic provisioner
|
||||
- `external_nfs` — `infrastructure_glue`; NFS server
|
||||
- `local_path_provisioner` — `infrastructure_glue`; local-path provisioner
|
||||
- `fip` — `infrastructure_glue`; floating IP attachment
|
||||
- `etcd` — `infrastructure_glue`; data captured by SystemBackupDef.etcd
|
||||
- `private_gateway` — `infrastructure_glue`; Cilium gateway
|
||||
- `buildkit_runner` — `infrastructure_glue`; ephemeral by design (ADR-039)
|
||||
|
||||
**DNS providers:**
|
||||
- `coredns` — `dns_provider`; zone files via SystemBackupDef.external_coredns
|
||||
- `external_dns` — `dns_provider`; controller, records live in upstream provider
|
||||
- `resolv` — `stateless`; /etc/resolv.conf manager
|
||||
|
||||
**TLS endpoints with ACME:**
|
||||
- `forgejo` — `tls_endpoint_with_acme`; backup `'pending` (Git+DB+actions)
|
||||
- `woodpecker` — `tls_endpoint_with_acme`; backup `'pending` (DB+agent state)
|
||||
- `stalwart` — `tls_endpoint_with_acme`; backup `'pending` (mail data+DB)
|
||||
- `nats` — internal cluster service; backup `'pending` (JetStream state)
|
||||
- `odoo` — bespoke; backup `'pending` for odoo-stack BackupGroup
|
||||
|
||||
**Databases:**
|
||||
- `mariadb` — `database`; backup `'pending` (dump_strategy at workspace level)
|
||||
- `surrealdb` — `database`; backup `'pending` (dump_strategy at workspace level)
|
||||
|
||||
**VPN gateway:**
|
||||
- `wireguard` — stateless; peer keys + config in git/SOPS
|
||||
|
||||
**Total: 37 of 38 components migrated.** Only `backup_manager` is excluded —
|
||||
it is the subsystem itself and does not consume its own ServiceConcerns schema.
|
||||
|
||||
**No components pending migration with `extensions/` directory.**
|
||||
|
||||
Components without `extensions/components/<name>/` directory (workspace-only):
|
||||
- `prometheus.ncl`, `grafana.ncl`, `loki.ncl`, `vector.ncl` — declare `concerns`
|
||||
directly in the workspace `.ncl` file
|
||||
- `ops_controller.ncl`, `audit_mirror.ncl`, `radicle_seed.ncl`,
|
||||
`coredns_vpn.ncl`, `coredns_vpn_wrk1.ncl` — workspace-declared, mostly
|
||||
reusing `ext.make_coredns` (which carries inherited `concerns` from coredns).
|
||||
|
||||
## Reusable presets
|
||||
|
||||
`provisioning/schemas/lib/concerns_presets.ncl` exports 7 archetype presets that
|
||||
defaults.ncl can import directly to avoid duplicating the same 9-line block
|
||||
across components. Components that match an archetype exactly should use:
|
||||
|
||||
```nickel
|
||||
let _presets = (import "schemas/lib/concerns_presets.ncl").presets in
|
||||
|
||||
{
|
||||
<component> | default = {
|
||||
# ... existing fields ...
|
||||
concerns | default = _presets.<archetype>,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Components using presets directly (8):** `containerd`, `crio`, `runc`, `crun`,
|
||||
`youki`, `os` (`stateless`); `cilium`, `hccm` (`infrastructure_glue`).
|
||||
|
||||
**Components using preset + override** (`_presets.<name> & { … | force = … }`)
|
||||
**(22):** `k8s-nodejoin`, `vol_prepare`, `resolv`, `wireguard`,
|
||||
`longhorn_node_prep`, `kubernetes`, `k0s` (stateless variants);
|
||||
`hetzner_csi`, `democratic_csi`, `external_nfs`, `local_path_provisioner`,
|
||||
`fip`, `private_gateway`, `buildkit_runner`, `etcd` (infrastructure_glue);
|
||||
`longhorn`, `cert_manager` (infra_storage_managed); `coredns`, `external_dns`
|
||||
(dns_provider); `postgresql`, `mariadb`, `surrealdb` (database).
|
||||
|
||||
**Components using parametric preset** (`_presets.tls_endpoint_with_acme {…}`)
|
||||
**(6):** `zot`, `docker_mailserver`, `forgejo`, `woodpecker`, `stalwart`, `odoo`.
|
||||
|
||||
**Of these, 4 also declare `manifest_hooks` in their `defaults.ncl`** (standard
|
||||
gateway-based TLS lifecycle via `catalog/lib/tls-hook-methods.sh`):
|
||||
`forgejo`, `wordpress_site`, `odoo`, `rustelo_website`.
|
||||
|
||||
`zot`, `docker_mailserver`, `stalwart` use custom TLS lifecycle (own lib methods)
|
||||
and do NOT declare `manifest_hooks` — the preset does not carry hooks.
|
||||
|
||||
**Components with full inline blocks (1):** `nats` (internal cluster service,
|
||||
distinct from public TLS endpoints).
|
||||
|
||||
**Total: 36 of 37 migrated components use presets** (97%).
|
||||
|
||||
## Mechanical pattern
|
||||
|
||||
For each `provisioning/extensions/components/<X>/nickel/`:
|
||||
|
||||
### Step 1 — `contracts.ncl`
|
||||
|
||||
Add the import at the top of the file (above the outer `{`):
|
||||
|
||||
```nickel
|
||||
let _concerns_lib = import "schemas/lib/concerns.ncl" in
|
||||
```
|
||||
|
||||
Inside the component schema record (before the trailing `..` if it has one,
|
||||
or as the last field otherwise) add:
|
||||
|
||||
```nickel
|
||||
# ServiceConcerns umbrella (ADR-008). See defaults.ncl for the populated value.
|
||||
concerns | _concerns_lib.ServiceConcerns | optional,
|
||||
```
|
||||
|
||||
If the contract is a closed record (no `..`), also add `..` so legacy
|
||||
`mode`/`operations`/`live_check` fields are accepted by the contract.
|
||||
|
||||
### Step 2 — `defaults.ncl`
|
||||
|
||||
Add a `concerns | default = { ... }` block before the closing `}` of the
|
||||
default record. Choose the variant matching the component's archetype.
|
||||
|
||||
#### Stateless preset (runtime/OS)
|
||||
|
||||
```nickel
|
||||
concerns | default = {
|
||||
tls = { kind = 'disabled, reason = "no TLS termination at this layer" },
|
||||
dns = { kind = 'disabled, reason = "no DNS records owned by this component" },
|
||||
certs = { kind = 'disabled, reason = "no ACME issuer required" },
|
||||
backup = {
|
||||
kind = 'disabled,
|
||||
reason = "stateless: configuration in git, no runtime data to capture",
|
||||
},
|
||||
observability = { kind = 'pending, reason = "ObservabilityImpl iteration deferred", backlog_ref = "OBS-001" },
|
||||
security = { kind = 'pending, reason = "SecurityImpl iteration deferred", backlog_ref = "SEC-001" },
|
||||
},
|
||||
```
|
||||
|
||||
#### Infrastructure glue (controllers/operators)
|
||||
|
||||
```nickel
|
||||
concerns | default = {
|
||||
tls = { kind = 'disabled, reason = "controller-level RBAC, not TLS endpoint" },
|
||||
dns = { kind = 'disabled, reason = "no DNS records owned by this component" },
|
||||
certs = { kind = 'disabled, reason = "no ACME issuer required" },
|
||||
backup = {
|
||||
kind = 'disabled,
|
||||
reason = "state in K8s API captured by SystemBackupDef.cluster_resources",
|
||||
},
|
||||
observability = { kind = 'pending, reason = "ObservabilityImpl iteration deferred", backlog_ref = "OBS-001" },
|
||||
security = { kind = 'pending, reason = "SecurityImpl iteration deferred", backlog_ref = "SEC-001" },
|
||||
},
|
||||
```
|
||||
|
||||
#### TLS endpoint with ACME (public service)
|
||||
|
||||
```nickel
|
||||
concerns | default = {
|
||||
tls = {
|
||||
kind = 'enabled,
|
||||
tls_impl = {
|
||||
secret_name = "<COMPONENT>-tls",
|
||||
issuer_ref = "letsencrypt-prod",
|
||||
hostnames = [],
|
||||
},
|
||||
},
|
||||
dns = {
|
||||
kind = 'enabled,
|
||||
dns_impl = {
|
||||
internal = [],
|
||||
zone = "",
|
||||
},
|
||||
},
|
||||
certs = {
|
||||
kind = 'enabled,
|
||||
certs_impl = {
|
||||
acme_server = "https://acme-v02.api.letsencrypt.org/directory",
|
||||
email = "",
|
||||
secret_ref = "",
|
||||
provider = 'cloudflare,
|
||||
},
|
||||
},
|
||||
backup = {
|
||||
kind = 'pending,
|
||||
reason = "BackupPolicy declared at workspace level",
|
||||
backlog_ref = "BACKUP-<COMPONENT>-001",
|
||||
},
|
||||
observability = { kind = 'pending, reason = "ObservabilityImpl iteration deferred", backlog_ref = "OBS-001" },
|
||||
security = { kind = 'pending, reason = "SecurityImpl iteration deferred", backlog_ref = "SEC-001" },
|
||||
},
|
||||
```
|
||||
|
||||
#### Database
|
||||
|
||||
```nickel
|
||||
concerns | default = {
|
||||
tls = { kind = 'disabled, reason = "internal cluster service, ingress-level TLS handled separately" },
|
||||
dns = { kind = 'disabled, reason = "no public DNS records" },
|
||||
certs = { kind = 'disabled, reason = "no ACME issuer required" },
|
||||
backup = {
|
||||
kind = 'pending,
|
||||
reason = "BackupPolicy with database scope + dump_strategy declared at workspace level",
|
||||
backlog_ref = "BACKUP-DB-<COMPONENT>-001",
|
||||
},
|
||||
observability = { kind = 'pending, reason = "ObservabilityImpl iteration deferred", backlog_ref = "OBS-001" },
|
||||
security = { kind = 'pending, reason = "SecurityImpl iteration deferred", backlog_ref = "SEC-001" },
|
||||
},
|
||||
```
|
||||
|
||||
### Step 3 — Verify
|
||||
|
||||
```bash
|
||||
cat > /tmp/check_<X>.ncl <<EOF
|
||||
let mod = import "extensions/components/<X>/nickel/defaults.ncl" in
|
||||
mod.<x>.concerns
|
||||
EOF
|
||||
nickel export --import-path provisioning /tmp/check_<X>.ncl
|
||||
```
|
||||
|
||||
Expected output: a JSON object with `tls`, `dns`, `certs`, `backup`,
|
||||
`observability`, `security` keys, each with `kind` and matching payload.
|
||||
|
||||
## Workspace-level overrides
|
||||
|
||||
After per-component defaults are populated, the workspace declarations in
|
||||
`infra/<workspace>/components/<x>.ncl` can override individual concerns. The
|
||||
most common override is to bring `backup` from `'pending` to `'enabled` with
|
||||
a real `BackupPolicy` referencing `infra/<workspace>/lib/backup_policies.ncl`
|
||||
presets.
|
||||
|
||||
Example — workspace activates backup for a component with full BackupPolicy:
|
||||
|
||||
```nickel
|
||||
let bp_lib = import "../../lib/backup_policies.ncl" in
|
||||
let ext = import "extensions/components/<X>/nickel/main.ncl" in
|
||||
|
||||
{
|
||||
<x> = ext.make_<x> {
|
||||
# ... existing fields ...
|
||||
concerns = ext.defaults.<x>.concerns & {
|
||||
backup = {
|
||||
kind = 'enabled,
|
||||
backup_impl = {
|
||||
provider = { name = "restic" },
|
||||
destinations = [bp_lib.destinations.hetzner_primary, bp_lib.destinations.b2_replica],
|
||||
encryption = bp_lib.encryption.component_key "<x>",
|
||||
schedule = { kind = 'cron, cron_expr = "0 3 * * *" },
|
||||
retention = bp_lib.retention.gold,
|
||||
scopes = [
|
||||
{ kind = 'service_full, name = "main", paths = ["/var/lib/<x>"] },
|
||||
],
|
||||
tag_strategy = bp_lib.tag_strategy.component "<x>",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Op-level manifest hooks (TLS gateway lifecycle)
|
||||
|
||||
Some cluster components require lifecycle steps that are structurally identical
|
||||
across instances — Cloudflare secret, ClusterIssuer, Certificate, ReferenceGrant,
|
||||
Gateway patch. Rather than repeating them in every `manifest_plan.ncl`, these
|
||||
components declare `manifest_hooks` inside their `concerns` record in `defaults.ncl`.
|
||||
|
||||
The Nu bundle builder (`comp-build-cluster-bundle`) merges hooks from two sources:
|
||||
|
||||
```
|
||||
effective_pre = concerns.manifest_hooks.op.pre ++ manifest_plan.hooks.op.pre
|
||||
effective_post = manifest_plan.hooks.op.post ++ concerns.manifest_hooks.op.post
|
||||
```
|
||||
|
||||
Generated `run-<op>.sh` executes: `effective_pre ++ plan_steps ++ effective_post`.
|
||||
|
||||
### Which components use manifest_hooks
|
||||
|
||||
Only components that deploy via the **standard gateway-based TLS lifecycle**
|
||||
(cert-manager ClusterIssuer + Gateway API HTTPRoute + Cloudflare DNS-01):
|
||||
|
||||
- `forgejo`, `wordpress_site`, `odoo`, `rustelo_website`
|
||||
|
||||
Components with custom TLS lifecycle (`docker_mailserver`, `stalwart`, `zot`)
|
||||
use the preset for concerns compliance only — no manifest_hooks.
|
||||
|
||||
### Required env vars for hook-based TLS components
|
||||
|
||||
Each component's `env-<name>.j2` must export these vars so that
|
||||
`catalog/lib/tls-hook-methods.sh` can execute the hooks at deploy time:
|
||||
|
||||
```bash
|
||||
TLS_HOOK_CF_SECRET_NAME=<cf-secret-name> # e.g. dns-jesusperez-pro
|
||||
TLS_HOOK_GATEWAY_NAME=<gateway-name> # e.g. libre-wuji
|
||||
TLS_HOOK_GATEWAY_NS=<gateway-namespace> # e.g. kube-system
|
||||
TLS_HOOK_LISTENER_NAME=https-<service-name> # e.g. https-wordpress-diegodelgado
|
||||
TLS_HOOK_DOMAIN=<primary-domain> # e.g. diegodelgado.es
|
||||
TLS_HOOK_TLS_SECRET=<tls-secret-name> # e.g. wordpress-diegodelgado-tls
|
||||
TLS_HOOK_NAMESPACE=<service-namespace> # e.g. diegodelgado
|
||||
TLS_HOOK_CLUSTER_ISSUER=<issuer-name> # e.g. letsencrypt-prod-diegodelgado
|
||||
```
|
||||
|
||||
These are populated from Tera variables that `vars.nu` must expose (no component
|
||||
prefix): `cf_secret_name`, `gateway_ip`, `tls_secret`, `dns_zone`.
|
||||
|
||||
### Adding a new gateway-TLS component
|
||||
|
||||
1. Declare `concerns | default = (_presets.tls_endpoint_with_acme {...}) & { manifest_hooks = {...} }` in `defaults.ncl` (copy from forgejo/defaults.ncl).
|
||||
2. Keep `manifest_plan.ncl` clean — no TLS steps (create-cf-secret, clusterissuer, certificate, referencegrant, patch-gateway-https, remove-gateway-https).
|
||||
3. Export `TLS_HOOK_*` vars in `env-<name>.j2`.
|
||||
4. Ensure `vars.nu` outputs `cf_secret_name`, `gateway_ip`, `tls_secret` (without component prefix).
|
||||
5. No changes to lib.sh — TLS methods live in `catalog/lib/tls-hook-methods.sh`.
|
||||
|
||||
## Reference
|
||||
|
||||
- ADR-008: Service Concerns Umbrella
|
||||
- ADR-009: Progressive Concerns Coverage
|
||||
- ADR-010: Backup Architecture
|
||||
- ADR-011: Multi-Destination Custody
|
||||
- Schema: `provisioning/schemas/lib/concerns.ncl`
|
||||
- Presets: `provisioning/schemas/lib/concerns_presets.ncl`
|
||||
- Hook methods: `catalog/lib/tls-hook-methods.sh`
|
||||
- Workspace defaults: `workspaces/libre-wuji/infra/lib/concerns_defaults.ncl`
|
||||
51
components/_renderer/common-templates/delete.sh.tmpl
Normal file
51
components/_renderer/common-templates/delete.sh.tmpl
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
#!/bin/bash
|
||||
# GENERATED — component={{COMPONENT}} bundle={{BUNDLE_ID}} op=delete
|
||||
# Removes workload resources only (by label). Preserves pvc, secret, namespace.
|
||||
set -euo pipefail
|
||||
|
||||
BUNDLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOG="${BUNDLE_DIR}/delete.log"
|
||||
NAMESPACE="{{NAMESPACE}}"
|
||||
COMPONENT="{{COMPONENT}}"
|
||||
STEP="init"
|
||||
|
||||
exec > >(tee -a "$LOG") 2>&1
|
||||
|
||||
_kubectl() {
|
||||
if command -v kubectl >/dev/null 2>&1; then
|
||||
command kubectl "$@"
|
||||
elif command -v k0s >/dev/null 2>&1; then
|
||||
k0s kubectl "$@"
|
||||
else
|
||||
echo "ERROR: no kubectl or k0s binary found on host" >&2
|
||||
exit 127
|
||||
fi
|
||||
}
|
||||
|
||||
_fail() {
|
||||
local rc=$?
|
||||
echo "FAILED step=${STEP} line=${BASH_LINENO[0]} rc=${rc}" >&2
|
||||
exit "${rc}"
|
||||
}
|
||||
trap _fail ERR
|
||||
|
||||
echo "[delete] component=${COMPONENT} bundle={{BUNDLE_ID}} namespace=${NAMESPACE} started=$(date -Iseconds)"
|
||||
|
||||
STEP="delete-workload"
|
||||
_kubectl delete statefulset,deployment,service,configmap \
|
||||
--namespace "${NAMESPACE}" \
|
||||
--selector "app.kubernetes.io/name=${COMPONENT}" \
|
||||
--ignore-not-found \
|
||||
--timeout=90s
|
||||
|
||||
STEP="verify-preserved"
|
||||
if _kubectl get pvc --namespace "${NAMESPACE}" --selector "app.kubernetes.io/name=${COMPONENT}" -o name 2>/dev/null | grep -q .; then
|
||||
echo "[delete] PVCs preserved:"
|
||||
_kubectl get pvc --namespace "${NAMESPACE}" --selector "app.kubernetes.io/name=${COMPONENT}"
|
||||
fi
|
||||
if _kubectl get secret "${COMPONENT}-credentials" --namespace "${NAMESPACE}" >/dev/null 2>&1; then
|
||||
echo "[delete] Secret ${COMPONENT}-credentials preserved."
|
||||
fi
|
||||
|
||||
STEP="done"
|
||||
echo "[delete] OK finished=$(date -Iseconds)"
|
||||
77
components/_renderer/common-templates/install.sh.tmpl
Normal file
77
components/_renderer/common-templates/install.sh.tmpl
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
#!/bin/bash
|
||||
# GENERATED — component={{COMPONENT}} bundle={{BUNDLE_ID}} op=install
|
||||
set -euo pipefail
|
||||
|
||||
BUNDLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOG="${BUNDLE_DIR}/install.log"
|
||||
NAMESPACE="{{NAMESPACE}}"
|
||||
COMPONENT="{{COMPONENT}}"
|
||||
STEP="init"
|
||||
|
||||
exec > >(tee -a "$LOG") 2>&1
|
||||
|
||||
_kubectl() {
|
||||
if command -v kubectl >/dev/null 2>&1; then
|
||||
command kubectl "$@"
|
||||
elif command -v k0s >/dev/null 2>&1; then
|
||||
k0s kubectl "$@"
|
||||
else
|
||||
echo "ERROR: no kubectl or k0s binary found on host" >&2
|
||||
exit 127
|
||||
fi
|
||||
}
|
||||
|
||||
_fail() {
|
||||
local rc=$?
|
||||
echo "FAILED step=${STEP} line=${BASH_LINENO[0]} rc=${rc}" >&2
|
||||
exit "${rc}"
|
||||
}
|
||||
trap _fail ERR
|
||||
|
||||
echo "[install] component=${COMPONENT} bundle={{BUNDLE_ID}} namespace=${NAMESPACE} started=$(date -Iseconds)"
|
||||
|
||||
STEP="precheck-kubectl"
|
||||
_kubectl version --client=true >/dev/null
|
||||
|
||||
STEP="precheck-credential"
|
||||
if ! _kubectl get secret "${COMPONENT}-credentials" --namespace "${NAMESPACE}" >/dev/null 2>&1; then
|
||||
echo "ERROR: secret ${COMPONENT}-credentials missing in namespace ${NAMESPACE}." >&2
|
||||
echo " The caller (deploy.nu) must create it via stdin BEFORE invoking install.sh." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
STEP="apply-manifests"
|
||||
for manifest in {{MANIFESTS_ORDER}}; do
|
||||
STEP="apply-${manifest%.yaml}"
|
||||
echo "[install] applying ${manifest}"
|
||||
_kubectl apply -f "${BUNDLE_DIR}/${manifest}"
|
||||
done
|
||||
|
||||
STEP="wait-for-pods"
|
||||
tries=0
|
||||
while [ "$tries" -lt 30 ]; do
|
||||
count=$(_kubectl get pods --namespace "${NAMESPACE}" --selector "app.kubernetes.io/name=${COMPONENT}" -o name 2>/dev/null | wc -l | tr -d ' ')
|
||||
[ "$count" -gt 0 ] && break
|
||||
tries=$((tries + 1))
|
||||
sleep 2
|
||||
done
|
||||
if [ "$count" -eq 0 ]; then
|
||||
echo "ERROR: no pods matched app.kubernetes.io/name=${COMPONENT} after 60s" >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
STEP="wait-ready"
|
||||
_kubectl wait pod \
|
||||
--namespace "${NAMESPACE}" \
|
||||
--selector "app.kubernetes.io/name=${COMPONENT}" \
|
||||
--for=condition=Ready \
|
||||
--timeout=240s
|
||||
|
||||
if [ -f "${BUNDLE_DIR}/post-install.sh" ]; then
|
||||
STEP="post-install"
|
||||
echo "[install] running post-install.sh"
|
||||
bash "${BUNDLE_DIR}/post-install.sh"
|
||||
fi
|
||||
|
||||
STEP="done"
|
||||
echo "[install] OK finished=$(date -Iseconds)"
|
||||
53
components/_renderer/common-templates/purge.sh.tmpl
Normal file
53
components/_renderer/common-templates/purge.sh.tmpl
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
#!/bin/bash
|
||||
# GENERATED — component={{COMPONENT}} bundle={{BUNDLE_ID}} op=purge
|
||||
# DESTRUCTIVE: deletes workload + pvc + secret for this component. Requires --confirm.
|
||||
# Does NOT delete the namespace (may host other components).
|
||||
set -euo pipefail
|
||||
|
||||
BUNDLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOG="${BUNDLE_DIR}/purge.log"
|
||||
NAMESPACE="{{NAMESPACE}}"
|
||||
COMPONENT="{{COMPONENT}}"
|
||||
STEP="init"
|
||||
|
||||
exec > >(tee -a "$LOG") 2>&1
|
||||
|
||||
_kubectl() {
|
||||
if command -v kubectl >/dev/null 2>&1; then
|
||||
command kubectl "$@"
|
||||
elif command -v k0s >/dev/null 2>&1; then
|
||||
k0s kubectl "$@"
|
||||
else
|
||||
echo "ERROR: no kubectl or k0s binary found on host" >&2
|
||||
exit 127
|
||||
fi
|
||||
}
|
||||
|
||||
_fail() {
|
||||
local rc=$?
|
||||
echo "FAILED step=${STEP} line=${BASH_LINENO[0]} rc=${rc}" >&2
|
||||
exit "${rc}"
|
||||
}
|
||||
trap _fail ERR
|
||||
|
||||
if [ "${1:-}" != "--confirm" ]; then
|
||||
echo "purge.sh requires --confirm. Will delete all ${COMPONENT} resources incl. PVCs in ${NAMESPACE}." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
echo "[purge] component=${COMPONENT} bundle={{BUNDLE_ID}} namespace=${NAMESPACE} started=$(date -Iseconds)"
|
||||
|
||||
STEP="delete-workload"
|
||||
_kubectl delete statefulset,deployment,service,configmap,pvc \
|
||||
--namespace "${NAMESPACE}" \
|
||||
--selector "app.kubernetes.io/name=${COMPONENT}" \
|
||||
--ignore-not-found \
|
||||
--timeout=120s
|
||||
|
||||
STEP="delete-secret"
|
||||
_kubectl delete secret "${COMPONENT}-credentials" \
|
||||
--namespace "${NAMESPACE}" \
|
||||
--ignore-not-found
|
||||
|
||||
STEP="done"
|
||||
echo "[purge] OK finished=$(date -Iseconds). Namespace ${NAMESPACE} NOT deleted."
|
||||
57
components/_renderer/common-templates/update.sh.tmpl
Normal file
57
components/_renderer/common-templates/update.sh.tmpl
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
#!/bin/bash
|
||||
# GENERATED — component={{COMPONENT}} bundle={{BUNDLE_ID}} op=update
|
||||
# Re-applies workload manifests only. Preserves namespace, pvc, secret.
|
||||
set -euo pipefail
|
||||
|
||||
BUNDLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOG="${BUNDLE_DIR}/update.log"
|
||||
NAMESPACE="{{NAMESPACE}}"
|
||||
COMPONENT="{{COMPONENT}}"
|
||||
STEP="init"
|
||||
|
||||
exec > >(tee -a "$LOG") 2>&1
|
||||
|
||||
_kubectl() {
|
||||
if command -v kubectl >/dev/null 2>&1; then
|
||||
command kubectl "$@"
|
||||
elif command -v k0s >/dev/null 2>&1; then
|
||||
k0s kubectl "$@"
|
||||
else
|
||||
echo "ERROR: no kubectl or k0s binary found on host" >&2
|
||||
exit 127
|
||||
fi
|
||||
}
|
||||
|
||||
_fail() {
|
||||
local rc=$?
|
||||
echo "FAILED step=${STEP} line=${BASH_LINENO[0]} rc=${rc}" >&2
|
||||
exit "${rc}"
|
||||
}
|
||||
trap _fail ERR
|
||||
|
||||
echo "[update] component=${COMPONENT} bundle={{BUNDLE_ID}} namespace=${NAMESPACE} started=$(date -Iseconds)"
|
||||
|
||||
STEP="precheck-namespace"
|
||||
_kubectl get namespace "${NAMESPACE}" >/dev/null
|
||||
|
||||
STEP="apply-workload"
|
||||
for manifest in {{MANIFESTS_ORDER}}; do
|
||||
# Skip namespace and pvc — update must preserve durable slices
|
||||
case "${manifest}" in
|
||||
namespace.yaml|pvc.yaml) continue ;;
|
||||
esac
|
||||
STEP="apply-${manifest%.yaml}"
|
||||
echo "[update] applying ${manifest}"
|
||||
_kubectl apply -f "${BUNDLE_DIR}/${manifest}"
|
||||
done
|
||||
|
||||
STEP="rollout"
|
||||
for kind in statefulset deployment; do
|
||||
for name in $(_kubectl get "${kind}" --namespace "${NAMESPACE}" --selector "app.kubernetes.io/name=${COMPONENT}" -o name 2>/dev/null || true); do
|
||||
STEP="rollout-${name//\//-}"
|
||||
_kubectl rollout status --namespace "${NAMESPACE}" "${name}" --timeout=240s
|
||||
done
|
||||
done
|
||||
|
||||
STEP="done"
|
||||
echo "[update] OK finished=$(date -Iseconds)"
|
||||
354
components/_renderer/deploy.nu
Normal file
354
components/_renderer/deploy.nu
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
#!/usr/bin/env nu
|
||||
# Deploy a rendered component bundle to a remote host.
|
||||
#
|
||||
# 1. Read bundle.meta.json (component, namespace, secret_name, credentials_required)
|
||||
# 2. tar.gz the bundle locally
|
||||
# 3. scp to remote:/tmp/
|
||||
# 4. ssh to extract
|
||||
# 5. For each credential in requires: build Secret YAML in-memory, pipe to
|
||||
# ssh stdin → remote kubectl apply (credentials NEVER touch disk)
|
||||
# 6. ssh to run <op>.sh, capture exit code + log
|
||||
# 7. Cleanup remote /tmp unless --debug
|
||||
#
|
||||
# Credentials are sourced from env vars matching their names.
|
||||
# Extra credentials not declared in the bundle can be injected via
|
||||
# --require-credential NAME (repeatable).
|
||||
#
|
||||
# Usage:
|
||||
# nu deploy.nu <bundle-dir> --host user@host
|
||||
# nu deploy.nu <bundle-dir> --host user@host --op update
|
||||
# nu deploy.nu <bundle-dir> --host user@host --op purge --confirm
|
||||
# nu deploy.nu <bundle-dir> --host user@host --dry-run
|
||||
# nu deploy.nu <bundle-dir> --host user@host --require-credential FORGEJO_ADMIN_PASSWORD
|
||||
|
||||
# ─── bundle metadata ──────────────────────────────────────────────────
|
||||
|
||||
def read_meta [bundle_dir: path]: nothing -> record {
|
||||
let meta_file = ($bundle_dir | path join "bundle.meta.json")
|
||||
if not ($meta_file | path exists) {
|
||||
error make { msg: $"bundle.meta.json missing: ($meta_file)" }
|
||||
}
|
||||
open $meta_file
|
||||
}
|
||||
|
||||
def resolve_credentials [
|
||||
meta: record
|
||||
extra: list<string>
|
||||
file_values: record
|
||||
]: nothing -> list<string> {
|
||||
let file_keys = ($file_values | columns)
|
||||
$meta.credentials_required ++ $extra ++ $file_keys | uniq
|
||||
}
|
||||
|
||||
def collect_credential_values [
|
||||
names: list<string>
|
||||
file_values: record
|
||||
]: nothing -> record {
|
||||
let file_keys = ($file_values | columns)
|
||||
$names | reduce --fold {} {|name, acc|
|
||||
let v = if $name in $file_keys {
|
||||
$file_values | get $name | into string
|
||||
} else {
|
||||
$env | get -o $name | default ""
|
||||
}
|
||||
if ($v | str trim | is-empty) {
|
||||
error make { msg: $"credential ($name) not found in --credentials-file nor in env var" }
|
||||
}
|
||||
$acc | insert $name $v
|
||||
}
|
||||
}
|
||||
|
||||
# ─── SOPS decryption ──────────────────────────────────────────────────
|
||||
|
||||
def decrypt_sops_file [path: path]: nothing -> record {
|
||||
if not ($path | path exists) {
|
||||
error make { msg: $"credentials file not found: ($path)" }
|
||||
}
|
||||
let r = (do { ^sops --decrypt $path } | complete)
|
||||
if $r.exit_code != 0 {
|
||||
error make { msg: $"sops decrypt failed for ($path):\n($r.stderr)" }
|
||||
}
|
||||
let ext = ($path | path parse | get extension | str downcase)
|
||||
match $ext {
|
||||
"yaml" | "yml" => ($r.stdout | from yaml)
|
||||
"json" => ($r.stdout | from json)
|
||||
"env" => (parse_env_file $r.stdout)
|
||||
_ => { error make { msg: $"unsupported credentials file extension: .($ext) — use .yaml|.yml|.json|.env" } }
|
||||
}
|
||||
}
|
||||
|
||||
def parse_env_file [content: string]: nothing -> record {
|
||||
$content
|
||||
| lines
|
||||
| where {|l| (not ($l | str starts-with "#")) and ($l | str contains "=") }
|
||||
| reduce --fold {} {|l, acc|
|
||||
let parts = ($l | split row --number 2 "=")
|
||||
$acc | insert ($parts.0 | str trim) ($parts.1 | str trim | str trim --char '"' | str trim --char "'")
|
||||
}
|
||||
}
|
||||
|
||||
# ─── secret construction ──────────────────────────────────────────────
|
||||
|
||||
def build_secret_yaml [
|
||||
secret_name: string
|
||||
namespace: string
|
||||
kv: record
|
||||
]: nothing -> string {
|
||||
let ns_doc = ({
|
||||
apiVersion: "v1"
|
||||
kind: "Namespace"
|
||||
metadata: {
|
||||
name: $namespace
|
||||
labels: { "app.kubernetes.io/managed-by": "provisioning" }
|
||||
}
|
||||
} | to yaml)
|
||||
let secret_doc = ({
|
||||
apiVersion: "v1"
|
||||
kind: "Secret"
|
||||
metadata: {
|
||||
name: $secret_name
|
||||
namespace: $namespace
|
||||
labels: {
|
||||
"app.kubernetes.io/name": ($secret_name | str replace --regex '-credentials$' "")
|
||||
"app.kubernetes.io/managed-by": "provisioning"
|
||||
}
|
||||
}
|
||||
type: "Opaque"
|
||||
stringData: $kv
|
||||
} | to yaml)
|
||||
$"($ns_doc)---\n($secret_doc)"
|
||||
}
|
||||
|
||||
# ─── remote operations ────────────────────────────────────────────────
|
||||
|
||||
def remote_archive_name [bundle_id: string]: nothing -> string {
|
||||
$"prvng-($bundle_id).tar.gz"
|
||||
}
|
||||
|
||||
def remote_workdir [bundle_id: string]: nothing -> string {
|
||||
$"/tmp/prvng-($bundle_id)"
|
||||
}
|
||||
|
||||
def pack_bundle [bundle_dir: path, bundle_id: string]: nothing -> path {
|
||||
let archive = (mktemp --tmpdir $"(remote_archive_name $bundle_id)-XXXX")
|
||||
let parent = ($bundle_dir | path dirname)
|
||||
let name = ($bundle_dir | path basename)
|
||||
let r = (do { ^tar czf $archive -C $parent $name } | complete)
|
||||
if $r.exit_code != 0 {
|
||||
error make { msg: $"tar failed: ($r.stderr)" }
|
||||
}
|
||||
$archive
|
||||
}
|
||||
|
||||
def ssh_run [host: string, cmd: string, dry_run: bool]: nothing -> record {
|
||||
if $dry_run {
|
||||
print $"(ansi dark_gray) [dry-run] ssh ($host) \"($cmd)\"(ansi reset)"
|
||||
return { exit_code: 0, stdout: "", stderr: "" }
|
||||
}
|
||||
do { ^ssh -o BatchMode=yes $host $cmd } | complete
|
||||
}
|
||||
|
||||
def ssh_pipe [host: string, cmd: string, stdin: string, dry_run: bool]: nothing -> record {
|
||||
if $dry_run {
|
||||
let preview = ($stdin | lines | length)
|
||||
print $"(ansi dark_gray) [dry-run] ($preview) lines | ssh ($host) \"($cmd)\"(ansi reset)"
|
||||
return { exit_code: 0, stdout: "", stderr: "" }
|
||||
}
|
||||
$stdin | do { ^ssh -o BatchMode=yes $host $cmd } | complete
|
||||
}
|
||||
|
||||
def scp_put [local: path, host: string, remote_path: string, dry_run: bool]: nothing -> record {
|
||||
if $dry_run {
|
||||
print $"(ansi dark_gray) [dry-run] scp ($local) ($host):($remote_path)(ansi reset)"
|
||||
return { exit_code: 0, stdout: "", stderr: "" }
|
||||
}
|
||||
do { ^scp -o BatchMode=yes $local $"($host):($remote_path)" } | complete
|
||||
}
|
||||
|
||||
def scp_get [host: string, remote_path: string, local: path, dry_run: bool]: nothing -> record {
|
||||
if $dry_run {
|
||||
print $"(ansi dark_gray) [dry-run] scp ($host):($remote_path) ($local)(ansi reset)"
|
||||
return { exit_code: 0, stdout: "", stderr: "" }
|
||||
}
|
||||
do { ^scp -o BatchMode=yes $"($host):($remote_path)" $local } | complete
|
||||
}
|
||||
|
||||
# ─── pipeline stages ──────────────────────────────────────────────────
|
||||
|
||||
def stage_upload [
|
||||
host: string
|
||||
bundle_dir: path
|
||||
meta: record
|
||||
dry_run: bool
|
||||
]: nothing -> string {
|
||||
let archive = if $dry_run { "/tmp/stub.tar.gz" } else { (pack_bundle $bundle_dir $meta.bundle_id) }
|
||||
print $"(ansi cyan)[upload](ansi reset) archive=($archive)"
|
||||
|
||||
let remote_archive = $"/tmp/(remote_archive_name $meta.bundle_id)"
|
||||
let r1 = (scp_put $archive $host $remote_archive $dry_run)
|
||||
if $r1.exit_code != 0 {
|
||||
error make { msg: $"scp failed: ($r1.stderr)" }
|
||||
}
|
||||
|
||||
let workdir = (remote_workdir $meta.bundle_id)
|
||||
let r2 = (ssh_run $host $"rm -rf ($workdir) && mkdir -p ($workdir) && tar xzf ($remote_archive) -C ($workdir) --strip-components=1" $dry_run)
|
||||
if $r2.exit_code != 0 {
|
||||
error make { msg: $"remote extract failed: ($r2.stderr)" }
|
||||
}
|
||||
if not $dry_run { rm $archive }
|
||||
$workdir
|
||||
}
|
||||
|
||||
def detect_remote_kubectl [host: string, dry_run: bool]: nothing -> string {
|
||||
if $dry_run { return "kubectl" }
|
||||
let probe = "if command -v kubectl >/dev/null 2>&1; then echo kubectl; elif command -v k0s >/dev/null 2>&1; then echo 'k0s kubectl'; else echo MISSING; fi"
|
||||
let r = (do { ^ssh -o BatchMode=yes $host $probe } | complete)
|
||||
if $r.exit_code != 0 {
|
||||
error make { msg: $"ssh probe failed on ($host): ($r.stderr)" }
|
||||
}
|
||||
let found = ($r.stdout | str trim)
|
||||
if $found == "MISSING" {
|
||||
error make { msg: $"neither kubectl nor k0s found on ($host)" }
|
||||
}
|
||||
$found
|
||||
}
|
||||
|
||||
def stage_inject_credentials [
|
||||
host: string
|
||||
meta: record
|
||||
extra_creds: list<string>
|
||||
file_values: record
|
||||
kubectl_cmd: string
|
||||
dry_run: bool
|
||||
]: nothing -> nothing {
|
||||
let creds = (resolve_credentials $meta $extra_creds $file_values)
|
||||
if ($creds | is-empty) {
|
||||
print $"(ansi yellow)[credentials](ansi reset) none declared — install.sh will fail precheck if secret missing"
|
||||
return
|
||||
}
|
||||
let file_keys = ($file_values | columns)
|
||||
let from_file = ($creds | where {|c| $c in $file_keys } | length)
|
||||
let from_env = (($creds | length) - $from_file)
|
||||
print $"(ansi cyan)[credentials](ansi reset) injecting ($creds | length) into secret ($meta.secret_name) \(from sops: ($from_file), from env: ($from_env)\)"
|
||||
# Validate credential availability even in dry-run; redact values afterwards.
|
||||
let real_kv = (collect_credential_values $creds $file_values)
|
||||
let kv = if $dry_run {
|
||||
$real_kv | transpose k v | reduce --fold {} {|row, acc| $acc | insert $row.k "<redacted>" }
|
||||
} else {
|
||||
$real_kv
|
||||
}
|
||||
let secret_yaml = (build_secret_yaml $meta.secret_name $meta.namespace $kv)
|
||||
let r = (ssh_pipe $host $"($kubectl_cmd) apply -f -" $secret_yaml $dry_run)
|
||||
if $r.exit_code != 0 {
|
||||
error make { msg: $"credential injection failed:\n($r.stderr)" }
|
||||
}
|
||||
if not $dry_run { print $" ($r.stdout | str trim)" }
|
||||
}
|
||||
|
||||
def stage_run_op [
|
||||
host: string
|
||||
workdir: string
|
||||
op: string
|
||||
confirm: bool
|
||||
dry_run: bool
|
||||
]: nothing -> record {
|
||||
let args = if $op == "purge" and $confirm { "--confirm" } else { "" }
|
||||
let cmd = $"cd ($workdir) && bash ($op).sh ($args)"
|
||||
print $"(ansi cyan)[run](ansi reset) ($op)"
|
||||
let r = (ssh_run $host $cmd $dry_run)
|
||||
if not $dry_run {
|
||||
if ($r.stdout | str trim | is-not-empty) { print $r.stdout }
|
||||
if ($r.stderr | str trim | is-not-empty) { print $"(ansi red)($r.stderr)(ansi reset)" }
|
||||
}
|
||||
$r
|
||||
}
|
||||
|
||||
def stage_fetch_log [
|
||||
host: string
|
||||
workdir: string
|
||||
op: string
|
||||
bundle_id: string
|
||||
dry_run: bool
|
||||
]: nothing -> path {
|
||||
let local_log = $"./logs/($bundle_id)-($op).log" | path expand
|
||||
mkdir ($local_log | path dirname)
|
||||
let r = (scp_get $host $"($workdir)/($op).log" $local_log $dry_run)
|
||||
if $r.exit_code != 0 {
|
||||
print $"(ansi yellow)could not fetch log: ($r.stderr)(ansi reset)"
|
||||
} else if not $dry_run {
|
||||
print $"(ansi cyan)[log](ansi reset) fetched → ($local_log)"
|
||||
}
|
||||
$local_log
|
||||
}
|
||||
|
||||
def stage_cleanup [host: string, bundle_id: string, debug: bool, dry_run: bool]: nothing -> nothing {
|
||||
if $debug {
|
||||
print $"(ansi yellow)[debug](ansi reset) remote files preserved at (remote_workdir $bundle_id)"
|
||||
return
|
||||
}
|
||||
let workdir = (remote_workdir $bundle_id)
|
||||
let archive = $"/tmp/(remote_archive_name $bundle_id)"
|
||||
let r = (ssh_run $host $"rm -rf ($workdir) ($archive)" $dry_run)
|
||||
if $r.exit_code != 0 {
|
||||
print $"(ansi yellow)cleanup warning: ($r.stderr)(ansi reset)"
|
||||
}
|
||||
}
|
||||
|
||||
# ─── entry ────────────────────────────────────────────────────────────
|
||||
|
||||
def main [
|
||||
bundle_dir: path
|
||||
--host: string
|
||||
--op: string = "install"
|
||||
--confirm
|
||||
--debug
|
||||
--dry-run
|
||||
--require-credentials: string = ""
|
||||
--credentials-file: path = ""
|
||||
]: nothing -> nothing {
|
||||
let extra_creds = if ($require_credentials | is-empty) {
|
||||
[]
|
||||
} else {
|
||||
$require_credentials | split row "," | each {|s| $s | str trim } | where {|s| $s | is-not-empty }
|
||||
}
|
||||
let file_values = if ($credentials_file | is-empty) {
|
||||
{}
|
||||
} else {
|
||||
decrypt_sops_file $credentials_file
|
||||
}
|
||||
if not ($bundle_dir | path exists) {
|
||||
error make { msg: $"bundle dir not found: ($bundle_dir)" }
|
||||
}
|
||||
if ($host | is-empty) {
|
||||
error make { msg: "--host required (user@host)" }
|
||||
}
|
||||
if $op not-in ["install", "update", "delete", "purge"] {
|
||||
error make { msg: $"unknown op: ($op). Valid: install|update|delete|purge" }
|
||||
}
|
||||
if $op == "purge" and not $confirm {
|
||||
error make { msg: "purge requires --confirm" }
|
||||
}
|
||||
|
||||
let meta = (read_meta $bundle_dir)
|
||||
print $"(ansi green_bold)deploy(ansi reset) component=($meta.component) bundle=($meta.bundle_id) host=($host) op=($op)"
|
||||
if $dry_run { print $"(ansi yellow)DRY-RUN(ansi reset) no remote state will be modified" }
|
||||
|
||||
let workdir = (stage_upload $host $bundle_dir $meta $dry_run)
|
||||
let kubectl_cmd = (detect_remote_kubectl $host $dry_run)
|
||||
if $kubectl_cmd != "kubectl" {
|
||||
print $"(ansi cyan)[kubectl](ansi reset) remote uses: ($kubectl_cmd)"
|
||||
}
|
||||
|
||||
if $op == "install" {
|
||||
stage_inject_credentials $host $meta $extra_creds $file_values $kubectl_cmd $dry_run
|
||||
}
|
||||
|
||||
let run_result = (stage_run_op $host $workdir $op $confirm $dry_run)
|
||||
stage_fetch_log $host $workdir $op $meta.bundle_id $dry_run
|
||||
stage_cleanup $host $meta.bundle_id $debug $dry_run
|
||||
|
||||
if $run_result.exit_code != 0 {
|
||||
error make { msg: $"op ($op) failed on ($host) with exit ($run_result.exit_code)" }
|
||||
}
|
||||
print $"(ansi green_bold)OK(ansi reset) ($op) completed on ($host)"
|
||||
}
|
||||
247
components/_renderer/lib/COMPONENT_CONTRACT.md
Normal file
247
components/_renderer/lib/COMPONENT_CONTRACT.md
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
# Catalog component install script contract
|
||||
|
||||
Every shell script under `provisioning/catalog/components/<name>/cluster/install-<name>.sh`
|
||||
(and the equivalent for taskserv components) MUST follow this contract so
|
||||
operators get uniform error capture, flag handling, and diagnostic output
|
||||
across all components. The contract is checked by
|
||||
`provisioning/catalog/components/_renderer/lib/check-contract.sh`.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Before this contract, each component's install script invented its own way
|
||||
of doing the same five things:
|
||||
|
||||
1. Resolving operator flags from env vars (some hardcoded, some absent).
|
||||
2. Reporting which phase failed (often: silent until kubectl exits).
|
||||
3. Dumping diagnostic context on failure (often: nothing — operator had
|
||||
to ssh in and run `kubectl describe` by hand).
|
||||
4. Suppressing routine output ("unchanged"/"configured") in normal use.
|
||||
5. Force-recreating a workload when k0s/k3s rollout hangs.
|
||||
|
||||
Each component duplicating these is brittle. The contract centralises them
|
||||
in `diagnostics.sh` (sourced once) and demands every install script use it.
|
||||
|
||||
## Required clauses
|
||||
|
||||
Every `install-<name>.sh` MUST contain ALL of the following. The order is
|
||||
prescriptive — `set -euo pipefail` first, source second, flag resolution
|
||||
third, kubeconfig resolution fourth, workload registration + ERR trap fifth,
|
||||
phase functions sixth, dispatcher last.
|
||||
|
||||
### R1 — Strict shell + set guards (MUST)
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
```
|
||||
|
||||
### R2 — Source diagnostics.sh (MUST)
|
||||
|
||||
```bash
|
||||
# shellcheck source=/dev/null
|
||||
[ -f "${SCRIPT_DIR}/diagnostics.sh" ] && source "${SCRIPT_DIR}/diagnostics.sh"
|
||||
```
|
||||
|
||||
The bundle ships `diagnostics.sh` alongside the install script. Sourcing is
|
||||
guarded so a hand-run from a partial bundle still works (with degraded
|
||||
diagnostics — the local fallbacks in R3 cover that path).
|
||||
|
||||
### R3 — Resolve operator flags (MUST)
|
||||
|
||||
Read `PROVISIONING_VERBOSE`/`PRVNG_VERBOSE`, `PROVISIONING_FORCE`/`PRVNG_FORCE`,
|
||||
and `PROVISIONING_DEBUG`/`PRVNG_DEBUG`. Prefer the helpers from `diagnostics.sh`,
|
||||
fall back to a local truthy check when the lib is absent:
|
||||
|
||||
```bash
|
||||
if command -v _diag_is_force >/dev/null 2>&1; then
|
||||
_diag_is_force && FORCE=true || FORCE=false
|
||||
_diag_is_debug && DEBUG=true || DEBUG=false
|
||||
_diag_is_verbose && VERBOSE=true || VERBOSE=false
|
||||
else
|
||||
# Local fallback (one case per flag — keep verbatim for the linter).
|
||||
case "$(printf '%s' "${PROVISIONING_FORCE:-${PRVNG_FORCE:-}}" | tr '[:upper:]' '[:lower:]')" in
|
||||
1|true|yes) FORCE=true ;; *) FORCE=false ;;
|
||||
esac
|
||||
case "$(printf '%s' "${PROVISIONING_DEBUG:-${PRVNG_DEBUG:-}}" | tr '[:upper:]' '[:lower:]')" in
|
||||
1|true|yes) DEBUG=true ;; *) DEBUG=false ;;
|
||||
esac
|
||||
case "$(printf '%s' "${PROVISIONING_VERBOSE:-${PRVNG_VERBOSE:-}}" | tr '[:upper:]' '[:lower:]')" in
|
||||
1|true|yes) VERBOSE=true ;; *) VERBOSE=false ;;
|
||||
esac
|
||||
fi
|
||||
```
|
||||
|
||||
### R4 — Define `_log` and `_apply_stdin` (MUST)
|
||||
|
||||
Step markers ALWAYS show. Kubectl apply output is gated by `VERBOSE`. Errors
|
||||
are NEVER hidden — `kubectl apply` non-zero exit prints stderr unconditionally.
|
||||
|
||||
```bash
|
||||
_log() {
|
||||
printf ' → %s\n' "$*"
|
||||
}
|
||||
_apply_stdin() {
|
||||
if [ "$VERBOSE" = "true" ]; then
|
||||
_kubectl apply -f -
|
||||
return $?
|
||||
fi
|
||||
local out rc
|
||||
out=$(_kubectl apply -f - 2>&1)
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
printf '%s\n' "$out" >&2
|
||||
fi
|
||||
return $rc
|
||||
}
|
||||
```
|
||||
|
||||
Use `_apply_stdin` (NOT bare `_kubectl apply -f -`) for ALL manifest applies.
|
||||
Use `_log "<phase>"` (NOT bare `echo " → ..."`) for ALL phase markers.
|
||||
|
||||
### R5 — Register the workload + install the ERR trap (MUST)
|
||||
|
||||
For cluster components (after `_resolve_kubeconfig`):
|
||||
|
||||
```bash
|
||||
if command -v _diag_register_cluster_workload >/dev/null 2>&1; then
|
||||
_diag_register_cluster_workload deployment "$<COMPONENT>_NAME" "$<COMPONENT>_NAMESPACE" \
|
||||
"app.kubernetes.io/name=${<COMPONENT>_NAME}"
|
||||
_diag_install_err_trap
|
||||
fi
|
||||
```
|
||||
|
||||
Kind can be `deployment`, `statefulset`, `daemonset`, or `cronjob`. Pick the
|
||||
one that owns the pods.
|
||||
|
||||
For taskserv components:
|
||||
|
||||
```bash
|
||||
if command -v _diag_register_systemd_unit >/dev/null 2>&1; then
|
||||
_diag_register_systemd_unit "<component>.service"
|
||||
_diag_install_err_trap
|
||||
fi
|
||||
```
|
||||
|
||||
### R6 — Honour `FORCE` when re-applying Deployments/StatefulSets (MUST)
|
||||
|
||||
Wrap the workload apply (Deployment/StatefulSet) so that, when `FORCE=true`,
|
||||
the existing resource is deleted before apply. Skip for stateless resources
|
||||
(Service, ConfigMap, NetworkPolicy) — apply alone is fine there.
|
||||
|
||||
```bash
|
||||
_apply_deployment_and_service() {
|
||||
if [ "$FORCE" = "true" ]; then
|
||||
if command -v _diag_force_delete_workload >/dev/null 2>&1; then
|
||||
_diag_force_delete_workload deployment "$NAME" "$NAMESPACE" 30
|
||||
else
|
||||
_kubectl delete deployment "$NAME" --namespace "$NAMESPACE" \
|
||||
--wait=true --timeout=30s --ignore-not-found 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
_apply_stdin <<MANIFEST
|
||||
...
|
||||
MANIFEST
|
||||
}
|
||||
```
|
||||
|
||||
### R7 — Wait via `_diag_wait_for_*` (MUST)
|
||||
|
||||
The rollout wait MUST use the diagnostic helpers, which auto-dump pod
|
||||
logs/describe on failure. Bare `kubectl rollout status` is NOT acceptable
|
||||
because it produces only `exceeded its progress deadline` — no context.
|
||||
|
||||
```bash
|
||||
if command -v _diag_wait_for_deployment >/dev/null 2>&1; then
|
||||
_diag_wait_for_deployment "$NAME" "$NAMESPACE" 180
|
||||
else
|
||||
_kubectl rollout status deployment/"$NAME" --namespace "$NAMESPACE" --timeout=180s
|
||||
fi
|
||||
```
|
||||
|
||||
### R8 — Never write secrets to disk (MUST)
|
||||
|
||||
Use `kubectl create secret … --from-literal=… --dry-run=client -o yaml |
|
||||
_apply_stdin`. The `_credentials.env` file is the sole on-disk surface for
|
||||
plaintext secrets, and it lives only in a `mode 0700` bundle dir, `mode
|
||||
0600` itself, removed after the op completes (unless `DEBUG=true`).
|
||||
|
||||
NEVER:
|
||||
- Write rendered Secret YAML to disk.
|
||||
- `echo "$SECRET" > /some/file`.
|
||||
- Embed plaintext in a heredoc that's later `kubectl apply -f` from a path.
|
||||
|
||||
### R9 — Local validation before apply (SHOULD)
|
||||
|
||||
If a manifest references structured user-supplied data (JSON access policy,
|
||||
YAML config, etc.), validate it locally BEFORE building the manifest. A bad
|
||||
input that reaches the cluster as a ConfigMap will manifest as a
|
||||
CrashLoopBackOff inside the workload — much harder to diagnose than a
|
||||
local `jq -e` rejection.
|
||||
|
||||
```bash
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
local err
|
||||
if ! err=$(printf '%s' "$ACCESS_POLICY_JSON" | jq -e . 2>&1 >/dev/null); then
|
||||
echo "ERROR: access-policy JSON failed local validation — refusing to apply." >&2
|
||||
echo " jq: $err" >&2
|
||||
[ "$DEBUG" = "true" ] && printf ' raw: %s\n' "$ACCESS_POLICY_JSON" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
### R10 — Dispatcher accepts `install|update|delete|purge|restart|health` (MUST)
|
||||
|
||||
```bash
|
||||
case "$CMD_TSK" in
|
||||
install) _do_install ;;
|
||||
update) _do_update ;;
|
||||
delete) _do_delete ;;
|
||||
purge) shift; _do_purge "${1:-}" ;;
|
||||
health) _do_health ;;
|
||||
restart) _do_restart ;;
|
||||
*)
|
||||
echo "ERROR: unknown CMD_TSK='$CMD_TSK'. Valid: install|update|delete|purge|health|restart" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
```
|
||||
|
||||
`purge` MUST require `--confirm`.
|
||||
|
||||
## Linter
|
||||
|
||||
`provisioning/catalog/components/_renderer/lib/check-contract.sh` scans
|
||||
every component's install script and reports R1..R10 compliance. Run via:
|
||||
|
||||
```bash
|
||||
bash provisioning/catalog/components/_renderer/lib/check-contract.sh
|
||||
# OR (from any workspace that imports the libre-forge deploy.just shape):
|
||||
just check-component-contract
|
||||
```
|
||||
|
||||
Exit code 0 = all components compliant. Exit code 1 = at least one
|
||||
violation. Reports per-component per-clause PASS/FAIL.
|
||||
|
||||
## Adding a new component
|
||||
|
||||
Copy `provisioning/catalog/components/fleet_daemon/` as a template — it is
|
||||
the reference implementation of this contract. Then:
|
||||
|
||||
1. Rename `install-fleet_daemon.sh` → `install-<your-name>.sh`.
|
||||
2. Rename `fleet_daemon-lib.sh` → `<your-name>-lib.sh` (keep its existence).
|
||||
3. Adjust the registered workload kind/name/namespace in the R5 block.
|
||||
4. Rewrite the phase functions for your manifest set.
|
||||
5. Run the linter — fix every FAIL it reports.
|
||||
6. Add a workspace `just <component>-up/down/update/stat` recipe following
|
||||
`libre-forge/justfiles/deploy.just::_fleet-daemon-op` as the template.
|
||||
|
||||
## See also
|
||||
|
||||
- `FLAGS.md` — what each flag does, combinability, examples.
|
||||
- `diagnostics.sh` — the implementation of every `_diag_*` helper.
|
||||
- `provisioning/catalog/components/fleet_daemon/cluster/install-fleet_daemon.sh`
|
||||
— reference implementation.
|
||||
96
components/_renderer/lib/FLAGS.md
Normal file
96
components/_renderer/lib/FLAGS.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# Operator flags — catalog component ops
|
||||
|
||||
Cross-component env vars that govern any catalog component op (cluster or
|
||||
taskserv). They are read by `diagnostics.sh` (sourced by every component's
|
||||
install script) and propagated via the workspace `just` recipe into the
|
||||
deploy bundle's `_credentials.env` (so the same flag works from your shell
|
||||
or from a recipe).
|
||||
|
||||
Each flag has a canonical name (`PROVISIONING_*`) and a short alias
|
||||
(`PRVNG_*`). Both accept `1`, `true`, `yes` (case-insensitive) as truthy;
|
||||
anything else is falsy.
|
||||
|
||||
## Flags
|
||||
|
||||
| Canonical | Alias | Effect |
|
||||
|------------------------|----------------|--------|
|
||||
| `PROVISIONING_VERBOSE` | `PRVNG_VERBOSE`| Stream every `kubectl apply`/`scp`/`ssh` line. Off by default (only the `→ phase` step markers, warnings, errors, and the final summary are shown). |
|
||||
| `PROVISIONING_FORCE` | `PRVNG_FORCE` | Delete the workload (Deployment/StatefulSet) before re-applying, instead of relying on rollout. Use when a k0s/k3s/RKE2 rollout hangs or pods get stuck pre-Ready. Drastic but reliable. |
|
||||
| `PROVISIONING_DEBUG` | `PRVNG_DEBUG` | Preserve the deploy bundle (local `mktemp` + remote `/tmp/<component>-bundle/`) on **both success and failure**. Also prints raw values (e.g. malformed JSON) inside error messages that otherwise stay structured. |
|
||||
|
||||
## Combinability
|
||||
|
||||
Flags are independent — combine freely:
|
||||
|
||||
```bash
|
||||
# default (silent except for phase markers + errors)
|
||||
just fleet-daemon-up
|
||||
|
||||
# every kubectl line (debug a manifest)
|
||||
PRVNG_VERBOSE=true just fleet-daemon-up
|
||||
|
||||
# delete + apply (skip the rollout dance on k0s)
|
||||
PRVNG_FORCE=true just fleet-daemon-up
|
||||
|
||||
# preserve /tmp bundle for post-mortem inspection
|
||||
PRVNG_DEBUG=true just fleet-daemon-up
|
||||
|
||||
# verbose + force + preserve — full transparency
|
||||
PRVNG_VERBOSE=true PRVNG_FORCE=true PRVNG_DEBUG=true just fleet-daemon-up
|
||||
|
||||
# canonical names work identically
|
||||
PROVISIONING_DEBUG=true PROVISIONING_FORCE=true just fleet-daemon-up
|
||||
```
|
||||
|
||||
## What's always shown
|
||||
|
||||
Independent of flags, the install script ALWAYS prints:
|
||||
|
||||
- Phase step markers (`→ namespace + service account`, `→ secrets`, ...).
|
||||
- Warnings (`WARN: PrometheusRule CRD not installed — skipping ...`).
|
||||
- Errors (any non-zero `kubectl` exit prints stderr).
|
||||
- The final success/failure summary block.
|
||||
- A diagnostic post-mortem (`describe` or `logs --tail=N --previous`) when
|
||||
the workload fails to become ready. Smart selection: if pod logs are
|
||||
accessible the daemon HAS started and logs are the source of truth, so
|
||||
describe is skipped. If logs are unavailable, describe + events are
|
||||
shown (image pull failures, scheduling errors, etc.).
|
||||
|
||||
## Bundle layout (what gets shipped)
|
||||
|
||||
Every `kube_workload` component renders a single tarball, scp'd once to the
|
||||
operator host. Contents:
|
||||
|
||||
```
|
||||
<component>-bundle/
|
||||
├── install-<component>.sh ← entry point (bash)
|
||||
├── <component>-lib.sh ← component-specific helpers
|
||||
├── diagnostics.sh ← THIS lib (ERR trap + flag helpers + force helpers)
|
||||
└── _credentials.env ← sops-decrypted secrets + flag propagation (mode 0600)
|
||||
```
|
||||
|
||||
The bundle is `chmod 0700` on disk. The credentials file is `chmod 0600`.
|
||||
Both are removed after the op completes (unless `PRVNG_DEBUG=true`, in which
|
||||
case they're kept at:
|
||||
|
||||
- local : `/var/folders/.../tmp.XXXXXX` (path printed at end of run)
|
||||
- remote: `/tmp/<component>-bundle/`).
|
||||
|
||||
Secrets never touch disk via `--from-literal=` + `kubectl apply -f -` from
|
||||
stdin — the bundle's `_credentials.env` carries them, but the install script
|
||||
sources it into env vars and pipes Secret manifests through stdin to the
|
||||
cluster, never writing materialised Secret YAML to the filesystem.
|
||||
|
||||
## Where this is wired
|
||||
|
||||
- Library: `provisioning/catalog/components/_renderer/lib/diagnostics.sh`
|
||||
- Per-component install scripts source it via:
|
||||
```bash
|
||||
source "${SCRIPT_DIR}/diagnostics.sh"
|
||||
```
|
||||
- Just recipes propagate flags into the bundle via `_credentials.env`,
|
||||
see e.g. `libre-forge/justfiles/deploy.just::_fleet-daemon-op`.
|
||||
- Display this file via the `flags-help` recipe in any workspace:
|
||||
```bash
|
||||
just flags-help
|
||||
```
|
||||
236
components/_renderer/lib/check-contract.sh
Executable file
236
components/_renderer/lib/check-contract.sh
Executable file
|
|
@ -0,0 +1,236 @@
|
|||
#!/bin/bash
|
||||
# Linter for the catalog component install-script contract.
|
||||
# Scans every provisioning/catalog/components/<name>/cluster/install-<name>.sh
|
||||
# and reports compliance with R1..R10 declared in COMPONENT_CONTRACT.md.
|
||||
#
|
||||
# Usage:
|
||||
# bash check-contract.sh # scan all components
|
||||
# bash check-contract.sh fleet_daemon nats # scan specific components
|
||||
# bash check-contract.sh --quiet # only print failures
|
||||
#
|
||||
# Exit code:
|
||||
# 0 — all scanned components compliant
|
||||
# 1 — at least one violation
|
||||
# 2 — invocation error (paths, etc.)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
COMPONENTS_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
# ── arg parsing ──────────────────────────────────────────────────────────
|
||||
quiet=false
|
||||
declare -a names=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--quiet|-q) quiet=true ;;
|
||||
-h|--help)
|
||||
sed -n '2,15p' "$0" | sed 's/^# \?//'
|
||||
exit 0
|
||||
;;
|
||||
--*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
*) names+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ${#names[@]} -eq 0 ]; then
|
||||
# Discover every component that has a cluster/install-*.sh.
|
||||
while IFS= read -r script; do
|
||||
comp="$(basename "$(dirname "$(dirname "$script")")")"
|
||||
# Skip pseudo-components (helpers, not real components).
|
||||
case "$comp" in _renderer|MIGRATION_CONCERNS.md) continue ;; esac
|
||||
names+=("$comp")
|
||||
done < <(find "$COMPONENTS_DIR" -mindepth 3 -maxdepth 3 -type f -name "install-*.sh" 2>/dev/null | sort)
|
||||
fi
|
||||
|
||||
# ── clause checks ────────────────────────────────────────────────────────
|
||||
# Each clause is a function clause_RN <script_path> → exit 0 (PASS) or 1 (FAIL).
|
||||
# Stdout from a failing clause is the human-readable reason.
|
||||
|
||||
clause_R1() { # strict shell + set -euo pipefail + SCRIPT_DIR
|
||||
grep -q '^#!/bin/bash' "$1" || { echo "missing #!/bin/bash shebang"; return 1; }
|
||||
grep -q 'set -euo pipefail' "$1" || { echo "missing 'set -euo pipefail'"; return 1; }
|
||||
grep -q 'SCRIPT_DIR=' "$1" || { echo "missing SCRIPT_DIR resolution"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
clause_R2() { # source diagnostics.sh
|
||||
grep -qE 'source +"\$\{?SCRIPT_DIR\}?/diagnostics\.sh"' "$1" \
|
||||
|| { echo "does not source diagnostics.sh"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
clause_R3() { # flag resolution
|
||||
grep -q 'PROVISIONING_FORCE' "$1" || { echo "missing PROVISIONING_FORCE handling"; return 1; }
|
||||
grep -q 'PROVISIONING_DEBUG' "$1" || { echo "missing PROVISIONING_DEBUG handling"; return 1; }
|
||||
grep -q 'PROVISIONING_VERBOSE' "$1" || { echo "missing PROVISIONING_VERBOSE handling"; return 1; }
|
||||
grep -q 'PRVNG_FORCE' "$1" || { echo "missing PRVNG_FORCE alias"; return 1; }
|
||||
grep -q 'PRVNG_DEBUG' "$1" || { echo "missing PRVNG_DEBUG alias"; return 1; }
|
||||
grep -q 'PRVNG_VERBOSE' "$1" || { echo "missing PRVNG_VERBOSE alias"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
clause_R4() { # _log + _apply_stdin defined; no bare _kubectl apply outside helper body
|
||||
grep -qE '^_log\(\)' "$1" || { echo "missing _log() helper definition"; return 1; }
|
||||
grep -qE '^_apply_stdin\(\)' "$1" || { echo "missing _apply_stdin() helper definition"; return 1; }
|
||||
# awk tracks brace depth from `_apply_stdin() {` to its matching `}`, so we
|
||||
# can scan ONLY the lines outside that function for bare `_kubectl apply`.
|
||||
local violators
|
||||
violators=$(awk '
|
||||
/^_apply_stdin\(\) *\{/ { inside = 1; depth = 1; next }
|
||||
inside {
|
||||
n = gsub(/\{/, "{")
|
||||
m = gsub(/\}/, "}")
|
||||
depth += n - m
|
||||
if (depth <= 0) { inside = 0 }
|
||||
next
|
||||
}
|
||||
/_kubectl[[:space:]]+apply[[:space:]]+-f[[:space:]]+-/ { printf(" line %d: %s\n", NR, $0) }
|
||||
' "$1")
|
||||
if [ -n "$violators" ]; then
|
||||
echo "bare '_kubectl apply -f -' found outside _apply_stdin body — must use _apply_stdin instead:"
|
||||
printf '%s\n' "$violators"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
clause_R5() { # register workload + install ERR trap
|
||||
grep -qE '_diag_register_(cluster_workload|systemd_unit)' "$1" \
|
||||
|| { echo "missing _diag_register_cluster_workload/systemd_unit call"; return 1; }
|
||||
grep -q '_diag_install_err_trap' "$1" \
|
||||
|| { echo "missing _diag_install_err_trap call"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
clause_R6() { # FORCE delete branch for deployments / statefulsets
|
||||
# Soft check: presence of FORCE-conditional delete somewhere in the script.
|
||||
# Skip if the script has no Deployment/StatefulSet/DaemonSet at all
|
||||
# (CronJob-only components don't need this clause).
|
||||
if ! grep -qE 'kind: +(Deployment|StatefulSet|DaemonSet)' "$1"; then
|
||||
return 0
|
||||
fi
|
||||
if grep -qE 'FORCE.*=.*true' "$1" && \
|
||||
grep -qE '(_diag_force_delete_workload|_kubectl +delete +(deployment|statefulset|daemonset))' "$1"; then
|
||||
return 0
|
||||
fi
|
||||
echo "Deployment/StatefulSet/DaemonSet present but no FORCE delete branch"
|
||||
return 1
|
||||
}
|
||||
|
||||
clause_R7() { # _diag_wait_for_* used instead of bare rollout status
|
||||
# Skip if no Deployment/StatefulSet/DaemonSet (nothing to roll out).
|
||||
if ! grep -qE 'kind: +(Deployment|StatefulSet|DaemonSet)' "$1"; then
|
||||
return 0
|
||||
fi
|
||||
# The wrapper may live in the sibling <name>-lib.sh — scan both.
|
||||
local lib="${1%/install-*.sh}/$(basename "${1%.*}" | sed 's/^install-//')-lib.sh"
|
||||
if grep -q '_diag_wait_for_' "$1"; then return 0; fi
|
||||
if [ -f "$lib" ] && grep -q '_diag_wait_for_' "$lib"; then return 0; fi
|
||||
echo "missing _diag_wait_for_* (in install or <name>-lib.sh) — bare rollout loses diagnostic context"
|
||||
return 1
|
||||
}
|
||||
|
||||
clause_R8() { # never write secrets to disk
|
||||
# Heuristic: a SECRET-suffixed env var being redirected to a file path.
|
||||
# Targets: `... > /path`, `... >> /path`, `tee /path`, `cat >file <<<`.
|
||||
# Excludes: status echoes/printfs without redirection, --from-literal=,
|
||||
# kubectl create secret pipelines, and heredoc string-builder printf's.
|
||||
local violators
|
||||
violators=$(awk '
|
||||
# Skip lines that are inside a kubectl create secret pipeline (heuristic).
|
||||
/kubectl[[:space:]]+create[[:space:]]+secret/ { next }
|
||||
/--from-literal=/ { next }
|
||||
# File redirection patterns with $SECRET, $TOKEN, $SEED, $KEY var names.
|
||||
/(\>|\>\>|tee[[:space:]]+)[[:space:]]*\/[A-Za-z0-9._\/-]+/ \
|
||||
&& /\$\{?[A-Z_]*(SECRET|TOKEN|SEED|PASSWORD|PRIVATE|HTPASSWD)/ {
|
||||
printf(" line %d: %s\n", NR, $0)
|
||||
}
|
||||
' "$1")
|
||||
if [ -n "$violators" ]; then
|
||||
echo "potential secret write to disk detected:"
|
||||
printf '%s\n' "$violators"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
clause_R10() { # dispatcher with full op set
|
||||
grep -q 'case "\$CMD_TSK"' "$1" || { echo "missing CMD_TSK dispatcher (case statement)"; return 1; }
|
||||
for op in install update delete purge health restart; do
|
||||
grep -qE "^[[:space:]]*${op}\)" "$1" || { echo "dispatcher missing branch for op: $op"; return 1; }
|
||||
done
|
||||
grep -q 'purge requires --confirm' "$1" || { echo "purge branch must guard with --confirm check"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── runner ───────────────────────────────────────────────────────────────
|
||||
declare -A CLAUSES=(
|
||||
[R1]="strict shell + set guards"
|
||||
[R2]="sources diagnostics.sh"
|
||||
[R3]="flag resolution (PROVISIONING_/PRVNG_)"
|
||||
[R4]="_log + _apply_stdin defined; no bare _kubectl apply"
|
||||
[R5]="workload registration + ERR trap installed"
|
||||
[R6]="FORCE delete branch (cluster workloads)"
|
||||
[R7]="_diag_wait_for_* (not bare rollout status)"
|
||||
[R8]="no secrets written to disk"
|
||||
[R10]="dispatcher: install|update|delete|purge|health|restart + --confirm on purge"
|
||||
)
|
||||
CLAUSE_ORDER=(R1 R2 R3 R4 R5 R6 R7 R8 R10)
|
||||
|
||||
total=0
|
||||
total_failed=0
|
||||
total_pass=0
|
||||
declare -a failed_components=()
|
||||
|
||||
color_ok="$(printf '\033[32m')"
|
||||
color_fail="$(printf '\033[31m')"
|
||||
color_reset="$(printf '\033[0m')"
|
||||
color_dim="$(printf '\033[2m')"
|
||||
|
||||
for comp in "${names[@]}"; do
|
||||
script="${COMPONENTS_DIR}/${comp}/cluster/install-${comp}.sh"
|
||||
if [ ! -f "$script" ]; then
|
||||
echo "${color_fail}[skip]${color_reset} ${comp}: no install-${comp}.sh found" >&2
|
||||
continue
|
||||
fi
|
||||
total=$((total + 1))
|
||||
comp_failed=0
|
||||
declare -a comp_lines=()
|
||||
for cid in "${CLAUSE_ORDER[@]}"; do
|
||||
reason=$("clause_${cid}" "$script" 2>&1) || {
|
||||
comp_lines+=(" ${color_fail}✗ ${cid}${color_reset} ${CLAUSES[$cid]}")
|
||||
if [ -n "$reason" ]; then
|
||||
comp_lines+=(" ${color_dim}${reason}${color_reset}")
|
||||
fi
|
||||
comp_failed=1
|
||||
continue
|
||||
}
|
||||
if [ "$quiet" = "false" ]; then
|
||||
comp_lines+=(" ${color_ok}✓ ${cid}${color_reset} ${CLAUSES[$cid]}")
|
||||
fi
|
||||
done
|
||||
if [ $comp_failed -eq 0 ]; then
|
||||
total_pass=$((total_pass + 1))
|
||||
if [ "$quiet" = "false" ]; then
|
||||
echo "${color_ok}${comp}${color_reset}: all clauses pass"
|
||||
printf '%s\n' "${comp_lines[@]}"
|
||||
fi
|
||||
else
|
||||
total_failed=$((total_failed + 1))
|
||||
failed_components+=("$comp")
|
||||
echo "${color_fail}${comp}${color_reset}: ${comp_failed} clause(s) failed"
|
||||
printf '%s\n' "${comp_lines[@]}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Summary: ${total} component(s) scanned, ${color_ok}${total_pass} pass${color_reset}, ${color_fail}${total_failed} fail${color_reset}"
|
||||
if [ $total_failed -gt 0 ]; then
|
||||
echo ""
|
||||
echo "Failing components: ${failed_components[*]}"
|
||||
echo "Run with --quiet to see only failures."
|
||||
echo "See COMPONENT_CONTRACT.md for the full clause specification."
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
306
components/_renderer/lib/diagnostics.sh
Normal file
306
components/_renderer/lib/diagnostics.sh
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
#!/bin/bash
|
||||
# Cross-component diagnostic helpers for catalog install/update/delete scripts.
|
||||
# Sourced by per-component install scripts (cluster + taskserv modes).
|
||||
#
|
||||
# Usage in install-<name>.sh:
|
||||
#
|
||||
# source "${SCRIPT_DIR}/diagnostics.sh"
|
||||
# _diag_install_err_trap # auto-dump on ERR
|
||||
# _diag_register_cluster_workload deployment fleet-daemon fleet-system \
|
||||
# "app.kubernetes.io/name=fleet-daemon"
|
||||
# _diag_wait_for_deployment "$NAME" "$NAMESPACE" 180 # use this, not bare rollout
|
||||
#
|
||||
# Or for taskserv:
|
||||
# _diag_register_systemd_unit fleet-agent.service
|
||||
# _diag_install_err_trap
|
||||
# ...do work...
|
||||
#
|
||||
# Both helpers assume _kubectl exists in the parent script (cluster mode) or
|
||||
# systemctl/journalctl are on PATH (taskserv mode).
|
||||
|
||||
set -o pipefail
|
||||
|
||||
# ── env flag resolution (canonical + PRVNG_ alias) ──────────────────────
|
||||
# Use these helpers in component install.sh:
|
||||
# if _diag_is_force; then ... ; fi
|
||||
# if _diag_is_debug; then ... ; fi
|
||||
# Both accept "1", "true", "yes" (case-insensitive) as truthy.
|
||||
|
||||
_diag_env_truthy() {
|
||||
local raw="${1:-}"
|
||||
case "$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" in
|
||||
1|true|yes) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
_diag_is_debug() {
|
||||
_diag_env_truthy "${PROVISIONING_DEBUG:-${PRVNG_DEBUG:-}}"
|
||||
}
|
||||
|
||||
_diag_is_force() {
|
||||
_diag_env_truthy "${PROVISIONING_FORCE:-${PRVNG_FORCE:-}}"
|
||||
}
|
||||
|
||||
_diag_is_verbose() {
|
||||
_diag_env_truthy "${PROVISIONING_VERBOSE:-${PRVNG_VERBOSE:-}}"
|
||||
}
|
||||
|
||||
# Step echo gated by verbose. In quiet mode, only major milestones print.
|
||||
_diag_log_step() {
|
||||
if _diag_is_verbose; then
|
||||
echo " → $*"
|
||||
fi
|
||||
}
|
||||
|
||||
# Run a kubectl-or-similar command, gating output by verbose:
|
||||
# - verbose: stream output as usual
|
||||
# - quiet : capture output, drop on success, print on failure (so errors
|
||||
# are never hidden, while routine "unchanged" lines disappear)
|
||||
_diag_run_quiet() {
|
||||
if _diag_is_verbose; then
|
||||
"$@"
|
||||
return $?
|
||||
fi
|
||||
local out rc
|
||||
out=$("$@" 2>&1)
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
printf '%s\n' "$out" >&2
|
||||
fi
|
||||
return $rc
|
||||
}
|
||||
|
||||
# Wrap a kubectl-apply-via-stdin invocation, gating output by verbose.
|
||||
# Usage:
|
||||
# echo "$manifest" | _diag_apply_quiet -f -
|
||||
# Equivalent to: _kubectl apply -f - with verbose-aware output.
|
||||
_diag_apply_quiet() {
|
||||
if _diag_is_verbose; then
|
||||
_kubectl apply "$@"
|
||||
return $?
|
||||
fi
|
||||
local out rc
|
||||
out=$(_kubectl apply "$@" 2>&1)
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
printf '%s\n' "$out" >&2
|
||||
fi
|
||||
return $rc
|
||||
}
|
||||
|
||||
# Force-recreate a kube workload: delete (wait for termination, with timeout)
|
||||
# then return success so the caller can re-apply. Idempotent — no-op when the
|
||||
# workload doesn't exist. Use BEFORE `kubectl apply` of the same resource:
|
||||
#
|
||||
# if _diag_is_force; then
|
||||
# _diag_force_delete_workload deployment fleet-daemon fleet-system
|
||||
# fi
|
||||
# _kubectl apply -f - <<MANIFEST
|
||||
# ...
|
||||
#
|
||||
# Requires _kubectl to be defined in the parent script (cluster mode).
|
||||
_diag_force_delete_workload() {
|
||||
local kind="$1" name="$2" namespace="$3" timeout="${4:-30}"
|
||||
if ! _kubectl get "$kind" "$name" --namespace "$namespace" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
echo " [force] deleting ${kind}/${name} before re-apply (timeout ${timeout}s)" >&2
|
||||
_kubectl delete "$kind" "$name" \
|
||||
--namespace "$namespace" \
|
||||
--wait=true --timeout="${timeout}s" --ignore-not-found 2>&1 || {
|
||||
echo " [force] WARN: delete did not complete cleanly within ${timeout}s — proceeding with apply anyway" >&2
|
||||
}
|
||||
}
|
||||
|
||||
# ── workload registration (used by ERR trap) ────────────────────────────
|
||||
_DIAG_KIND="" # 'cluster' or 'taskserv'
|
||||
_DIAG_K8S_KIND="" # deployment|statefulset|daemonset|cronjob|job
|
||||
_DIAG_K8S_NAME=""
|
||||
_DIAG_K8S_NAMESPACE=""
|
||||
_DIAG_K8S_SELECTOR=""
|
||||
_DIAG_SYSTEMD_UNIT=""
|
||||
_DIAG_TAIL="${_DIAG_TAIL:-60}"
|
||||
|
||||
_diag_register_cluster_workload() {
|
||||
_DIAG_KIND="cluster"
|
||||
_DIAG_K8S_KIND="$1"
|
||||
_DIAG_K8S_NAME="$2"
|
||||
_DIAG_K8S_NAMESPACE="$3"
|
||||
_DIAG_K8S_SELECTOR="${4:-app.kubernetes.io/name=$2}"
|
||||
}
|
||||
|
||||
_diag_register_systemd_unit() {
|
||||
_DIAG_KIND="taskserv"
|
||||
_DIAG_SYSTEMD_UNIT="$1"
|
||||
}
|
||||
|
||||
# ── ERR trap installation ───────────────────────────────────────────────
|
||||
_diag_install_err_trap() {
|
||||
trap '_diag_on_error $? "$BASH_COMMAND" $LINENO' ERR
|
||||
}
|
||||
|
||||
_diag_on_error() {
|
||||
local rc="$1" cmd="$2" lineno="$3"
|
||||
{
|
||||
echo ""
|
||||
echo "═══ FAILURE ═══════════════════════════════════════════════════════"
|
||||
echo "exit code : $rc"
|
||||
echo "line : $lineno"
|
||||
echo "command : $cmd"
|
||||
echo ""
|
||||
case "$_DIAG_KIND" in
|
||||
cluster) _diag_dump_cluster ;;
|
||||
taskserv) _diag_dump_taskserv ;;
|
||||
"") echo "(no diagnostic context registered)" ;;
|
||||
*) echo "(unknown _DIAG_KIND=$_DIAG_KIND)" ;;
|
||||
esac
|
||||
echo "═══════════════════════════════════════════════════════════════════"
|
||||
} >&2
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
# ── cluster mode diagnostics ────────────────────────────────────────────
|
||||
_diag_dump_cluster() {
|
||||
if ! command -v kubectl >/dev/null 2>&1 && ! command -v k0s >/dev/null 2>&1; then
|
||||
echo " (no kubectl/k0s on this host — cannot collect k8s diagnostics)"
|
||||
return
|
||||
fi
|
||||
if [ -z "$_DIAG_K8S_NAME" ]; then
|
||||
echo " (no cluster workload registered — call _diag_register_cluster_workload first)"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "─── ${_DIAG_K8S_KIND}/${_DIAG_K8S_NAME} (namespace=${_DIAG_K8S_NAMESPACE}) ───"
|
||||
_kubectl get "${_DIAG_K8S_KIND}" "${_DIAG_K8S_NAME}" \
|
||||
--namespace "${_DIAG_K8S_NAMESPACE}" -o wide 2>&1 || true
|
||||
|
||||
case "$_DIAG_K8S_KIND" in
|
||||
deployment|statefulset|daemonset)
|
||||
_diag_dump_pods "$_DIAG_K8S_SELECTOR" "$_DIAG_K8S_NAMESPACE"
|
||||
;;
|
||||
cronjob)
|
||||
echo "─── recent jobs from cronjob/${_DIAG_K8S_NAME} ───"
|
||||
_kubectl get jobs --namespace "${_DIAG_K8S_NAMESPACE}" \
|
||||
-l "app.kubernetes.io/managed-by=cronjob,batch.kubernetes.io/cronjob-name=${_DIAG_K8S_NAME}" \
|
||||
2>/dev/null || _kubectl get jobs --namespace "${_DIAG_K8S_NAMESPACE}" 2>/dev/null | grep "${_DIAG_K8S_NAME}" || true
|
||||
_diag_dump_pods "$_DIAG_K8S_SELECTOR" "$_DIAG_K8S_NAMESPACE"
|
||||
;;
|
||||
*)
|
||||
_diag_dump_pods "$_DIAG_K8S_SELECTOR" "$_DIAG_K8S_NAMESPACE"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
_diag_dump_pods() {
|
||||
local selector="$1" namespace="$2" tail="${3:-$_DIAG_TAIL}"
|
||||
echo ""
|
||||
echo "─── pods (selector=${selector}) ───"
|
||||
_kubectl get pods --namespace "$namespace" --selector "$selector" -o wide 2>&1 || true
|
||||
local pods
|
||||
pods=$(_kubectl get pods --namespace "$namespace" --selector "$selector" \
|
||||
-o jsonpath='{.items[*].metadata.name}' 2>/dev/null || true)
|
||||
if [ -z "$pods" ]; then
|
||||
echo " (no pods match selector)"
|
||||
return
|
||||
fi
|
||||
for pod in $pods; do
|
||||
# Smart dump: if logs are accessible the daemon HAS started, so the
|
||||
# error is in the daemon's startup — logs are sufficient. Skip
|
||||
# describe in that case (it doesn't add info). Only when logs are
|
||||
# unavailable (pod never started: image pull failure, volume mount
|
||||
# error, scheduling issue) do we fall back to describe.
|
||||
local logs_out prev_out rc_logs rc_prev
|
||||
logs_out=$(_kubectl logs "$pod" --namespace "$namespace" \
|
||||
--tail="$tail" --all-containers=true 2>&1)
|
||||
rc_logs=$?
|
||||
if [ $rc_logs -eq 0 ] && [ -n "$logs_out" ]; then
|
||||
echo ""
|
||||
echo "─── logs pod/${pod} --tail=${tail} ───"
|
||||
printf '%s\n' "$logs_out"
|
||||
# If the pod has restarted (CrashLoop), --previous shows the
|
||||
# last instance — usually the most useful for diagnosis.
|
||||
prev_out=$(_kubectl logs "$pod" --namespace "$namespace" \
|
||||
--tail="$tail" --all-containers=true --previous 2>/dev/null)
|
||||
rc_prev=$?
|
||||
if [ $rc_prev -eq 0 ] && [ -n "$prev_out" ] \
|
||||
&& [ "$prev_out" != "$logs_out" ]; then
|
||||
echo ""
|
||||
echo "─── logs pod/${pod} --previous --tail=${tail} (CrashLoop) ───"
|
||||
printf '%s\n' "$prev_out"
|
||||
fi
|
||||
else
|
||||
# No logs accessible → pod never reached running. Describe + events
|
||||
# are the right diagnostic here.
|
||||
echo ""
|
||||
echo "─── describe pod/${pod} (last 40 lines) ───"
|
||||
_kubectl describe pod "$pod" --namespace "$namespace" 2>&1 | tail -40 || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Wrap kubectl rollout with timeout + auto-dump-on-failure.
|
||||
_diag_wait_for_deployment() {
|
||||
local name="$1" namespace="$2" timeout="${3:-180}"
|
||||
local selector="${4:-app.kubernetes.io/name=$name}"
|
||||
_diag_register_cluster_workload deployment "$name" "$namespace" "$selector"
|
||||
if _kubectl rollout status deployment/"$name" \
|
||||
--namespace "$namespace" --timeout="${timeout}s"; then
|
||||
return 0
|
||||
fi
|
||||
local rc=$?
|
||||
echo "" >&2
|
||||
echo "ERROR: deployment/${name} did not become ready within ${timeout}s" >&2
|
||||
_diag_dump_cluster >&2
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
_diag_wait_for_statefulset() {
|
||||
local name="$1" namespace="$2" timeout="${3:-300}"
|
||||
local selector="${4:-app.kubernetes.io/name=$name}"
|
||||
_diag_register_cluster_workload statefulset "$name" "$namespace" "$selector"
|
||||
if _kubectl rollout status statefulset/"$name" \
|
||||
--namespace "$namespace" --timeout="${timeout}s"; then
|
||||
return 0
|
||||
fi
|
||||
local rc=$?
|
||||
echo "" >&2
|
||||
echo "ERROR: statefulset/${name} did not become ready within ${timeout}s" >&2
|
||||
_diag_dump_cluster >&2
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# ── taskserv mode diagnostics ───────────────────────────────────────────
|
||||
_diag_dump_taskserv() {
|
||||
if [ -z "$_DIAG_SYSTEMD_UNIT" ]; then
|
||||
echo " (no systemd unit registered — call _diag_register_systemd_unit first)"
|
||||
return
|
||||
fi
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
echo " (no systemctl on this host — cannot collect systemd diagnostics)"
|
||||
return
|
||||
fi
|
||||
echo "─── systemctl status ${_DIAG_SYSTEMD_UNIT} ───"
|
||||
systemctl status "$_DIAG_SYSTEMD_UNIT" --no-pager --lines=20 2>&1 || true
|
||||
echo ""
|
||||
echo "─── journalctl -u ${_DIAG_SYSTEMD_UNIT} -n ${_DIAG_TAIL} ───"
|
||||
journalctl --no-pager -u "$_DIAG_SYSTEMD_UNIT" -n "$_DIAG_TAIL" 2>&1 || true
|
||||
}
|
||||
|
||||
# Idempotent "wait for unit Active" with timeout + auto-dump-on-failure.
|
||||
_diag_wait_for_systemd() {
|
||||
local unit="$1" timeout="${2:-60}"
|
||||
_diag_register_systemd_unit "$unit"
|
||||
local elapsed=0
|
||||
while [ "$elapsed" -lt "$timeout" ]; do
|
||||
if systemctl is-active --quiet "$unit"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
echo "" >&2
|
||||
echo "ERROR: ${unit} did not become Active within ${timeout}s" >&2
|
||||
_diag_dump_taskserv >&2
|
||||
return 1
|
||||
}
|
||||
398
components/_renderer/render.nu
Normal file
398
components/_renderer/render.nu
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
#!/usr/bin/env nu
|
||||
# Generic component bundle renderer.
|
||||
#
|
||||
# Discovers templates from _renderer/common-templates/ (shared scripts) and
|
||||
# <component>/cluster/templates/ (YAMLs + optional per-component overrides).
|
||||
# Component templates override common by name.
|
||||
#
|
||||
# Variables:
|
||||
# - Auto-flattened scalars from NCL subtree (UPPER_SNAKE_CASE with dotted paths)
|
||||
# - Extras from optional <component>/cluster/vars.nu
|
||||
# - MANIFESTS_ORDER: explicit via vars.nu, or derived from YAML kinds
|
||||
# - BUNDLE_ID, COMPONENT: injected
|
||||
#
|
||||
# Modes:
|
||||
# (default) Write bundle to --out/<bundle-id>/
|
||||
# --plan Vars + file tree + diff vs prior bundle
|
||||
# --render-stdout All files to stdout
|
||||
# --diff kubectl diff vs live cluster
|
||||
# --vars Just extracted vars (debug)
|
||||
|
||||
# ─── paths ──────────────────────────────────────────────────────────────
|
||||
|
||||
def components_dir []: nothing -> path {
|
||||
$env.FILE_PWD | path dirname
|
||||
}
|
||||
|
||||
def component_manifests_dir [name: string]: nothing -> path {
|
||||
components_dir | path join $name "cluster" "manifests"
|
||||
}
|
||||
|
||||
def provisioning_root []: nothing -> path {
|
||||
$env.FILE_PWD | path dirname --num-levels 3
|
||||
}
|
||||
|
||||
def common_templates_dir []: nothing -> path {
|
||||
$env.FILE_PWD | path join "common-templates"
|
||||
}
|
||||
|
||||
def component_templates_dir [name: string]: nothing -> path {
|
||||
components_dir | path join $name "cluster" "templates"
|
||||
}
|
||||
|
||||
def component_vars_script [name: string]: nothing -> path {
|
||||
components_dir | path join $name "cluster" "vars.nu"
|
||||
}
|
||||
|
||||
# ─── NCL export + component detection ──────────────────────────────────
|
||||
|
||||
def nickel_export [ncl_path: path]: nothing -> record {
|
||||
let r = (
|
||||
do { ^nickel export --format json --import-path (provisioning_root) $ncl_path }
|
||||
| complete
|
||||
)
|
||||
if $r.exit_code != 0 {
|
||||
error make { msg: $"nickel export failed:\n($r.stderr)" }
|
||||
}
|
||||
$r.stdout | from json
|
||||
}
|
||||
|
||||
def detect_component [exported: record]: nothing -> string {
|
||||
let keys = ($exported | columns)
|
||||
if ($keys | length) != 1 {
|
||||
error make { msg: $"expected single top-level key in NCL export, got: ($keys)" }
|
||||
}
|
||||
$keys | first
|
||||
}
|
||||
|
||||
# ─── template discovery (common + component) ──────────────────────────
|
||||
|
||||
def discover_templates [component: string]: nothing -> list<record<name: string, src: path>> {
|
||||
let cdir = common_templates_dir
|
||||
let pdir = (component_templates_dir $component)
|
||||
|
||||
let common_list = if ($cdir | path exists) {
|
||||
glob ($cdir | path join "*.tmpl")
|
||||
| each {|p| { name: ($p | path basename | str replace --regex '\.tmpl$' ""), src: $p } }
|
||||
} else { [] }
|
||||
|
||||
let comp_list = if ($pdir | path exists) {
|
||||
glob ($pdir | path join "*.tmpl")
|
||||
| each {|p| { name: ($p | path basename | str replace --regex '\.tmpl$' ""), src: $p } }
|
||||
} else { [] }
|
||||
|
||||
let comp_names = ($comp_list | get name)
|
||||
let filtered_common = ($common_list | where {|t| $t.name not-in $comp_names })
|
||||
$filtered_common ++ $comp_list
|
||||
}
|
||||
|
||||
# ─── variable extraction ──────────────────────────────────────────────
|
||||
|
||||
def flatten_scalars [rec: record, prefix: string]: nothing -> record {
|
||||
$rec
|
||||
| transpose key value
|
||||
| reduce --fold {} {|row, acc|
|
||||
let path = if ($prefix | is-empty) { $row.key } else { $"($prefix)_($row.key)" }
|
||||
let t = ($row.value | describe)
|
||||
if ($t | str starts-with "record") {
|
||||
$acc | merge (flatten_scalars $row.value $path)
|
||||
} else if (($t | str starts-with "list") or ($t | str starts-with "table")) {
|
||||
$acc
|
||||
} else if ($row.value == null) {
|
||||
$acc
|
||||
} else {
|
||||
$acc | insert ($path | str upcase) ($row.value | into string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def derive_extra_vars [component_name: string, component_record: record]: nothing -> record {
|
||||
let vars_file = (component_vars_script $component_name)
|
||||
if not ($vars_file | path exists) {
|
||||
return {}
|
||||
}
|
||||
let r = (
|
||||
do { ^nu $vars_file ($component_record | to json --raw) }
|
||||
| complete
|
||||
)
|
||||
if $r.exit_code != 0 {
|
||||
error make { msg: $"vars.nu failed for ($component_name):\n($r.stderr)" }
|
||||
}
|
||||
if ($r.stdout | str trim | is-empty) { {} } else { $r.stdout | from json }
|
||||
}
|
||||
|
||||
# K8s apply precedence: lower first. Ignored kinds default to high value (applied last).
|
||||
const YAML_PRECEDENCE = {
|
||||
namespace: 1
|
||||
configmap: 2
|
||||
secret: 2
|
||||
pv: 3
|
||||
pvc: 3
|
||||
statefulset: 4
|
||||
deployment: 4
|
||||
daemonset: 4
|
||||
job: 4
|
||||
server: 4
|
||||
agent: 5
|
||||
service: 6
|
||||
ingress: 7
|
||||
}
|
||||
|
||||
def default_manifests_order [yaml_names: list<string>]: nothing -> string {
|
||||
$yaml_names
|
||||
| sort-by {|n|
|
||||
let stem = ($n | str replace --regex '\.yaml$' "")
|
||||
$YAML_PRECEDENCE | get -o $stem | default 10
|
||||
}
|
||||
| str join " "
|
||||
}
|
||||
|
||||
def build_vars [
|
||||
exported: record
|
||||
component: string
|
||||
bundle_id: string
|
||||
template_names: list<string>
|
||||
]: nothing -> record {
|
||||
let subtree = ($exported | get $component)
|
||||
let auto = (flatten_scalars $subtree "")
|
||||
let extra = (derive_extra_vars $component $subtree)
|
||||
let merged = ($auto | merge $extra)
|
||||
let with_order = if "MANIFESTS_ORDER" in ($merged | columns) {
|
||||
$merged
|
||||
} else {
|
||||
let yamls = ($template_names | where {|n| $n | str ends-with ".yaml" })
|
||||
$merged | insert MANIFESTS_ORDER (default_manifests_order $yamls)
|
||||
}
|
||||
$with_order
|
||||
| insert BUNDLE_ID $bundle_id
|
||||
| insert COMPONENT $component
|
||||
}
|
||||
|
||||
def prepare [ncl_path: path, bundle_id: string]: nothing -> record {
|
||||
let exported = (nickel_export $ncl_path)
|
||||
let component = (detect_component $exported)
|
||||
let templates = (discover_templates $component)
|
||||
if ($templates | is-empty) {
|
||||
error make { msg: $"no templates found for component '($component)'" }
|
||||
}
|
||||
let vars = (build_vars $exported $component $bundle_id ($templates | get name))
|
||||
let subtree = ($exported | get $component)
|
||||
let credentials = ($subtree | get -o requires | default {} | get -o credentials | default [])
|
||||
{
|
||||
component: $component
|
||||
templates: $templates
|
||||
vars: $vars
|
||||
credentials: $credentials
|
||||
}
|
||||
}
|
||||
|
||||
# ─── rendering ────────────────────────────────────────────────────────
|
||||
|
||||
def render_one [tmpl_path: path, vars: record]: nothing -> string {
|
||||
mut content = (open --raw $tmpl_path)
|
||||
for kv in ($vars | transpose key value) {
|
||||
let needle = $"{{($kv.key)}}"
|
||||
$content = ($content | str replace --all $needle ($kv.value | into string))
|
||||
}
|
||||
$content
|
||||
}
|
||||
|
||||
def render_all [templates: list<record>, vars: record]: nothing -> list<record> {
|
||||
$templates | each {|t| { name: $t.name, content: (render_one $t.src $vars) } }
|
||||
}
|
||||
|
||||
# ─── output ───────────────────────────────────────────────────────────
|
||||
|
||||
def copy_bundle_files [component: string, out_dir: path]: nothing -> list<string> {
|
||||
let mdir = (component_manifests_dir $component)
|
||||
if not ($mdir | path exists) { return [] }
|
||||
glob ($mdir | path join "*.yaml")
|
||||
| each {|src|
|
||||
let name = ($src | path basename)
|
||||
^cp $src ($out_dir | path join $name)
|
||||
$name
|
||||
}
|
||||
}
|
||||
|
||||
def write_bundle [
|
||||
out_dir: path
|
||||
rendered: list<record>
|
||||
vars: record
|
||||
credentials: list<string>
|
||||
]: nothing -> nothing {
|
||||
mkdir $out_dir
|
||||
for r in $rendered {
|
||||
let dst = ($out_dir | path join $r.name)
|
||||
$r.content | save --force $dst
|
||||
if ($r.name | str ends-with ".sh") {
|
||||
^chmod +x $dst
|
||||
}
|
||||
}
|
||||
copy_bundle_files $vars.COMPONENT $out_dir | ignore
|
||||
write_meta $out_dir $vars $rendered $credentials
|
||||
write_readme $out_dir $vars
|
||||
}
|
||||
|
||||
def write_meta [
|
||||
out_dir: path
|
||||
vars: record
|
||||
rendered: list<record>
|
||||
credentials: list<string>
|
||||
]: nothing -> nothing {
|
||||
let meta = {
|
||||
component: $vars.COMPONENT
|
||||
bundle_id: $vars.BUNDLE_ID
|
||||
namespace: ($vars | get -o NAMESPACE | default "default")
|
||||
secret_name: $"($vars.COMPONENT)-credentials"
|
||||
credentials_required: $credentials
|
||||
manifests_order: ($vars.MANIFESTS_ORDER | split row " " | where {|s| ($s | str trim | is-not-empty) })
|
||||
generated_at: (date now | format date "%Y-%m-%dT%H:%M:%S%z")
|
||||
files: ($rendered | get name)
|
||||
}
|
||||
$meta | to json --indent 2 | save --force ($out_dir | path join "bundle.meta.json")
|
||||
}
|
||||
|
||||
def write_readme [out_dir: path, vars: record]: nothing -> nothing {
|
||||
let body = $"# ($vars.COMPONENT) bundle ($vars.BUNDLE_ID)
|
||||
|
||||
Generated from NCL. Credential-free by design.
|
||||
Secret name expected in cluster: ($vars.COMPONENT)-credentials \(namespace: ($vars.NAMESPACE? | default 'default')\)
|
||||
|
||||
Operations:
|
||||
install.sh full install \(applies manifests in declared order\)
|
||||
update.sh workload-only update \(preserves ns, pvc, secret\)
|
||||
delete.sh remove workload \(preserves pvc, secret\)
|
||||
purge.sh --confirm destructive: deletes pvc + secret \(preserves namespace\)
|
||||
|
||||
All scripts: set -euo pipefail, trap ERR, STEP tracker, per-op .log file.
|
||||
"
|
||||
$body | save --force ($out_dir | path join "README")
|
||||
}
|
||||
|
||||
# ─── modes ────────────────────────────────────────────────────────────
|
||||
|
||||
def mode_vars [ncl_path: path, bid: string]: nothing -> nothing {
|
||||
let p = (prepare $ncl_path $bid)
|
||||
$p.vars | transpose key value | sort-by key | each {|row|
|
||||
print $" ($row.key | fill --alignment left --width 28) ($row.value)"
|
||||
} | ignore
|
||||
}
|
||||
|
||||
def mode_plan [ncl_path: path, out: path, bid: string]: nothing -> nothing {
|
||||
let p = (prepare $ncl_path $bid)
|
||||
let rendered = (render_all $p.templates $p.vars)
|
||||
|
||||
print $"(ansi green_bold)[plan](ansi reset) component=($p.component)"
|
||||
print $"(ansi cyan)vars \(top 15\):(ansi reset)"
|
||||
$p.vars | transpose key value | sort-by key | first 15 | each {|row|
|
||||
print $" ($row.key | fill --alignment left --width 28) ($row.value)"
|
||||
} | ignore
|
||||
|
||||
print $"\n(ansi cyan)files to be generated:(ansi reset) → ($out | path join $bid)"
|
||||
for r in $rendered {
|
||||
let bytes = ($r.content | str length)
|
||||
print $" ($r.name | fill --alignment left --width 22) ($bytes) bytes"
|
||||
}
|
||||
print $" bundle.meta.json \(metadata\)"
|
||||
print $" README \(usage\)"
|
||||
|
||||
let prev_dir = if ($out | path exists) {
|
||||
ls $out | where type == dir | where name !~ $bid | sort-by modified | last 1
|
||||
} else { [] }
|
||||
if ($prev_dir | is-not-empty) {
|
||||
let prev = $prev_dir.0.name
|
||||
print $"\n(ansi cyan)diff vs previous bundle(ansi reset): ($prev)"
|
||||
for r in $rendered {
|
||||
let prev_file = ($prev | path join $r.name)
|
||||
if ($prev_file | path exists) {
|
||||
let d = (do { $r.content | ^diff -u $prev_file - } | complete)
|
||||
if $d.exit_code != 0 {
|
||||
print $" (ansi yellow)~ ($r.name)(ansi reset)"
|
||||
print ($d.stdout | lines | skip 2 | str join "\n")
|
||||
}
|
||||
} else {
|
||||
print $" (ansi green)+ ($r.name) \(new\)(ansi reset)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def mode_render_stdout [ncl_path: path, bid: string]: nothing -> nothing {
|
||||
let p = (prepare $ncl_path $bid)
|
||||
let rendered = (render_all $p.templates $p.vars)
|
||||
let sep = "────────────────────────────────────────────────────────────────────────"
|
||||
for r in $rendered {
|
||||
print $"\n(ansi dark_gray)($sep)(ansi reset)"
|
||||
print $"(ansi cyan_bold)=== ($r.name) ===(ansi reset)"
|
||||
print $"(ansi dark_gray)($sep)(ansi reset)"
|
||||
print $r.content
|
||||
}
|
||||
}
|
||||
|
||||
def mode_diff [ncl_path: path, bid: string]: nothing -> nothing {
|
||||
let p = (prepare $ncl_path $bid)
|
||||
let rendered = (render_all $p.templates $p.vars)
|
||||
let tmp = (mktemp --directory --tmpdir $"prvng-diff-($bid)-XXXX")
|
||||
write_bundle $tmp $rendered $p.vars $p.credentials
|
||||
print $"(ansi cyan)rendered to:(ansi reset) ($tmp)"
|
||||
for r in ($rendered | where name =~ '\.yaml$') {
|
||||
let file = ($tmp | path join $r.name)
|
||||
print $"\n(ansi cyan_bold)kubectl diff ($r.name)(ansi reset)"
|
||||
let out = (do { ^kubectl diff -f $file } | complete)
|
||||
match $out.exit_code {
|
||||
0 => { print " (no changes)" }
|
||||
1 => { print $out.stdout }
|
||||
_ => { print $"(ansi red)error:(ansi reset) ($out.stderr)" }
|
||||
}
|
||||
}
|
||||
print $"\n(ansi yellow)tmp kept for inspection:(ansi reset) ($tmp)"
|
||||
}
|
||||
|
||||
def mode_write [ncl_path: path, out: path, bid: string]: nothing -> nothing {
|
||||
let p = (prepare $ncl_path $bid)
|
||||
let rendered = (render_all $p.templates $p.vars)
|
||||
let out_dir = ($out | path join $bid)
|
||||
write_bundle $out_dir $rendered $p.vars $p.credentials
|
||||
print $"bundle written: ($out_dir)"
|
||||
for r in $rendered { print $" ($r.name)" }
|
||||
print $" bundle.meta.json"
|
||||
print $" README"
|
||||
}
|
||||
|
||||
# ─── entry ────────────────────────────────────────────────────────────
|
||||
|
||||
def main [
|
||||
ncl_path: path
|
||||
--out (-o): path = "./out"
|
||||
--bundle-id (-b): string = ""
|
||||
--plan
|
||||
--render-stdout
|
||||
--diff
|
||||
--vars
|
||||
]: nothing -> nothing {
|
||||
if not ($ncl_path | path exists) {
|
||||
error make { msg: $"ncl path not found: ($ncl_path)" }
|
||||
}
|
||||
let modes_on = ([$plan, $render_stdout, $diff, $vars] | where {|x| $x } | length)
|
||||
if $modes_on > 1 {
|
||||
error make { msg: "--plan, --render-stdout, --diff, --vars are mutually exclusive" }
|
||||
}
|
||||
let bid = if ($bundle_id | is-empty) {
|
||||
let stem = ($ncl_path | path basename | str replace --regex '\.ncl$' "")
|
||||
$"($stem)-(date now | format date '%Y%m%d-%H%M%S')"
|
||||
} else {
|
||||
$bundle_id
|
||||
}
|
||||
|
||||
if $vars {
|
||||
mode_vars $ncl_path $bid
|
||||
} else if $plan {
|
||||
mode_plan $ncl_path $out $bid
|
||||
} else if $render_stdout {
|
||||
mode_render_stdout $ncl_path $bid
|
||||
} else if $diff {
|
||||
mode_diff $ncl_path $bid
|
||||
} else {
|
||||
mode_write $ncl_path $out $bid
|
||||
}
|
||||
}
|
||||
16
components/acme_certd/metadata.ncl
Normal file
16
components/acme_certd/metadata.ncl
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
name = "acme_certd",
|
||||
version = "0.1.0",
|
||||
description = "TLS certificate issuance and renewal via acme.sh with DNS-01 challenge. Runs as a systemd timer. No cert-manager dependency — suitable for bare VM fleets. DNS provider is pluggable; Cloudflare (dns_cf) is the reference implementation.",
|
||||
tags = ["tls", "acme", "certificates", "dns-01", "systemd"],
|
||||
modes = ["systemd_timer"],
|
||||
dependencies = [],
|
||||
provides = [{ id = "tls-certificates", version = "0.1", interface = "acme-certd" }],
|
||||
requires = [
|
||||
{ capability = "vm-lifecycle", kind = 'Required },
|
||||
{ capability = "ssh-access", kind = 'Required },
|
||||
{ capability = "dns-api-token", kind = 'Required },
|
||||
],
|
||||
conflicts_with = [],
|
||||
best_practices = ["dns-01-challenge", "wildcard-cert-per-fleet"],
|
||||
}
|
||||
60
components/acme_certd/nickel/contracts.ncl
Normal file
60
components/acme_certd/nickel/contracts.ncl
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
let _concerns_lib = import "schemas/lib/concerns.ncl" in
|
||||
|
||||
{
|
||||
AcmeCertd = {
|
||||
name | String,
|
||||
version | String,
|
||||
mode | [| 'systemd_timer |],
|
||||
|
||||
acme_sh_version | String,
|
||||
|
||||
# ACME server endpoint — letsencrypt | letsencrypt_test | zerossl
|
||||
server | [| 'letsencrypt, 'letsencrypt_test, 'zerossl |] | default = 'letsencrypt,
|
||||
|
||||
# acme.sh DNS plugin name — e.g. dns_cf (Cloudflare), dns_he, dns_aws
|
||||
dns_provider | String,
|
||||
|
||||
# SOPS ref to the DNS API token resolved at deploy time
|
||||
dns_token_ref | String,
|
||||
|
||||
# Domains to issue — supports wildcards (e.g. "*.librecloud.online")
|
||||
domains | Array String,
|
||||
|
||||
# Email for ACME account registration and expiry notifications
|
||||
acme_email | String,
|
||||
|
||||
# Directory where issued certs are installed
|
||||
cert_dir | String | default = "/etc/ssl/acme",
|
||||
|
||||
# systemd timer interval for renewal checks
|
||||
renewal_check | String | default = "12h",
|
||||
|
||||
# OS user acme.sh runs as (created if absent)
|
||||
user | String | default = "acme",
|
||||
group | String | default = "acme",
|
||||
|
||||
# Commands to run after a successful renewal (e.g. systemctl reload nginx)
|
||||
reload_hooks | Array String | default = [],
|
||||
|
||||
requires | {
|
||||
credentials | Array String | default = [],
|
||||
capabilities | Array String | default = [],
|
||||
} | default = {},
|
||||
|
||||
provides | {
|
||||
service | String | optional,
|
||||
interface | String | optional,
|
||||
} | default = {},
|
||||
|
||||
operations | {
|
||||
install | Bool | default = true,
|
||||
update | Bool | default = true,
|
||||
delete | Bool | default = false,
|
||||
health | Bool | default = true,
|
||||
} | default = {},
|
||||
|
||||
concerns | _concerns_lib.ServiceConcerns | optional,
|
||||
|
||||
..
|
||||
},
|
||||
}
|
||||
42
components/acme_certd/nickel/defaults.ncl
Normal file
42
components/acme_certd/nickel/defaults.ncl
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
let _presets = (import "schemas/lib/concerns_presets.ncl").presets in
|
||||
|
||||
{
|
||||
acme_certd | default = {
|
||||
name | default = "acme_certd",
|
||||
version | default = "0.1.0",
|
||||
mode | default = 'systemd_timer,
|
||||
|
||||
acme_sh_version | default = "3.0.9",
|
||||
server | default = 'letsencrypt,
|
||||
dns_provider | default = "dns_cf",
|
||||
dns_token_ref | default = "UNSET",
|
||||
domains | default = [],
|
||||
acme_email | default = "UNSET",
|
||||
cert_dir | default = "/etc/ssl/acme",
|
||||
renewal_check | default = "12h",
|
||||
user | default = "acme",
|
||||
group | default = "acme",
|
||||
reload_hooks | default = [],
|
||||
|
||||
requires | default = {
|
||||
credentials = [],
|
||||
capabilities = ["vm-lifecycle", "ssh-access", "dns-api-token"],
|
||||
},
|
||||
|
||||
provides | default = {
|
||||
service = "acme_certd",
|
||||
interface = "acme-certd",
|
||||
},
|
||||
|
||||
operations | default = {
|
||||
install = true,
|
||||
update = true,
|
||||
health = true,
|
||||
},
|
||||
|
||||
concerns | default = _presets.infrastructure_glue & {
|
||||
tls | force = { kind = 'enabled, reason = "this component IS the TLS issuer" },
|
||||
backup | force = { kind = 'pending, reason = "cert dir should be backed up; acme.sh account key loss requires re-registration" },
|
||||
},
|
||||
},
|
||||
}
|
||||
14
components/acme_certd/nickel/main.ncl
Normal file
14
components/acme_certd/nickel/main.ncl
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
let contracts_lib = import "contracts.ncl" in
|
||||
let defaults_lib = import "defaults.ncl" in
|
||||
let version = import "version.ncl" in
|
||||
|
||||
{
|
||||
defaults = defaults_lib,
|
||||
|
||||
make_acme_certd | not_exported = fun overrides =>
|
||||
defaults_lib.acme_certd & overrides,
|
||||
|
||||
DefaultAcmeCertd = defaults_lib.acme_certd,
|
||||
AcmeCertd | contracts_lib.AcmeCertd = defaults_lib.acme_certd,
|
||||
Version = version,
|
||||
}
|
||||
1
components/acme_certd/nickel/version.ncl
Normal file
1
components/acme_certd/nickel/version.ncl
Normal file
|
|
@ -0,0 +1 @@
|
|||
"0.1.0"
|
||||
11
components/acme_certd/taskserv/env-acme_certd
Normal file
11
components/acme_certd/taskserv/env-acme_certd
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
ACME_SH_VERSION="3.0.9"
|
||||
ACME_SERVER="letsencrypt"
|
||||
ACME_EMAIL="UNSET"
|
||||
ACME_DNS_PROVIDER="dns_cf"
|
||||
ACME_DOMAINS=""
|
||||
ACME_CERT_DIR="/etc/ssl/acme"
|
||||
ACME_RENEWAL_CHECK="12h"
|
||||
ACME_USER="acme"
|
||||
ACME_GROUP="acme"
|
||||
ACME_RELOAD_HOOKS=""
|
||||
DNS_TOKEN="UNSET"
|
||||
10
components/acme_certd/taskserv/env-acme_certd.j2
Normal file
10
components/acme_certd/taskserv/env-acme_certd.j2
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
ACME_SH_VERSION={{ taskserv.acme_sh_version }}
|
||||
ACME_SERVER={{ taskserv.server }}
|
||||
ACME_EMAIL={{ taskserv.acme_email }}
|
||||
ACME_DNS_PROVIDER={{ taskserv.dns_provider }}
|
||||
ACME_DOMAINS={{ taskserv.domains | join(sep=",") }}
|
||||
ACME_CERT_DIR={{ taskserv.cert_dir }}
|
||||
ACME_RENEWAL_CHECK={{ taskserv.renewal_check }}
|
||||
ACME_USER={{ taskserv.user }}
|
||||
ACME_GROUP={{ taskserv.group }}
|
||||
ACME_RELOAD_HOOKS={{ taskserv.reload_hooks | join(sep=";") }}
|
||||
192
components/acme_certd/taskserv/install-acme_certd.sh
Normal file
192
components/acme_certd/taskserv/install-acme_certd.sh
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
#!/bin/bash
|
||||
# install-acme_certd.sh — taskserv installer for acme.sh + systemd timer.
|
||||
# Runs on the target Debian/Ubuntu host as root. Idempotent.
|
||||
#
|
||||
# Inputs (env file scp'd alongside this script):
|
||||
# ACME_SH_VERSION — acme.sh release tag (e.g. 3.0.9)
|
||||
# ACME_SERVER — letsencrypt | letsencrypt_test | zerossl
|
||||
# ACME_EMAIL — ACME account email
|
||||
# ACME_DNS_PROVIDER — acme.sh DNS plugin (e.g. dns_cf)
|
||||
# ACME_DOMAINS — comma-separated domain list (e.g. "*.librecloud.online,librecloud.online")
|
||||
# ACME_CERT_DIR — where to install issued certs (default /etc/ssl/acme)
|
||||
# ACME_RENEWAL_CHECK — systemd timer interval (default 12h)
|
||||
# ACME_USER — OS user to run acme.sh (default acme)
|
||||
# ACME_GROUP — OS group (default acme)
|
||||
# ACME_RELOAD_HOOKS — semicolon-separated commands to run after renewal
|
||||
# DNS_TOKEN — DNS provider API token (resolved from SOPS by provisioning)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
for f in env-acme_certd _credentials.env; do
|
||||
[ -f "${SCRIPT_DIR}/${f}" ] && source "${SCRIPT_DIR}/${f}" || true
|
||||
done
|
||||
|
||||
: "${ACME_SH_VERSION:?ACME_SH_VERSION must be set}"
|
||||
: "${ACME_EMAIL:?ACME_EMAIL must be set}"
|
||||
: "${ACME_DNS_PROVIDER:?ACME_DNS_PROVIDER must be set}"
|
||||
: "${ACME_DOMAINS:?ACME_DOMAINS must be set}"
|
||||
: "${DNS_TOKEN:?DNS_TOKEN must be set}"
|
||||
|
||||
ACME_SERVER="${ACME_SERVER:-letsencrypt}"
|
||||
ACME_CERT_DIR="${ACME_CERT_DIR:-/etc/ssl/acme}"
|
||||
ACME_RENEWAL_CHECK="${ACME_RENEWAL_CHECK:-12h}"
|
||||
ACME_USER="${ACME_USER:-acme}"
|
||||
ACME_GROUP="${ACME_GROUP:-acme}"
|
||||
ACME_RELOAD_HOOKS="${ACME_RELOAD_HOOKS:-}"
|
||||
|
||||
ACME_HOME="/home/${ACME_USER}/.acme.sh"
|
||||
|
||||
bold() { printf "\033[1m%s\033[0m\n" "$*"; }
|
||||
fatal() { printf "\033[31mFATAL:\033[0m %s\n" "$*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || fatal "must run as root"
|
||||
[ "$ACME_EMAIL" != "UNSET" ] || fatal "ACME_EMAIL is sentinel 'UNSET'"
|
||||
[ "$DNS_TOKEN" != "UNSET" ] || fatal "DNS_TOKEN is sentinel 'UNSET'"
|
||||
|
||||
ACTION="${1:-install}"
|
||||
|
||||
_acme() {
|
||||
sudo -u "${ACME_USER}" \
|
||||
env HOME="/home/${ACME_USER}" \
|
||||
CF_Token="${DNS_TOKEN}" \
|
||||
"${ACME_HOME}/acme.sh" "$@"
|
||||
}
|
||||
|
||||
_server_url() {
|
||||
case "$ACME_SERVER" in
|
||||
letsencrypt) echo "https://acme-v02.api.letsencrypt.org/directory" ;;
|
||||
letsencrypt_test) echo "https://acme-staging-v02.api.letsencrypt.org/directory" ;;
|
||||
zerossl) echo "https://acme.zerossl.com/v2/DV90" ;;
|
||||
*) echo "$ACME_SERVER" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_install() {
|
||||
bold "==> [1/5] System packages"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq curl socat openssl cron
|
||||
|
||||
bold "==> [2/5] OS user ${ACME_USER}"
|
||||
if ! id "${ACME_USER}" &>/dev/null; then
|
||||
useradd --system --create-home --shell /bin/bash "${ACME_USER}"
|
||||
echo " created user ${ACME_USER}"
|
||||
else
|
||||
echo " user ${ACME_USER} already exists"
|
||||
fi
|
||||
|
||||
bold "==> [3/5] Install acme.sh ${ACME_SH_VERSION}"
|
||||
if [ -x "${ACME_HOME}/acme.sh" ] && \
|
||||
"${ACME_HOME}/acme.sh" --version 2>/dev/null | grep -q "${ACME_SH_VERSION}"; then
|
||||
echo " acme.sh ${ACME_SH_VERSION} already installed"
|
||||
else
|
||||
TMP=$(sudo -u "${ACME_USER}" mktemp -d)
|
||||
curl -fsSL "https://github.com/acmesh-official/acme.sh/archive/refs/tags/${ACME_SH_VERSION}.tar.gz" \
|
||||
| sudo -u "${ACME_USER}" tar -xz -C "${TMP}" --strip-components=1
|
||||
sudo -u "${ACME_USER}" \
|
||||
env HOME="/home/${ACME_USER}" \
|
||||
bash -c "cd '${TMP}' && bash acme.sh --install \
|
||||
--home '${ACME_HOME}' \
|
||||
--accountemail '${ACME_EMAIL}' \
|
||||
--server '$(_server_url)' \
|
||||
--no-cron"
|
||||
rm -rf "${TMP}"
|
||||
echo " installed acme.sh ${ACME_SH_VERSION}"
|
||||
fi
|
||||
|
||||
bold "==> [4/5] Issue certificates"
|
||||
install -d -m 0755 -o "${ACME_USER}" -g "${ACME_GROUP}" "${ACME_CERT_DIR}"
|
||||
|
||||
IFS=',' read -ra DOMAIN_LIST <<< "${ACME_DOMAINS}"
|
||||
DOMAIN_ARGS=""
|
||||
for d in "${DOMAIN_LIST[@]}"; do
|
||||
DOMAIN_ARGS="${DOMAIN_ARGS} -d ${d}"
|
||||
done
|
||||
|
||||
# Build reload hook args
|
||||
HOOK_ARGS=""
|
||||
if [ -n "${ACME_RELOAD_HOOKS}" ]; then
|
||||
IFS=';' read -ra HOOKS <<< "${ACME_RELOAD_HOOKS}"
|
||||
HOOK_CMD="${HOOKS[*]}"
|
||||
HOOK_ARGS="--reloadcmd \"${HOOK_CMD}\""
|
||||
fi
|
||||
|
||||
# Issue or renew — idempotent. --ecc forces ECC key (P-256); cert stored under <domain>_ecc/.
|
||||
eval _acme --issue \
|
||||
--dns "${ACME_DNS_PROVIDER}" \
|
||||
--ecc \
|
||||
${DOMAIN_ARGS} \
|
||||
--cert-home "${ACME_CERT_DIR}" \
|
||||
--server "$(_server_url)" \
|
||||
${HOOK_ARGS} \
|
||||
--log || true # exit 2 = already up to date, not an error
|
||||
|
||||
# Install certs to cert_dir/<primary_domain>/
|
||||
PRIMARY="${DOMAIN_LIST[0]//\*/wildcard}"
|
||||
CERT_TARGET="${ACME_CERT_DIR}/${PRIMARY}"
|
||||
install -d -m 0750 -o "${ACME_USER}" -g "${ACME_GROUP}" "${CERT_TARGET}"
|
||||
|
||||
eval _acme --install-cert \
|
||||
--ecc \
|
||||
--cert-home "${ACME_CERT_DIR}" \
|
||||
-d "${DOMAIN_LIST[0]}" \
|
||||
--cert-file "${CERT_TARGET}/cert.pem" \
|
||||
--key-file "${CERT_TARGET}/key.pem" \
|
||||
--fullchain-file "${CERT_TARGET}/fullchain.pem" \
|
||||
${HOOK_ARGS}
|
||||
|
||||
bold "==> [5/5] systemd timer (${ACME_RENEWAL_CHECK})"
|
||||
cat > /etc/systemd/system/acme-certd.service <<EOF
|
||||
[Unit]
|
||||
Description=acme.sh certificate renewal
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=${ACME_USER}
|
||||
Environment=HOME=/home/${ACME_USER}
|
||||
Environment=CF_Token=${DNS_TOKEN}
|
||||
ExecStart=${ACME_HOME}/acme.sh --cron --home ${ACME_HOME}
|
||||
EOF
|
||||
|
||||
cat > /etc/systemd/system/acme-certd.timer <<EOF
|
||||
[Unit]
|
||||
Description=acme.sh renewal timer
|
||||
|
||||
[Timer]
|
||||
OnBootSec=5min
|
||||
OnUnitActiveSec=${ACME_RENEWAL_CHECK}
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now acme-certd.timer
|
||||
bold "==> Done"
|
||||
systemctl list-timers acme-certd.timer --no-pager
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
systemctl status acme-certd.timer --no-pager
|
||||
echo ""
|
||||
_acme --list
|
||||
}
|
||||
|
||||
cmd_uninstall() {
|
||||
systemctl disable --now acme-certd.timer 2>/dev/null || true
|
||||
rm -f /etc/systemd/system/acme-certd.{service,timer}
|
||||
systemctl daemon-reload
|
||||
echo "uninstalled (certs in ${ACME_CERT_DIR} and acme.sh in ${ACME_HOME} preserved)"
|
||||
}
|
||||
|
||||
case "$ACTION" in
|
||||
install) cmd_install ;;
|
||||
status) cmd_status ;;
|
||||
uninstall) cmd_uninstall ;;
|
||||
*) fatal "unknown action: $ACTION (install|status|uninstall)" ;;
|
||||
esac
|
||||
115
components/backup_manager/cluster/install-backup_manager.sh
Executable file
115
components/backup_manager/cluster/install-backup_manager.sh
Executable file
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env bash
|
||||
# Install/update backup-manager (cluster mode + on-host runners).
|
||||
#
|
||||
# Operating model:
|
||||
# 1. Receives bootstrap secrets via env (decrypted by the wrapper from
|
||||
# SOPS+Age — only at deploy time; runtime keys live in vault per
|
||||
# ADR-017).
|
||||
# 2. Creates the bootstrap K8s Secret in $NAMESPACE.
|
||||
# 3. Renders and applies Deployment + Service + ServiceMonitor +
|
||||
# PrometheusRule for the daemon (cluster mode).
|
||||
# 4. For every entry in system-backups.ncl: SSH to the selected hosts and
|
||||
# install the prvng-backup binary + systemd timer or cron.d entry.
|
||||
#
|
||||
# Required env (typically populated by `provisioning component install …`):
|
||||
# VAULT_BOOTSTRAP_TOKEN
|
||||
# NATS_BOOTSTRAP_NKEY_SEED
|
||||
# BACKUP_AGE_KEY bootstrap age key (NOT in vault — ADR-011)
|
||||
# BACKUP_S3_PRIMARY_ACCESS_KEY
|
||||
# BACKUP_S3_PRIMARY_SECRET_KEY
|
||||
# BACKUP_B2_REPLICA_KEY_ID
|
||||
# BACKUP_B2_REPLICA_APPLICATION_KEY
|
||||
# NAMESPACE (default: backup-system)
|
||||
# COMPONENT_PATH (path to instance .ncl rendered to JSON for Jinja2)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NAMESPACE="${NAMESPACE:-backup-system}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TEMPLATES="$SCRIPT_DIR/templates"
|
||||
|
||||
# ── Pre-flight ──────────────────────────────────────────────────────────────
|
||||
require_var() {
|
||||
local name="$1"
|
||||
if [ -z "${!name:-}" ]; then
|
||||
echo "ERROR: required env var $name is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_var VAULT_BOOTSTRAP_TOKEN
|
||||
require_var NATS_BOOTSTRAP_NKEY_SEED
|
||||
require_var BACKUP_AGE_KEY
|
||||
require_var BACKUP_S3_PRIMARY_ACCESS_KEY
|
||||
require_var BACKUP_S3_PRIMARY_SECRET_KEY
|
||||
require_var BACKUP_B2_REPLICA_KEY_ID
|
||||
require_var BACKUP_B2_REPLICA_APPLICATION_KEY
|
||||
|
||||
command -v kubectl >/dev/null || { echo "kubectl is required" >&2; exit 1; }
|
||||
command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; }
|
||||
|
||||
# ── Namespace ────────────────────────────────────────────────────────────────
|
||||
kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
# ── Bootstrap Secret ─────────────────────────────────────────────────────────
|
||||
# Pattern mirrors zot S3 install (provisioning/extensions/components/zot/cluster/install-zot.sh):
|
||||
# wrapper passes secrets as env vars; we materialise them as a K8s Secret.
|
||||
# Daemon reads on first run and promotes runtime credentials into vault.
|
||||
kubectl create secret generic backup-manager-bootstrap \
|
||||
--namespace="$NAMESPACE" \
|
||||
--from-literal=vault_token="$VAULT_BOOTSTRAP_TOKEN" \
|
||||
--from-literal=nats_nkey_seed="$NATS_BOOTSTRAP_NKEY_SEED" \
|
||||
--from-literal=age_key="$BACKUP_AGE_KEY" \
|
||||
--from-literal=s3_primary_access_key="$BACKUP_S3_PRIMARY_ACCESS_KEY" \
|
||||
--from-literal=s3_primary_secret_key="$BACKUP_S3_PRIMARY_SECRET_KEY" \
|
||||
--from-literal=b2_replica_key_id="$BACKUP_B2_REPLICA_KEY_ID" \
|
||||
--from-literal=b2_replica_application_key="$BACKUP_B2_REPLICA_APPLICATION_KEY" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
# ── Deployment + Service ─────────────────────────────────────────────────────
|
||||
# Templates are Jinja2; rendered by `nu render-template.nu` (provisioning helper)
|
||||
# with the JSON dump of the component config bound as $component.
|
||||
# Here we leave a render hook the wrapper invokes; if invoked directly without
|
||||
# the wrapper, fall back to substituting NAMESPACE and DAEMON_IMAGE only.
|
||||
RENDER_BIN="${RENDER_BIN:-render-template.nu}"
|
||||
RENDER_INPUT="${COMPONENT_PATH:-}"
|
||||
|
||||
render_template() {
|
||||
local template="$1"
|
||||
if command -v "$RENDER_BIN" >/dev/null && [ -n "$RENDER_INPUT" ]; then
|
||||
"$RENDER_BIN" "$template" --json "$RENDER_INPUT"
|
||||
else
|
||||
# Minimal substitution fallback — sufficient for development.
|
||||
sed -e "s|{{ namespace }}|$NAMESPACE|g" \
|
||||
-e "s|{{ daemon_image }}|${DAEMON_IMAGE:-ghcr.io/jesusperezlorenzo/backup-manager-runner:0.1.0}|g" \
|
||||
-e "s|{{ daemon_http_port }}|${DAEMON_HTTP_PORT:-9099}|g" \
|
||||
-e "s|{{ daemon_metrics_port }}|${DAEMON_METRICS_PORT:-9100}|g" \
|
||||
"$template"
|
||||
fi
|
||||
}
|
||||
|
||||
for tpl in deployment.yaml.j2 service.yaml.j2 servicemonitor.yaml.j2; do
|
||||
if [ -f "$TEMPLATES/$tpl" ]; then
|
||||
render_template "$TEMPLATES/$tpl" | kubectl apply -f -
|
||||
fi
|
||||
done
|
||||
|
||||
# ── PrometheusRule (alerts from prometheus.ncl) ─────────────────────────────
|
||||
# The alert rules are declared in the prometheus component; install-prometheus.sh
|
||||
# is responsible for rendering them. backup-manager only emits the metrics; the
|
||||
# rules live with the observability stack.
|
||||
|
||||
# ── On-host runners ──────────────────────────────────────────────────────────
|
||||
# For each SystemBackupDef in the workspace, install /usr/local/bin/prvng-backup
|
||||
# and a systemd timer or cron.d entry on the selected hosts. This step is
|
||||
# delegated to the provisioning host runner (Nushell) when available; here we
|
||||
# emit the list to stdout so the caller can act.
|
||||
SYSTEM_BACKUPS_PATH="${SYSTEM_BACKUPS_PATH:-infra/libre-wuji/system-backups.ncl}"
|
||||
if command -v nickel >/dev/null && [ -f "$SYSTEM_BACKUPS_PATH" ]; then
|
||||
echo "==> SystemBackupDef entries requiring on-host runner installation:"
|
||||
nickel export "$SYSTEM_BACKUPS_PATH" 2>/dev/null \
|
||||
| jq -r 'to_entries[] | " \(.value.name) -> host_selector=\(.value.host_selector.kind), members=\(.value.host_selector.members | join(","))"' || true
|
||||
echo "==> Run 'provisioning component install backup_manager --target-hosts' to deploy binaries."
|
||||
fi
|
||||
|
||||
echo "==> backup-manager cluster install complete in namespace $NAMESPACE"
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: backup-manager
|
||||
namespace: {{ namespace }}
|
||||
labels:
|
||||
app: backup-manager
|
||||
app.kubernetes.io/name: backup-manager
|
||||
app.kubernetes.io/component: orchestrator
|
||||
app.kubernetes.io/part-of: backup-system
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: backup-manager
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: backup-manager
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "{{ daemon_metrics_port }}"
|
||||
prometheus.io/path: "/metrics"
|
||||
spec:
|
||||
serviceAccountName: backup-manager
|
||||
containers:
|
||||
- name: daemon
|
||||
image: "{{ daemon_image }}"
|
||||
args: ["daemon", "start"]
|
||||
ports:
|
||||
- name: api
|
||||
containerPort: {{ daemon_http_port }}
|
||||
- name: metrics
|
||||
containerPort: {{ daemon_metrics_port }}
|
||||
env:
|
||||
- name: BACKUP_LOG
|
||||
value: "info"
|
||||
- name: BACKUP_DAEMON_HTTP_ADDR
|
||||
value: "0.0.0.0:{{ daemon_http_port }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: backup-manager-bootstrap
|
||||
resources:
|
||||
requests:
|
||||
cpu: "200m"
|
||||
memory: "256Mi"
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: "1Gi"
|
||||
volumeMounts:
|
||||
- name: state
|
||||
mountPath: /var/lib/prvng-backup
|
||||
- name: definitions
|
||||
mountPath: /opt/provisioning
|
||||
readOnly: true
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: api
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: api
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
volumes:
|
||||
- name: state
|
||||
persistentVolumeClaim:
|
||||
claimName: backup-manager-state
|
||||
- name: definitions
|
||||
configMap:
|
||||
name: backup-manager-definitions
|
||||
optional: true
|
||||
74
components/backup_manager/cluster/templates/service.yaml.j2
Normal file
74
components/backup_manager/cluster/templates/service.yaml.j2
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: backup-manager
|
||||
namespace: {{ namespace }}
|
||||
labels:
|
||||
app: backup-manager
|
||||
app.kubernetes.io/name: backup-manager
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: backup-manager
|
||||
ports:
|
||||
- name: api
|
||||
port: {{ daemon_http_port }}
|
||||
targetPort: api
|
||||
protocol: TCP
|
||||
- name: metrics
|
||||
port: {{ daemon_metrics_port }}
|
||||
targetPort: metrics
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: backup-manager
|
||||
namespace: {{ namespace }}
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: backup-manager
|
||||
rules:
|
||||
# Read state of every workload to know what to back up.
|
||||
- apiGroups: [""]
|
||||
resources: ["pods", "services", "configmaps", "secrets", "persistentvolumeclaims", "namespaces", "events"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
# Drive Longhorn VolumeSnapshot CRD for csi_consistent_group coordination.
|
||||
- apiGroups: ["snapshot.storage.k8s.io"]
|
||||
resources: ["volumesnapshots", "volumesnapshotclasses", "volumesnapshotcontents"]
|
||||
verbs: ["get", "list", "watch", "create", "delete"]
|
||||
# Manage CronJobs for one-shot scheduling.
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["cronjobs", "jobs"]
|
||||
verbs: ["get", "list", "watch", "create", "patch", "update", "delete"]
|
||||
# Pod exec for quiesce hooks.
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/exec", "pods/log"]
|
||||
verbs: ["create", "get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: backup-manager
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: backup-manager
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: backup-manager
|
||||
namespace: {{ namespace }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: backup-manager-state
|
||||
namespace: {{ namespace }}
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
storageClassName: hcloud-volumes
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: backup-manager
|
||||
namespace: {{ namespace }}
|
||||
labels:
|
||||
app: backup-manager
|
||||
release: prometheus
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: backup-manager
|
||||
namespaceSelector:
|
||||
matchNames:
|
||||
- {{ namespace }}
|
||||
endpoints:
|
||||
- port: metrics
|
||||
interval: 30s
|
||||
path: /metrics
|
||||
scrapeTimeout: 10s
|
||||
25
components/backup_manager/metadata.ncl
Normal file
25
components/backup_manager/metadata.ncl
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Backup Manager component metadata.
|
||||
# Deploys the prvng-backup daemon (cluster mode) and renders K8s CronJob /
|
||||
# system cron / systemd timer artefacts for every BackupPolicy / BackupGroup /
|
||||
# SystemBackupDef in the workspace.
|
||||
|
||||
{
|
||||
name = "backup_manager",
|
||||
version = "0.1.0",
|
||||
category = "service",
|
||||
description = "Multi-context backup orchestrator daemon and on-host runner. Restic-first with kopia opt-in; reads Nickel-declared policies, replicates to ≥2 destinations, integrates with secretumvault and platform-nats.",
|
||||
dependencies = ["secretumvault", "platform_nats"],
|
||||
provides = [{ id = "backup-manager", version = "0.1.0", interface = "service/backup-manager" }],
|
||||
requires = [
|
||||
{ capability = "kubernetes-cluster", kind = 'Required },
|
||||
{ capability = "object-storage-s3", kind = 'Required },
|
||||
{ capability = "secret-store-vault", kind = 'Required },
|
||||
],
|
||||
conflicts_with = ["velero", "k10", "stash"],
|
||||
tags = ["backup", "restic", "kopia", "ontoref", "service"],
|
||||
best_practices = [
|
||||
"bp_016", # Encrypt Data at Rest
|
||||
"bp_032", # Data Replication
|
||||
"bp_033", # Backup and Recovery
|
||||
],
|
||||
}
|
||||
91
components/backup_manager/nickel/contracts.ncl
Normal file
91
components/backup_manager/nickel/contracts.ncl
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Contract for the backup_manager component instance configuration.
|
||||
# Consumed by the cluster install script via env-backup_manager.j2.
|
||||
|
||||
{
|
||||
BackupManager = {
|
||||
name | String,
|
||||
namespace | String | doc "K8s namespace for the daemon Deployment" | default = "backup-system",
|
||||
mode | [| 'cluster |] | default = 'cluster,
|
||||
|
||||
# Daemon Deployment parameters
|
||||
daemon_replicas | Number | doc "Single replica today (HA via leader election deferred)" | default = 1,
|
||||
daemon_image | String,
|
||||
daemon_http_port | Number | default = 9099,
|
||||
daemon_metrics_port | Number | default = 9100,
|
||||
|
||||
# Mount Pod (ephemeral, not 24/7)
|
||||
mount_image | String,
|
||||
|
||||
# Vault wiring (ADR-017)
|
||||
vault_endpoint | String,
|
||||
vault_bootstrap_secret_ref | String | doc "K8s Secret holding the bootstrap NKey/JWT for first-time vault auth",
|
||||
|
||||
# NATS wiring (ADR-016)
|
||||
nats_url | String,
|
||||
nats_topology_ref | String | doc "Path to the NATS topology Nickel file (declarative streams + consumers)",
|
||||
|
||||
# Workspace policy paths (consumed by daemon at startup + on watcher reload)
|
||||
policies = {
|
||||
components_path | String | doc "Glob pattern for component .ncl files" | default = "infra/libre-wuji/components/*.ncl",
|
||||
groups_path | String | doc "Path to backup-groups.ncl" | default = "infra/libre-wuji/backup-groups.ncl",
|
||||
system_backups_path | String | doc "Path to system-backups.ncl" | default = "infra/libre-wuji/system-backups.ncl",
|
||||
verify_recipes_path | String | doc "Directory with DrillSpec recipes" | default = "infra/libre-wuji/verify-recipes",
|
||||
nickel_import_path | String | default = "/opt/provisioning",
|
||||
},
|
||||
|
||||
# Concurrency budgets
|
||||
concurrency = {
|
||||
max_parallel_backups | Number | default = 4,
|
||||
max_parallel_per_destination | Number | default = 2,
|
||||
},
|
||||
|
||||
# On-host installation: where to deploy the binary for SystemBackupDef
|
||||
# consumers (etcd, k8s_certs, vault_state, host_configs). The wrapper
|
||||
# provisioning installer copies the prvng-backup binary via SSH.
|
||||
host_install = {
|
||||
binary_path | String | default = "/usr/local/bin/prvng-backup",
|
||||
cron_dir | String | default = "/etc/cron.d",
|
||||
systemd_dir | String | default = "/etc/systemd/system",
|
||||
env_dir | String | default = "/etc/prvng-backup",
|
||||
},
|
||||
|
||||
# Required Secrets injected into the daemon Pod (via envFrom.secretRef).
|
||||
# Contents are populated at install time by the wrapper from SOPS+Age
|
||||
# bootstrap secrets; the daemon then promotes them into vault for
|
||||
# subsequent runs (ADR-017).
|
||||
requires = {
|
||||
storage | { size | String, persistent | Bool } | default = { size = "5Gi", persistent = true },
|
||||
ports | Array {
|
||||
port | Number,
|
||||
protocol | String | default = "TCP",
|
||||
exposure | [| 'public, 'private, 'internal |] | default = 'internal,
|
||||
} | default = [
|
||||
{ port = 9099, exposure = 'internal },
|
||||
{ port = 9100, exposure = 'internal },
|
||||
],
|
||||
credentials | Array String | default = [
|
||||
"VAULT_BOOTSTRAP_TOKEN",
|
||||
"NATS_BOOTSTRAP_NKEY_SEED",
|
||||
"BACKUP_AGE_KEY", # bootstrap age key for vault_state recovery
|
||||
"BACKUP_S3_PRIMARY_ACCESS_KEY",
|
||||
"BACKUP_S3_PRIMARY_SECRET_KEY",
|
||||
"BACKUP_B2_REPLICA_KEY_ID",
|
||||
"BACKUP_B2_REPLICA_APPLICATION_KEY",
|
||||
],
|
||||
},
|
||||
|
||||
provides = {
|
||||
service | String | default = "backup-manager",
|
||||
port | Number | default = 9099,
|
||||
endpoints | Array String | default = ["/metrics", "/api/v1/policy", "/api/v1/queue"],
|
||||
},
|
||||
|
||||
operations = {
|
||||
install | Bool | default = true,
|
||||
update | Bool | default = true,
|
||||
delete | Bool | default = true,
|
||||
health | Bool | default = true,
|
||||
restart | Bool | default = true,
|
||||
},
|
||||
},
|
||||
}
|
||||
76
components/backup_manager/nickel/defaults.ncl
Normal file
76
components/backup_manager/nickel/defaults.ncl
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Defaults for backup_manager. Override in workspace component declarations
|
||||
# (infra/libre-wuji/components/backup_manager.ncl).
|
||||
|
||||
let version = import "version.ncl" in
|
||||
|
||||
{
|
||||
backup_manager = {
|
||||
name = "backup-manager",
|
||||
namespace = "backup-system",
|
||||
mode = 'cluster,
|
||||
|
||||
daemon_replicas = 1,
|
||||
daemon_image = version.daemon_image,
|
||||
daemon_http_port = 9099,
|
||||
daemon_metrics_port = 9100,
|
||||
|
||||
mount_image = version.mount_image,
|
||||
|
||||
vault_endpoint = "https://vault.libre-wuji.svc.cluster.local:8200",
|
||||
vault_bootstrap_secret_ref = "backup-manager-bootstrap",
|
||||
|
||||
nats_url = "nats://nats.ops-system.svc.cluster.local:4222",
|
||||
nats_topology_ref = "infra/libre-wuji/backup-nats-topology.ncl",
|
||||
|
||||
policies = {
|
||||
components_path = "infra/libre-wuji/components/*.ncl",
|
||||
groups_path = "infra/libre-wuji/backup-groups.ncl",
|
||||
system_backups_path = "infra/libre-wuji/system-backups.ncl",
|
||||
verify_recipes_path = "infra/libre-wuji/verify-recipes",
|
||||
nickel_import_path = "/opt/provisioning",
|
||||
},
|
||||
|
||||
concurrency = {
|
||||
max_parallel_backups = 4,
|
||||
max_parallel_per_destination = 2,
|
||||
},
|
||||
|
||||
host_install = {
|
||||
binary_path = "/usr/local/bin/prvng-backup",
|
||||
cron_dir = "/etc/cron.d",
|
||||
systemd_dir = "/etc/systemd/system",
|
||||
env_dir = "/etc/prvng-backup",
|
||||
},
|
||||
|
||||
requires = {
|
||||
storage = { size = "5Gi", persistent = true },
|
||||
ports = [
|
||||
{ port = 9099, exposure = 'internal },
|
||||
{ port = 9100, exposure = 'internal },
|
||||
],
|
||||
credentials = [
|
||||
"VAULT_BOOTSTRAP_TOKEN",
|
||||
"NATS_BOOTSTRAP_NKEY_SEED",
|
||||
"BACKUP_AGE_KEY",
|
||||
"BACKUP_S3_PRIMARY_ACCESS_KEY",
|
||||
"BACKUP_S3_PRIMARY_SECRET_KEY",
|
||||
"BACKUP_B2_REPLICA_KEY_ID",
|
||||
"BACKUP_B2_REPLICA_APPLICATION_KEY",
|
||||
],
|
||||
},
|
||||
|
||||
provides = {
|
||||
service = "backup-manager",
|
||||
port = 9099,
|
||||
endpoints = ["/metrics", "/api/v1/policy", "/api/v1/queue"],
|
||||
},
|
||||
|
||||
operations = {
|
||||
install = true,
|
||||
update = true,
|
||||
delete = true,
|
||||
health = true,
|
||||
restart = true,
|
||||
},
|
||||
},
|
||||
}
|
||||
14
components/backup_manager/nickel/main.ncl
Normal file
14
components/backup_manager/nickel/main.ncl
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
let contracts_lib = import "contracts.ncl" in
|
||||
let defaults_lib = import "defaults.ncl" in
|
||||
let version = import "version.ncl" in
|
||||
|
||||
{
|
||||
defaults = defaults_lib,
|
||||
|
||||
make_backup_manager | not_exported = fun overrides =>
|
||||
defaults_lib.backup_manager & overrides,
|
||||
|
||||
DefaultBackupManager = defaults_lib.backup_manager,
|
||||
BackupManager | contracts_lib.BackupManager = defaults_lib.backup_manager,
|
||||
Version = version,
|
||||
}
|
||||
9
components/backup_manager/nickel/version.ncl
Normal file
9
components/backup_manager/nickel/version.ncl
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Pinned versions for backup_manager artefacts.
|
||||
{
|
||||
component = "0.1.0",
|
||||
binary = "1.0.11", # crate version (provisioning/platform workspace)
|
||||
restic = "0.16.4", # minimum restic CLI version
|
||||
kopia = "0.18.0", # minimum kopia CLI version (opt-in)
|
||||
daemon_image = "ghcr.io/jesusperezlorenzo/backup-manager-runner:0.1.0",
|
||||
mount_image = "ghcr.io/jesusperezlorenzo/backup-manager-mount:0.1.0",
|
||||
}
|
||||
205
components/buildkit_lite/cluster/buildkit_lite-lib.sh
Executable file
205
components/buildkit_lite/cluster/buildkit_lite-lib.sh
Executable file
|
|
@ -0,0 +1,205 @@
|
|||
#!/bin/bash
|
||||
# Methods library for buildkit_lite — sourced by run-*.sh scripts generated from manifest_plan.
|
||||
# SCRIPT_DIR must be set by the caller before sourcing.
|
||||
|
||||
[ -f "${SCRIPT_DIR}/env-buildkit_lite" ] && source "${SCRIPT_DIR}/env-buildkit_lite"
|
||||
[ -f "${SCRIPT_DIR}/_credentials.env" ] && source "${SCRIPT_DIR}/_credentials.env"
|
||||
|
||||
BUILDKIT_NAMESPACE="${BUILDKIT_NAMESPACE:-build-system}"
|
||||
BUILDKIT_DEPLOYMENT="${BUILDKIT_DEPLOYMENT:-buildkitd}"
|
||||
BUILDKIT_REGISTRY_SECRET="${BUILDKIT_REGISTRY_SECRET:-zot-credentials}"
|
||||
|
||||
_kubectl() {
|
||||
if command -v kubectl >/dev/null 2>&1; then
|
||||
command kubectl "$@"
|
||||
elif command -v k0s >/dev/null 2>&1; then
|
||||
k0s kubectl "$@"
|
||||
else
|
||||
echo "ERROR: no kubectl or k0s binary found" >&2; exit 127
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Manifest plan helpers ─────────────────────────────────────────────────────
|
||||
|
||||
# Treat blank/whitespace-only manifests as no-ops — used by conditional templates
|
||||
# (e.g. service-vpn.yaml renders empty when vpn_service.enabled = false).
|
||||
_manifest_is_blank() {
|
||||
local path="$1"
|
||||
[ -f "$path" ] || return 0
|
||||
! grep -q '[^[:space:]]' "$path"
|
||||
}
|
||||
|
||||
_plan_apply() {
|
||||
local file="$1"
|
||||
local path="$SCRIPT_DIR/manifests/${file}.yaml"
|
||||
[ -f "$path" ] || { echo " [skip] $file.yaml not in manifests/"; return 0; }
|
||||
if _manifest_is_blank "$path"; then
|
||||
echo " [skip] $file.yaml is empty (template inactive)"
|
||||
return 0
|
||||
fi
|
||||
_kubectl apply -f "$path"
|
||||
}
|
||||
|
||||
_plan_apply_skip() {
|
||||
local file="$1"
|
||||
local path="$SCRIPT_DIR/manifests/${file}.yaml"
|
||||
[ -f "$path" ] || { echo " [skip] $file.yaml not in manifests/"; return 0; }
|
||||
if _manifest_is_blank "$path"; then
|
||||
echo " [skip] $file.yaml is empty (template inactive)"
|
||||
return 0
|
||||
fi
|
||||
if _kubectl get -f "$path" >/dev/null 2>&1; then
|
||||
echo " [skip] $file already exists in cluster"
|
||||
return 0
|
||||
fi
|
||||
_kubectl apply -f "$path"
|
||||
}
|
||||
|
||||
_plan_rollout_restart() {
|
||||
local file="$1"
|
||||
local path="$SCRIPT_DIR/manifests/${file}.yaml"
|
||||
[ -f "$path" ] || { echo " [skip] $file.yaml not in manifests/"; return 0; }
|
||||
if _manifest_is_blank "$path"; then
|
||||
echo " [skip] $file.yaml is empty (template inactive)"
|
||||
return 0
|
||||
fi
|
||||
_kubectl rollout restart -f "$path"
|
||||
}
|
||||
|
||||
_plan_delete() {
|
||||
local file="$1"
|
||||
local path="$SCRIPT_DIR/manifests/${file}.yaml"
|
||||
[ -f "$path" ] || { echo " [skip] $file.yaml not in manifests/"; return 0; }
|
||||
if _manifest_is_blank "$path"; then
|
||||
echo " [skip] $file.yaml is empty (template inactive)"
|
||||
return 0
|
||||
fi
|
||||
_kubectl delete -f "$path" --ignore-not-found
|
||||
}
|
||||
|
||||
# ── Component methods ─────────────────────────────────────────────────────────
|
||||
|
||||
_method_create-credentials() {
|
||||
if _kubectl get secret "$BUILDKIT_REGISTRY_SECRET" \
|
||||
--namespace "$BUILDKIT_NAMESPACE" >/dev/null 2>&1; then
|
||||
echo " [skip] secret $BUILDKIT_REGISTRY_SECRET already in $BUILDKIT_NAMESPACE"
|
||||
return 0
|
||||
fi
|
||||
# Prefer credentials injected from SOPS at deploy time (REGISTRY_HOST/USER/PASS).
|
||||
if [ -n "${REGISTRY_USER:-}" ] && [ -n "${REGISTRY_PASS:-}" ]; then
|
||||
local host="${REGISTRY_HOST:-daoreg.librecloud.online}"
|
||||
local auth
|
||||
auth=$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_PASS" | base64 | tr -d '\n')
|
||||
local config
|
||||
config=$(printf '{"auths":{"%s":{"auth":"%s"}}}' "$host" "$auth")
|
||||
_kubectl create secret generic "$BUILDKIT_REGISTRY_SECRET" \
|
||||
--namespace "$BUILDKIT_NAMESPACE" \
|
||||
--from-literal=config.json="$config" \
|
||||
--dry-run=client -o yaml | _kubectl apply -f -
|
||||
echo " Created $BUILDKIT_REGISTRY_SECRET in $BUILDKIT_NAMESPACE from injected credentials"
|
||||
return 0
|
||||
fi
|
||||
# Fallback: copy pre-existing docker config secret from registry namespace.
|
||||
if _kubectl get secret "$BUILDKIT_REGISTRY_SECRET" \
|
||||
--namespace registry >/dev/null 2>&1; then
|
||||
_kubectl get secret "$BUILDKIT_REGISTRY_SECRET" \
|
||||
--namespace registry -o yaml \
|
||||
| sed "s/namespace: registry/namespace: $BUILDKIT_NAMESPACE/" \
|
||||
| _kubectl apply -f -
|
||||
echo " Copied $BUILDKIT_REGISTRY_SECRET from registry → $BUILDKIT_NAMESPACE"
|
||||
return 0
|
||||
fi
|
||||
echo " WARNING: no registry credentials available — buildkitd will have no registry auth" >&2
|
||||
}
|
||||
|
||||
_method_wait-ready() {
|
||||
local timeout="${1:-120}"
|
||||
_kubectl rollout status deployment/"$BUILDKIT_DEPLOYMENT" \
|
||||
--namespace "$BUILDKIT_NAMESPACE" \
|
||||
--timeout="${timeout}s"
|
||||
}
|
||||
|
||||
# ── mTLS client-cert minting (cert-manager-driven) ────────────────────────────
|
||||
#
|
||||
# Source of truth: runtime-manifests/mtls-client-certificate.yaml.tmpl
|
||||
# This helper renders that template with envsubst → kubectl apply.
|
||||
# The resulting Certificate CR persists in cluster etcd (cert-manager rotates it
|
||||
# automatically); subsequent issue calls for the same name are idempotent.
|
||||
#
|
||||
# Preferred path for known consumers: declare a Certificate manifest in the
|
||||
# consumer's own catalog (e.g. lian_build/cluster/manifests/buildkit-client-cert.yaml.j2).
|
||||
# This helper is for ad-hoc operator clients (humans, debug sessions) — the
|
||||
# Certificate it creates still survives redeploys because it lives in etcd.
|
||||
|
||||
_method_issue-client-cert() {
|
||||
local client_name="${1:?client name required}"
|
||||
local output_dir="${2:-./client-${client_name}}"
|
||||
local duration="${3:-${BUILDKIT_CLIENT_DURATION:-2160h}}"
|
||||
local renew="${4:-${BUILDKIT_CLIENT_RENEW:-360h}}"
|
||||
local ca_issuer_name="${BUILDKIT_MTLS_CA_ISSUER:-buildkit-ca-issuer}"
|
||||
local secret_name="buildkit-client-${client_name}-tls"
|
||||
local tmpl="${SCRIPT_DIR}/runtime-manifests/mtls-client-certificate.yaml.tmpl"
|
||||
|
||||
if [ ! -f "$tmpl" ]; then
|
||||
echo "ERROR: client cert template missing: $tmpl" >&2
|
||||
return 1
|
||||
fi
|
||||
if ! _kubectl get issuer "$ca_issuer_name" --namespace "$BUILDKIT_NAMESPACE" >/dev/null 2>&1; then
|
||||
echo "ERROR: Issuer $ca_issuer_name not found in $BUILDKIT_NAMESPACE — enable mtls.cert_manager first" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
BUILDKIT_CLIENT_NAME="$client_name" \
|
||||
BUILDKIT_NAMESPACE="$BUILDKIT_NAMESPACE" \
|
||||
BUILDKIT_MTLS_CA_ISSUER="$ca_issuer_name" \
|
||||
BUILDKIT_CLIENT_DURATION="$duration" \
|
||||
BUILDKIT_CLIENT_RENEW="$renew" \
|
||||
envsubst < "$tmpl" | _kubectl apply -f -
|
||||
|
||||
_method_export-client-cert "$client_name" "$output_dir"
|
||||
}
|
||||
|
||||
# Reads an existing buildkit-client-<name>-tls secret and dumps the contents
|
||||
# (ca.crt, tls.crt, tls.key) to $output_dir for buildctl consumption.
|
||||
# Waits up to 60s for cert-manager to populate the secret on first call.
|
||||
_method_export-client-cert() {
|
||||
local client_name="${1:?client name required}"
|
||||
local output_dir="${2:-./client-${client_name}}"
|
||||
local secret_name="buildkit-client-${client_name}-tls"
|
||||
mkdir -p "$output_dir"
|
||||
|
||||
local attempts=30
|
||||
while [ "$attempts" -gt 0 ]; do
|
||||
if _kubectl get secret "$secret_name" --namespace "$BUILDKIT_NAMESPACE" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
attempts=$((attempts - 1))
|
||||
done
|
||||
if [ "$attempts" -eq 0 ]; then
|
||||
echo "ERROR: secret $secret_name not found in $BUILDKIT_NAMESPACE — issue the Certificate first" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
_kubectl get secret "$secret_name" --namespace "$BUILDKIT_NAMESPACE" \
|
||||
-o jsonpath='{.data.ca\.crt}' | base64 -d > "$output_dir/ca.crt"
|
||||
_kubectl get secret "$secret_name" --namespace "$BUILDKIT_NAMESPACE" \
|
||||
-o jsonpath='{.data.tls\.crt}' | base64 -d > "$output_dir/tls.crt"
|
||||
_kubectl get secret "$secret_name" --namespace "$BUILDKIT_NAMESPACE" \
|
||||
-o jsonpath='{.data.tls\.key}' | base64 -d > "$output_dir/tls.key"
|
||||
chmod 0600 "$output_dir/tls.key"
|
||||
echo " Client cert for '${client_name}' exported to $output_dir"
|
||||
echo " Use with: buildctl --tlscacert=$output_dir/ca.crt --tlscert=$output_dir/tls.crt --tlskey=$output_dir/tls.key --addr tcp://build.librecloud.online:31234 ..."
|
||||
}
|
||||
|
||||
# Deletes the Certificate CR (and its Secret). For Certificates declared in
|
||||
# a consumer's catalog (e.g. lian_build), prefer removing the manifest from the
|
||||
# catalog and re-running the consumer's install — this is for ad-hoc clients only.
|
||||
_method_revoke-client-cert() {
|
||||
local client_name="${1:?client name required}"
|
||||
local cert_name="buildkit-client-${client_name}"
|
||||
local secret_name="${cert_name}-tls"
|
||||
_kubectl delete certificate "$cert_name" --namespace "$BUILDKIT_NAMESPACE" --ignore-not-found
|
||||
_kubectl delete secret "$secret_name" --namespace "$BUILDKIT_NAMESPACE" --ignore-not-found
|
||||
echo " Revoked client cert for '${client_name}'"
|
||||
}
|
||||
13
components/buildkit_lite/cluster/env-buildkit_lite.j2
Normal file
13
components/buildkit_lite/cluster/env-buildkit_lite.j2
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
BUILDKIT_NAMESPACE={{ taskserv.namespace | default(value="build-system") }}
|
||||
BUILDKIT_DEPLOYMENT={{ taskserv.deployment | default(value="buildkitd") }}
|
||||
BUILDKIT_IMAGE={{ taskserv.image | default(value="moby/buildkit:v0.29.0") }}
|
||||
BUILDKIT_PORT={{ taskserv.port | default(value=1234) }}
|
||||
BUILDKIT_REGISTRY_SECRET={{ taskserv.registry_secret | default(value="zot-credentials") }}
|
||||
BUILDKIT_VPN_ENABLED={{ taskserv.vpn_service.enabled | default(value=false) }}
|
||||
BUILDKIT_VPN_NODEPORT={{ taskserv.vpn_service.nodeport | default(value=31234) }}
|
||||
BUILDKIT_MTLS_ENABLED={{ taskserv.mtls.enabled | default(value=false) }}
|
||||
BUILDKIT_MTLS_CA_SECRET={{ taskserv.mtls.ca_secret | default(value="buildkit-mtls-ca") }}
|
||||
BUILDKIT_MTLS_SERVER_SECRET={{ taskserv.mtls.server_cert_secret | default(value="buildkit-mtls-server") }}
|
||||
BUILDKIT_MTLS_CERT_MANAGER_ENABLED={{ taskserv.mtls.cert_manager.enabled | default(value=false) }}
|
||||
BUILDKIT_MTLS_CLUSTER_ISSUER={{ taskserv.mtls.cert_manager.cluster_issuer_name | default(value="buildkit-selfsigned-issuer") }}
|
||||
BUILDKIT_MTLS_CA_ISSUER={{ taskserv.mtls.cert_manager.ca_issuer_name | default(value="buildkit-ca-issuer") }}
|
||||
184
components/buildkit_lite/cluster/install-buildkit_lite.sh
Executable file
184
components/buildkit_lite/cluster/install-buildkit_lite.sh
Executable file
|
|
@ -0,0 +1,184 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
[ -f "${SCRIPT_DIR}/env-buildkit_lite" ] && source "${SCRIPT_DIR}/env-buildkit_lite"
|
||||
[ -f "${SCRIPT_DIR}/_credentials.env" ] && source "${SCRIPT_DIR}/_credentials.env"
|
||||
|
||||
BUILDKIT_NAMESPACE="${BUILDKIT_NAMESPACE:-build-system}"
|
||||
BUILDKIT_DEPLOYMENT="${BUILDKIT_DEPLOYMENT:-buildkitd}"
|
||||
BUILDKIT_IMAGE="${BUILDKIT_IMAGE:-moby/buildkit:v0.29.0}"
|
||||
BUILDKIT_PORT="${BUILDKIT_PORT:-1234}"
|
||||
BUILDKIT_REGISTRY_SECRET="${BUILDKIT_REGISTRY_SECRET:-zot-credentials}"
|
||||
CMD_TSK="${CMD_TSK:-${1:-install}}"
|
||||
|
||||
_resolve_kubeconfig() {
|
||||
if [ -n "${KUBECONFIG:-}" ] && [ -f "$KUBECONFIG" ]; then
|
||||
return
|
||||
fi
|
||||
for candidate in \
|
||||
/var/lib/k0s/pki/admin.conf \
|
||||
/etc/kubernetes/admin.conf \
|
||||
/root/.kube/config; do
|
||||
if [ -f "$candidate" ]; then
|
||||
export KUBECONFIG="$candidate"
|
||||
return
|
||||
fi
|
||||
done
|
||||
if command -v k0s >/dev/null 2>&1; then
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
k0s kubeconfig admin > "$tmp" 2>/dev/null && export KUBECONFIG="$tmp" && return
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
echo "ERROR: kubeconfig not found — set KUBECONFIG explicitly" >&2; exit 1
|
||||
}
|
||||
_resolve_kubeconfig
|
||||
|
||||
_kubectl() {
|
||||
if command -v kubectl >/dev/null 2>&1; then
|
||||
command kubectl "$@"
|
||||
elif command -v k0s >/dev/null 2>&1; then
|
||||
k0s kubectl "$@"
|
||||
else
|
||||
echo "ERROR: no kubectl or k0s binary found" >&2; exit 127
|
||||
fi
|
||||
}
|
||||
|
||||
_wait_for_deploy() {
|
||||
local timeout="${1:-120}"
|
||||
_kubectl rollout status deployment/"$BUILDKIT_DEPLOYMENT" \
|
||||
--namespace "$BUILDKIT_NAMESPACE" \
|
||||
--timeout="${timeout}s"
|
||||
}
|
||||
|
||||
_ensure_registry_secret() {
|
||||
if _kubectl get secret "$BUILDKIT_REGISTRY_SECRET" \
|
||||
--namespace "$BUILDKIT_NAMESPACE" >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
if _kubectl get secret "$BUILDKIT_REGISTRY_SECRET" \
|
||||
--namespace registry >/dev/null 2>&1; then
|
||||
_kubectl get secret "$BUILDKIT_REGISTRY_SECRET" \
|
||||
--namespace registry -o yaml \
|
||||
| sed "s/namespace: registry/namespace: $BUILDKIT_NAMESPACE/" \
|
||||
| _kubectl apply -f -
|
||||
echo "Copied $BUILDKIT_REGISTRY_SECRET from registry namespace"
|
||||
else
|
||||
echo "WARNING: secret $BUILDKIT_REGISTRY_SECRET not found — buildkitd will have no registry auth" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
_apply_if_present() {
|
||||
local path="$1"
|
||||
[ -f "$path" ] || return 0
|
||||
if ! grep -q '[^[:space:]]' "$path"; then
|
||||
echo " [skip] $(basename "$path") is empty (template inactive)"
|
||||
return 0
|
||||
fi
|
||||
_kubectl apply -f "$path"
|
||||
}
|
||||
|
||||
_delete_if_present() {
|
||||
local path="$1"
|
||||
[ -f "$path" ] || return 0
|
||||
if ! grep -q '[^[:space:]]' "$path"; then
|
||||
return 0
|
||||
fi
|
||||
_kubectl delete -f "$path" --ignore-not-found
|
||||
}
|
||||
|
||||
_wait_for_secret() {
|
||||
local secret="$1"
|
||||
local timeout="${2:-60}"
|
||||
local elapsed=0
|
||||
while [ "$elapsed" -lt "$timeout" ]; do
|
||||
if _kubectl get secret "$secret" --namespace "$BUILDKIT_NAMESPACE" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
echo "WARNING: secret $secret not issued within ${timeout}s — buildkitd will fail until cert-manager catches up" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
_apply_pki_if_enabled() {
|
||||
# Templates render empty when mtls.cert_manager.enabled is false → blank-skip.
|
||||
_apply_if_present "$SCRIPT_DIR/templates/mtls-clusterissuer.yaml"
|
||||
_apply_if_present "$SCRIPT_DIR/templates/mtls-ca-certificate.yaml"
|
||||
# CA Issuer depends on the CA secret being populated by cert-manager.
|
||||
if [ -f "$SCRIPT_DIR/templates/mtls-ca-certificate.yaml" ] \
|
||||
&& grep -q '[^[:space:]]' "$SCRIPT_DIR/templates/mtls-ca-certificate.yaml"; then
|
||||
_wait_for_secret "${BUILDKIT_MTLS_CA_SECRET:-buildkit-mtls-ca}" 60 || true
|
||||
fi
|
||||
_apply_if_present "$SCRIPT_DIR/templates/mtls-ca-issuer.yaml"
|
||||
_apply_if_present "$SCRIPT_DIR/templates/mtls-server-certificate.yaml"
|
||||
if [ -f "$SCRIPT_DIR/templates/mtls-server-certificate.yaml" ] \
|
||||
&& grep -q '[^[:space:]]' "$SCRIPT_DIR/templates/mtls-server-certificate.yaml"; then
|
||||
_wait_for_secret "${BUILDKIT_MTLS_SERVER_SECRET:-buildkit-mtls-server}" 60 || true
|
||||
fi
|
||||
}
|
||||
|
||||
_do_install() {
|
||||
_kubectl apply -f "$SCRIPT_DIR/templates/namespace.yaml"
|
||||
_apply_pki_if_enabled
|
||||
_ensure_registry_secret
|
||||
# cache PVC (renders empty unless cache.enabled) — must exist before the Deployment mounts it
|
||||
_apply_if_present "$SCRIPT_DIR/templates/pvc.yaml"
|
||||
_kubectl apply -f "$SCRIPT_DIR/templates/buildkitd-config.yaml"
|
||||
_kubectl apply -f "$SCRIPT_DIR/templates/deployment.yaml"
|
||||
_kubectl apply -f "$SCRIPT_DIR/templates/service.yaml"
|
||||
_apply_if_present "$SCRIPT_DIR/templates/service-vpn.yaml"
|
||||
echo "Waiting for buildkitd to be ready..."
|
||||
_wait_for_deploy 120
|
||||
echo "buildkit_lite installed in namespace $BUILDKIT_NAMESPACE"
|
||||
}
|
||||
|
||||
_do_update() {
|
||||
_apply_pki_if_enabled
|
||||
_apply_if_present "$SCRIPT_DIR/templates/pvc.yaml"
|
||||
_kubectl apply -f "$SCRIPT_DIR/templates/buildkitd-config.yaml"
|
||||
_kubectl apply -f "$SCRIPT_DIR/templates/deployment.yaml"
|
||||
_apply_if_present "$SCRIPT_DIR/templates/service-vpn.yaml"
|
||||
_kubectl rollout restart deployment/"$BUILDKIT_DEPLOYMENT" --namespace "$BUILDKIT_NAMESPACE"
|
||||
echo "Waiting for buildkitd to restart..."
|
||||
_wait_for_deploy 120
|
||||
echo "buildkit_lite updated"
|
||||
}
|
||||
|
||||
_do_delete() {
|
||||
_kubectl delete -f "$SCRIPT_DIR/templates/deployment.yaml" --ignore-not-found
|
||||
_kubectl delete -f "$SCRIPT_DIR/templates/service.yaml" --ignore-not-found
|
||||
_delete_if_present "$SCRIPT_DIR/templates/service-vpn.yaml"
|
||||
_delete_if_present "$SCRIPT_DIR/templates/mtls-server-certificate.yaml"
|
||||
_delete_if_present "$SCRIPT_DIR/templates/mtls-ca-issuer.yaml"
|
||||
_delete_if_present "$SCRIPT_DIR/templates/mtls-ca-certificate.yaml"
|
||||
# mtls-clusterissuer is cluster-scoped and shareable — never delete it from a component teardown.
|
||||
echo "buildkit_lite removed from namespace $BUILDKIT_NAMESPACE"
|
||||
}
|
||||
|
||||
_do_health() {
|
||||
local ready
|
||||
ready=$(_kubectl get deployment "$BUILDKIT_DEPLOYMENT" \
|
||||
--namespace "$BUILDKIT_NAMESPACE" \
|
||||
-o jsonpath='{.status.readyReplicas}' 2>/dev/null || echo "0")
|
||||
if [ "${ready:-0}" -ge 1 ]; then
|
||||
echo "HEALTHY: $BUILDKIT_DEPLOYMENT $ready/$ready ready"
|
||||
else
|
||||
echo "UNHEALTHY: $BUILDKIT_DEPLOYMENT not ready in namespace $BUILDKIT_NAMESPACE" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
case "$CMD_TSK" in
|
||||
install) _do_install ;;
|
||||
update) _do_update ;;
|
||||
delete) _do_delete ;;
|
||||
health) _do_health ;;
|
||||
*)
|
||||
echo "ERROR: unknown CMD_TSK='$CMD_TSK'. Valid: install|update|delete|health" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
43
components/buildkit_lite/cluster/manifest_plan.ncl
Normal file
43
components/buildkit_lite/cluster/manifest_plan.ncl
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
let mp = import "schemas/lib/manifest_plan.ncl" in
|
||||
|
||||
{
|
||||
manifest_plan | mp.ManifestPlan = {
|
||||
init = [
|
||||
{ file = "namespace", action = 'apply, skip_if_exists = true },
|
||||
# cert-manager PKI bootstrap (skipped if mtls.cert_manager.enabled = false — templates render empty)
|
||||
{ file = "mtls-clusterissuer", action = 'apply },
|
||||
{ file = "mtls-ca-certificate", action = 'apply },
|
||||
{ file = "mtls-ca-issuer", action = 'apply, delay = 2 },
|
||||
{ file = "mtls-server-certificate", action = 'apply, delay = 2 },
|
||||
{ action = 'create-credentials },
|
||||
# cache PVC (renders empty unless cache.enabled) — must exist before the Deployment mounts it
|
||||
{ file = "pvc", action = 'apply, skip_if_exists = true },
|
||||
{ file = "buildkitd-config", action = 'apply },
|
||||
{ file = "deployment", action = 'apply },
|
||||
{ file = "service", action = 'apply },
|
||||
{ file = "service-vpn", action = 'apply },
|
||||
{ action = 'wait-ready },
|
||||
],
|
||||
update = [
|
||||
{ file = "pvc", action = 'apply, skip_if_exists = true },
|
||||
{ file = "mtls-ca-certificate", action = 'apply },
|
||||
{ file = "mtls-server-certificate", action = 'apply },
|
||||
{ file = "buildkitd-config", action = 'apply },
|
||||
{ file = "deployment", action = 'apply },
|
||||
{ file = "service-vpn", action = 'apply },
|
||||
{ file = "deployment", action = 'rollout-restart, delay = 3, post = [{ action = 'wait-ready }] },
|
||||
],
|
||||
delete = [
|
||||
{ file = "deployment", action = 'delete },
|
||||
{ file = "service", action = 'delete },
|
||||
{ file = "service-vpn", action = 'delete },
|
||||
{ file = "mtls-server-certificate", action = 'delete },
|
||||
{ file = "mtls-ca-issuer", action = 'delete },
|
||||
{ file = "mtls-ca-certificate", action = 'delete },
|
||||
# mtls-clusterissuer is cluster-scoped and may be shared — never delete it from a component teardown.
|
||||
],
|
||||
restart = [
|
||||
{ file = "deployment", action = 'rollout-restart, post = [{ action = 'wait-ready }] },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# Runtime-rendered (envsubst) Certificate template — applied on demand by
|
||||
# _method_issue-client-cert <client-name> in buildkit_lite-lib.sh.
|
||||
#
|
||||
# This file is the canonical source of truth for the schema of a buildkit
|
||||
# client identity. Edit here; the lib helper just substitutes env vars.
|
||||
#
|
||||
# Required env vars at envsubst time:
|
||||
# BUILDKIT_CLIENT_NAME — short identity ("lian-build", "ops-jpl", "woodpecker")
|
||||
# BUILDKIT_NAMESPACE — namespace where the CA Issuer lives
|
||||
# BUILDKIT_MTLS_CA_ISSUER — name of the Issuer that signs leaf client certs
|
||||
# BUILDKIT_CLIENT_DURATION — leaf cert validity (e.g. 2160h)
|
||||
# BUILDKIT_CLIENT_RENEW — renewal lead time (e.g. 360h)
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: buildkit-client-${BUILDKIT_CLIENT_NAME}
|
||||
namespace: ${BUILDKIT_NAMESPACE}
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
app.kubernetes.io/component: buildkit-mtls-client
|
||||
librecloud.online/buildkit-client: "${BUILDKIT_CLIENT_NAME}"
|
||||
spec:
|
||||
commonName: ${BUILDKIT_CLIENT_NAME}
|
||||
secretName: buildkit-client-${BUILDKIT_CLIENT_NAME}-tls
|
||||
duration: ${BUILDKIT_CLIENT_DURATION}
|
||||
renewBefore: ${BUILDKIT_CLIENT_RENEW}
|
||||
privateKey:
|
||||
algorithm: ECDSA
|
||||
size: 256
|
||||
rotationPolicy: Always
|
||||
usages:
|
||||
- client auth
|
||||
- digital signature
|
||||
- key encipherment
|
||||
issuerRef:
|
||||
name: ${BUILDKIT_MTLS_CA_ISSUER}
|
||||
kind: Issuer
|
||||
group: cert-manager.io
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: buildkitd-config
|
||||
namespace: {{ taskserv.namespace }}
|
||||
data:
|
||||
buildkitd.toml: |
|
||||
[grpc]
|
||||
keepaliveTime = "{{ taskserv.grpc.keepalive_time }}"
|
||||
keepaliveTimeout = "{{ taskserv.grpc.keepalive_timeout }}"
|
||||
{% if taskserv.cache.enabled %}
|
||||
[worker.oci]
|
||||
gc = true
|
||||
gckeepstorage = "{{ taskserv.cache.gc_keep_storage }}"
|
||||
{% endif %}
|
||||
118
components/buildkit_lite/cluster/templates/deployment.yaml.j2
Normal file
118
components/buildkit_lite/cluster/templates/deployment.yaml.j2
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ taskserv.deployment }}
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.deployment }}
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
spec:
|
||||
replicas: {{ taskserv.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ taskserv.deployment }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.deployment }}
|
||||
spec:
|
||||
containers:
|
||||
- name: buildkitd
|
||||
image: {{ taskserv.image }}
|
||||
args:
|
||||
- --config
|
||||
- /etc/buildkit/buildkitd.toml
|
||||
- --addr
|
||||
- unix:///run/buildkit/buildkitd.sock
|
||||
{% if taskserv.mtls.enabled %}
|
||||
- --addr
|
||||
- tcp://0.0.0.0:{{ taskserv.port }}
|
||||
- --tlscacert
|
||||
- {{ taskserv.mtls.cert_mount_path }}/ca.crt
|
||||
- --tlscert
|
||||
- {{ taskserv.mtls.cert_mount_path }}/tls.crt
|
||||
- --tlskey
|
||||
- {{ taskserv.mtls.cert_mount_path }}/tls.key
|
||||
{% else %}
|
||||
- --addr
|
||||
- tcp://0.0.0.0:{{ taskserv.port }}
|
||||
{% endif %}
|
||||
env:
|
||||
- name: DOCKER_CONFIG
|
||||
value: /root/.docker
|
||||
ports:
|
||||
- containerPort: {{ taskserv.port }}
|
||||
protocol: TCP
|
||||
resources:
|
||||
requests:
|
||||
cpu: "{{ taskserv.resources.requests.cpu }}"
|
||||
memory: "{{ taskserv.resources.requests.memory }}"
|
||||
limits:
|
||||
cpu: "{{ taskserv.resources.limits.cpu }}"
|
||||
memory: "{{ taskserv.resources.limits.memory }}"
|
||||
securityContext:
|
||||
privileged: {{ taskserv.privileged }}
|
||||
seccompProfile:
|
||||
type: Unconfined
|
||||
volumeMounts:
|
||||
- name: docker-config
|
||||
mountPath: /root/.docker/config.json
|
||||
subPath: config.json
|
||||
readOnly: true
|
||||
- name: buildkit-socket
|
||||
mountPath: /run/buildkit
|
||||
- name: buildkitd-config
|
||||
mountPath: /etc/buildkit/buildkitd.toml
|
||||
subPath: buildkitd.toml
|
||||
readOnly: true
|
||||
{% if taskserv.cache.enabled %}
|
||||
- name: buildkit-cache
|
||||
mountPath: {{ taskserv.cache.mount_path }}
|
||||
{% endif %}
|
||||
{% if taskserv.mtls.enabled %}
|
||||
- name: buildkit-mtls-ca
|
||||
mountPath: {{ taskserv.mtls.cert_mount_path }}/ca.crt
|
||||
subPath: ca.crt
|
||||
readOnly: true
|
||||
- name: buildkit-mtls-server
|
||||
mountPath: {{ taskserv.mtls.cert_mount_path }}/tls.crt
|
||||
subPath: tls.crt
|
||||
readOnly: true
|
||||
- name: buildkit-mtls-server
|
||||
mountPath: {{ taskserv.mtls.cert_mount_path }}/tls.key
|
||||
subPath: tls.key
|
||||
readOnly: true
|
||||
{% endif %}
|
||||
volumes:
|
||||
- name: docker-config
|
||||
secret:
|
||||
secretName: {{ taskserv.registry_secret | default(value="zot-credentials") }}
|
||||
items:
|
||||
- key: config.json
|
||||
path: config.json
|
||||
- name: buildkit-socket
|
||||
emptyDir: {}
|
||||
{% if taskserv.cache.enabled %}
|
||||
- name: buildkit-cache
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ taskserv.deployment }}-cache
|
||||
{% endif %}
|
||||
- name: buildkitd-config
|
||||
configMap:
|
||||
name: buildkitd-config
|
||||
{% if taskserv.mtls.enabled %}
|
||||
- name: buildkit-mtls-ca
|
||||
secret:
|
||||
secretName: {{ taskserv.mtls.ca_secret }}
|
||||
items:
|
||||
- key: ca.crt
|
||||
path: ca.crt
|
||||
- name: buildkit-mtls-server
|
||||
secret:
|
||||
secretName: {{ taskserv.mtls.server_cert_secret }}
|
||||
items:
|
||||
- key: tls.crt
|
||||
path: tls.crt
|
||||
- key: tls.key
|
||||
path: tls.key
|
||||
{% endif %}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{% if taskserv.mtls.enabled and taskserv.mtls.cert_manager.enabled %}
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: {{ taskserv.mtls.ca_secret }}
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.deployment }}
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
app.kubernetes.io/component: buildkit-mtls-ca
|
||||
spec:
|
||||
isCA: true
|
||||
commonName: {{ taskserv.mtls.cert_manager.ca_common_name }}
|
||||
secretName: {{ taskserv.mtls.ca_secret }}
|
||||
duration: {{ taskserv.mtls.cert_manager.ca_duration }}
|
||||
renewBefore: {{ taskserv.mtls.cert_manager.ca_renew_before }}
|
||||
privateKey:
|
||||
algorithm: ECDSA
|
||||
size: 256
|
||||
issuerRef:
|
||||
name: {{ taskserv.mtls.cert_manager.cluster_issuer_name }}
|
||||
kind: ClusterIssuer
|
||||
group: cert-manager.io
|
||||
{% endif %}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{% if taskserv.mtls.enabled and taskserv.mtls.cert_manager.enabled %}
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Issuer
|
||||
metadata:
|
||||
name: {{ taskserv.mtls.cert_manager.ca_issuer_name }}
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
app.kubernetes.io/component: buildkit-mtls-ca-issuer
|
||||
spec:
|
||||
ca:
|
||||
secretName: {{ taskserv.mtls.ca_secret }}
|
||||
{% endif %}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{% if taskserv.mtls.enabled and taskserv.mtls.cert_manager.enabled %}
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: {{ taskserv.mtls.cert_manager.cluster_issuer_name }}
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
app.kubernetes.io/component: buildkit-mtls-bootstrap
|
||||
spec:
|
||||
selfSigned: {}
|
||||
{% endif %}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{% if taskserv.mtls.enabled and taskserv.mtls.cert_manager.enabled %}
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: {{ taskserv.mtls.server_cert_secret }}
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.deployment }}
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
app.kubernetes.io/component: buildkit-mtls-server
|
||||
spec:
|
||||
commonName: {{ taskserv.mtls.cert_manager.server_common_name }}
|
||||
secretName: {{ taskserv.mtls.server_cert_secret }}
|
||||
duration: {{ taskserv.mtls.cert_manager.server_duration }}
|
||||
renewBefore: {{ taskserv.mtls.cert_manager.server_renew_before }}
|
||||
privateKey:
|
||||
algorithm: ECDSA
|
||||
size: 256
|
||||
rotationPolicy: Always
|
||||
usages:
|
||||
- server auth
|
||||
- digital signature
|
||||
- key encipherment
|
||||
dnsNames:
|
||||
{% for dns in taskserv.mtls.cert_manager.server_dns_names %}
|
||||
- {{ dns }}
|
||||
{% endfor %}
|
||||
{% if taskserv.mtls.cert_manager.server_ip_sans | length > 0 %}
|
||||
ipAddresses:
|
||||
{% for ip in taskserv.mtls.cert_manager.server_ip_sans %}
|
||||
- {{ ip }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
issuerRef:
|
||||
name: {{ taskserv.mtls.cert_manager.ca_issuer_name }}
|
||||
kind: Issuer
|
||||
group: cert-manager.io
|
||||
{% endif %}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
17
components/buildkit_lite/cluster/templates/pvc.yaml.j2
Normal file
17
components/buildkit_lite/cluster/templates/pvc.yaml.j2
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{% if taskserv.cache.enabled %}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ taskserv.deployment }}-cache
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.deployment }}
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: {{ taskserv.cache.storage_class }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ taskserv.cache.size }}
|
||||
{% endif %}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{% if taskserv.vpn_service.enabled %}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ taskserv.deployment }}-{{ taskserv.vpn_service.service_name }}
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.deployment }}
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
app.kubernetes.io/component: vpn
|
||||
annotations:
|
||||
librecloud.online/exposure: "vpn-private"
|
||||
{% if taskserv.mtls.enabled %}
|
||||
librecloud.online/mtls: "enabled"
|
||||
{% else %}
|
||||
librecloud.online/mtls: "disabled"
|
||||
{% endif %}
|
||||
spec:
|
||||
type: NodePort
|
||||
externalTrafficPolicy: Local
|
||||
{% if taskserv.vpn_service.external_ips | length > 0 %}
|
||||
externalIPs:
|
||||
{% for ip in taskserv.vpn_service.external_ips %}
|
||||
- {{ ip }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
selector:
|
||||
app.kubernetes.io/name: {{ taskserv.deployment }}
|
||||
ports:
|
||||
- name: buildkit-tls
|
||||
port: {{ taskserv.port }}
|
||||
targetPort: {{ taskserv.port }}
|
||||
nodePort: {{ taskserv.vpn_service.nodeport }}
|
||||
protocol: TCP
|
||||
{% endif %}
|
||||
16
components/buildkit_lite/cluster/templates/service.yaml.j2
Normal file
16
components/buildkit_lite/cluster/templates/service.yaml.j2
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ taskserv.deployment }}
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.deployment }}
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: {{ taskserv.deployment }}
|
||||
ports:
|
||||
- port: {{ taskserv.port }}
|
||||
targetPort: {{ taskserv.port }}
|
||||
protocol: TCP
|
||||
name: buildkit
|
||||
77
components/buildkit_lite/nickel/contracts.ncl
Normal file
77
components/buildkit_lite/nickel/contracts.ncl
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
let _context_lib = import "schemas/catalog/context.ncl" in
|
||||
|
||||
{
|
||||
BuildkitLite = {
|
||||
namespace | String,
|
||||
deployment | String,
|
||||
port | std.number.Nat,
|
||||
image | String,
|
||||
replicas | std.number.Nat,
|
||||
resources | {
|
||||
requests | { cpu | String, memory | String },
|
||||
limits | { cpu | String, memory | String },
|
||||
},
|
||||
privileged | Bool,
|
||||
|
||||
requires | {
|
||||
capabilities | Array String | default = [],
|
||||
} | default = {},
|
||||
|
||||
provides | {
|
||||
service | String | optional,
|
||||
interface | String | optional,
|
||||
} | default = {},
|
||||
|
||||
grpc | {
|
||||
keepalive_time | String | doc "Server→client gRPC ping interval. Keeps NAT entries alive during long builds." | default = "30s",
|
||||
keepalive_timeout | String | doc "Time buildkitd waits for a ping ack before closing the connection." | default = "10s",
|
||||
} | default = {},
|
||||
|
||||
vpn_service | {
|
||||
enabled | Bool | doc "Expose buildkitd through a NodePort reachable on the daoshi node IP (wuwei VPN)." | default = false,
|
||||
nodeport | std.number.Nat | doc "Fixed NodePort in 30000-32767. Used only when enabled." | default = 31234,
|
||||
external_ips | Array String | doc "Optional externalIPs to bind the Service on (use only with single-node clusters)." | default = [],
|
||||
service_name | String | doc "Name suffix for the VPN-facing Service. Final name: <deployment>-vpn." | default = "vpn",
|
||||
} | default = {},
|
||||
|
||||
mtls | {
|
||||
enabled | Bool | doc "Enable mTLS on buildkitd. Required when vpn_service is enabled in any non-trusted network." | default = false,
|
||||
ca_secret | String | doc "K8s Secret (same namespace) with key ca.crt — clients must present a cert signed by this CA." | default = "buildkit-mtls-ca",
|
||||
server_cert_secret | String | doc "K8s Secret (same namespace) with keys tls.crt and tls.key — buildkitd server cert/key." | default = "buildkit-mtls-server",
|
||||
cert_mount_path | String | doc "Mount path inside the buildkitd container for cert material." | default = "/etc/buildkit/certs",
|
||||
|
||||
cert_manager | {
|
||||
enabled | Bool | doc "Provision CA and server cert via cert-manager (SelfSigned ClusterIssuer → CA Certificate → namespaced CA Issuer → server Certificate)." | default = false,
|
||||
cluster_issuer_name | String | doc "Name of the SelfSigned ClusterIssuer (shared, idempotent)." | default = "buildkit-selfsigned-issuer",
|
||||
ca_issuer_name | String | doc "Name of the namespace-scoped CA Issuer that signs leaf certs." | default = "buildkit-ca-issuer",
|
||||
ca_common_name | String | doc "Subject CN on the CA cert." | default = "buildkit-mtls-ca",
|
||||
ca_duration | String | doc "CA validity (ISO 8601 duration). 5 years default." | default = "43800h",
|
||||
ca_renew_before | String | doc "CA renewal lead time." | default = "720h",
|
||||
server_common_name | String | doc "Subject CN on the server leaf cert." | default = "buildkitd",
|
||||
server_dns_names | Array String | doc "DNS SANs on the server cert (at least one VPN hostname recommended)." | default = ["buildkitd"],
|
||||
server_ip_sans | Array String | doc "IP SANs on the server cert (node IP for direct VPN access)." | default = [],
|
||||
server_duration | String | doc "Server cert validity." | default = "2160h",
|
||||
server_renew_before | String | doc "Server cert renewal lead time." | default = "360h",
|
||||
} | default = {},
|
||||
} | default = {},
|
||||
|
||||
operations | {
|
||||
install | Bool | default = true,
|
||||
update | Bool | default = false,
|
||||
delete | Bool | default = false,
|
||||
health | Bool | default = false,
|
||||
} | default = {},
|
||||
|
||||
cache | {
|
||||
enabled | Bool | doc "Back the buildkitd cache dir with a PersistentVolumeClaim instead of the image's ephemeral anonymous VOLUME. The anonymous VOLUME grows unbounded on the node root filesystem and on a control-plane node can fill / and trigger DiskPressure eviction. Requires a working CSI on the target node." | default = false,
|
||||
size | String | doc "Cache PVC size." | default = "10Gi",
|
||||
storage_class | String | doc "StorageClass for the cache PVC. Default local-path (node-local disk) — the buildkit cache is disposable, so a paid cloud volume is unnecessary; gckeepstorage bounds the actual size." | default = "local-path",
|
||||
mount_path | String | doc "buildkitd cache directory (the image's declared VOLUME)." | default = "/var/lib/buildkit",
|
||||
gc_keep_storage | String | doc "buildkitd worker GC ceiling — keeps the on-disk cache under this size. Set below the PVC size to leave headroom for the active build." | default = "8GB",
|
||||
} | default = {},
|
||||
|
||||
context | _context_lib.ComponentContext | optional,
|
||||
|
||||
..
|
||||
},
|
||||
}
|
||||
90
components/buildkit_lite/nickel/defaults.ncl
Normal file
90
components/buildkit_lite/nickel/defaults.ncl
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
let _presets = (import "schemas/lib/concerns_presets.ncl").presets in
|
||||
|
||||
{
|
||||
buildkit_lite | default = {
|
||||
namespace | default = "build-system",
|
||||
deployment | default = "buildkit",
|
||||
port | default = 1234,
|
||||
image | default = "moby/buildkit:v0.13.2",
|
||||
replicas | default = 1,
|
||||
|
||||
resources | default = {
|
||||
requests = { cpu = "500m", memory = "512Mi" },
|
||||
limits = { cpu = "4", memory = "4Gi" },
|
||||
},
|
||||
|
||||
privileged | default = true,
|
||||
registry_secret | default = "zot-credentials",
|
||||
|
||||
live_check | default = {
|
||||
strategy = 'k8s_pods,
|
||||
namespace = "build-system",
|
||||
selector = "buildkit",
|
||||
},
|
||||
|
||||
requires | default = {
|
||||
capabilities = ["k8s-cluster-access", "buildkit-daemon"],
|
||||
},
|
||||
|
||||
provides | default = {
|
||||
service = "buildkit_lite",
|
||||
interface = "buildkit-tcp",
|
||||
},
|
||||
|
||||
grpc | default = {
|
||||
keepalive_time | default = "30s",
|
||||
keepalive_timeout | default = "10s",
|
||||
},
|
||||
|
||||
vpn_service | default = {
|
||||
enabled = false,
|
||||
nodeport = 31234,
|
||||
external_ips = [],
|
||||
service_name = "vpn",
|
||||
},
|
||||
|
||||
mtls | default = {
|
||||
enabled = false,
|
||||
ca_secret = "buildkit-mtls-ca",
|
||||
server_cert_secret = "buildkit-mtls-server",
|
||||
cert_mount_path = "/etc/buildkit/certs",
|
||||
cert_manager = {
|
||||
enabled = false,
|
||||
cluster_issuer_name = "buildkit-selfsigned-issuer",
|
||||
ca_issuer_name = "buildkit-ca-issuer",
|
||||
ca_common_name = "buildkit-mtls-ca",
|
||||
ca_duration = "43800h",
|
||||
ca_renew_before = "720h",
|
||||
server_common_name = "buildkitd",
|
||||
server_dns_names = ["buildkitd"],
|
||||
server_ip_sans = [],
|
||||
server_duration = "2160h",
|
||||
server_renew_before = "360h",
|
||||
},
|
||||
},
|
||||
|
||||
operations | default = {
|
||||
install = true,
|
||||
health = true,
|
||||
},
|
||||
|
||||
# Cache backing for /var/lib/buildkit. Off by default (anonymous image VOLUME);
|
||||
# opt in per instance to use a bounded PVC + buildkitd GC ceiling.
|
||||
cache | default = {
|
||||
enabled = false,
|
||||
size = "10Gi",
|
||||
# local-path by default: the buildkit cache is disposable, so node-local disk is
|
||||
# the right backing — not a paid cloud volume. gckeepstorage is the real size bound.
|
||||
storage_class = "local-path",
|
||||
mount_path = "/var/lib/buildkit",
|
||||
gc_keep_storage = "8GB",
|
||||
},
|
||||
|
||||
# ServiceConcerns — lite builder; no persistent state, registry traffic in-cluster.
|
||||
# tls.kind flips to 'enforced when vpn_service.enabled AND mtls.enabled in the instance overlay.
|
||||
concerns | default = _presets.infrastructure_glue & {
|
||||
tls | force = { kind = 'disabled, reason = "TCP inside cluster; mTLS not required for in-cluster port-forward path" },
|
||||
backup | force = { kind = 'disabled, reason = "no persistent state; BuildKit cache is ephemeral layer storage on the pod" },
|
||||
},
|
||||
},
|
||||
}
|
||||
14
components/buildkit_lite/nickel/main.ncl
Normal file
14
components/buildkit_lite/nickel/main.ncl
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
let contracts_lib = import "contracts.ncl" in
|
||||
let defaults_lib = import "defaults.ncl" in
|
||||
let version = import "version.ncl" in
|
||||
|
||||
{
|
||||
defaults = defaults_lib,
|
||||
|
||||
make_buildkit_lite | not_exported = fun overrides =>
|
||||
defaults_lib.buildkit_lite & overrides,
|
||||
|
||||
DefaultBuildkitLite = defaults_lib.buildkit_lite,
|
||||
BuildkitLite | contracts_lib.BuildkitLite = defaults_lib.buildkit_lite,
|
||||
Version = version,
|
||||
}
|
||||
1
components/buildkit_lite/nickel/version.ncl
Normal file
1
components/buildkit_lite/nickel/version.ncl
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ version = "0.1.0", nickel_api = "1.0" }
|
||||
78
components/buildkit_runner/cloud-init/runner.yaml
Normal file
78
components/buildkit_runner/cloud-init/runner.yaml
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
#cloud-config
|
||||
# BuildKit runner golden image — Debian 13 (Trixie) arm64. Bakes binaries only; runtime config injected at spawn time.
|
||||
# Versions: update here for weekly golden image rebuild cadence (ADR-039 constraint golden-image-rebuild-cadence)
|
||||
# SSH key: injected at server creation by hcloud --ssh-key; cloud-init only hardens sshd policy.
|
||||
# buildkitd runs as root system service — avoids D-Bus/linger issues during cloud-init setup.
|
||||
|
||||
packages:
|
||||
- uidmap
|
||||
- fuse-overlayfs
|
||||
- slirp4netns
|
||||
- wget
|
||||
- ca-certificates
|
||||
- just
|
||||
|
||||
write_files:
|
||||
- path: /etc/ssh/sshd_config.d/10-hardening.conf
|
||||
permissions: "0644"
|
||||
content: |
|
||||
PasswordAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
PermitRootLogin prohibit-password
|
||||
ChallengeResponseAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
|
||||
- path: /etc/systemd/system/buildkitd.service
|
||||
permissions: "0644"
|
||||
content: |
|
||||
[Unit]
|
||||
Description=BuildKit daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
ExecStart=/usr/local/bin/buildkitd --addr unix:///run/buildkit/buildkitd.sock
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
RuntimeDirectory=buildkit
|
||||
RuntimeDirectoryMode=0711
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
runcmd:
|
||||
- |
|
||||
set -eu
|
||||
BUILDKIT_VERSION="v0.29.0"
|
||||
wget -q "https://github.com/moby/buildkit/releases/download/${BUILDKIT_VERSION}/buildkit-${BUILDKIT_VERSION}.linux-arm64.tar.gz" \
|
||||
-O /tmp/buildkit.tar.gz
|
||||
tar -xzf /tmp/buildkit.tar.gz -C /usr/local/
|
||||
rm /tmp/buildkit.tar.gz
|
||||
buildctl --version
|
||||
|
||||
- |
|
||||
set -eu
|
||||
SCCACHE_VERSION="v0.9.1"
|
||||
SCCACHE_ARCHIVE="sccache-${SCCACHE_VERSION}-aarch64-unknown-linux-musl"
|
||||
wget -q "https://github.com/mozilla/sccache/releases/download/${SCCACHE_VERSION}/${SCCACHE_ARCHIVE}.tar.gz" \
|
||||
-O /tmp/sccache.tar.gz
|
||||
tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin/ "${SCCACHE_ARCHIVE}/sccache"
|
||||
chmod +x /usr/local/bin/sccache
|
||||
rm /tmp/sccache.tar.gz
|
||||
sccache --version
|
||||
|
||||
- |
|
||||
set -eu
|
||||
NU_VERSION="0.112.2"
|
||||
NU_ARCHIVE="nu-${NU_VERSION}-aarch64-unknown-linux-gnu"
|
||||
wget -q "https://github.com/nushell/nushell/releases/download/${NU_VERSION}/${NU_ARCHIVE}.tar.gz" \
|
||||
-O /tmp/nu.tar.gz
|
||||
tar -xzf /tmp/nu.tar.gz --strip-components=1 -C /usr/local/bin/ "${NU_ARCHIVE}/nu"
|
||||
chmod +x /usr/local/bin/nu
|
||||
rm /tmp/nu.tar.gz
|
||||
nu --version
|
||||
|
||||
- systemctl daemon-reload
|
||||
- systemctl enable buildkitd
|
||||
- systemctl start buildkitd
|
||||
- systemctl status buildkitd
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
---
|
||||
# Golden runner image self-rebuild pipeline.
|
||||
# Repo: infra/buildkit-runner-golden (Forgejo, libre-daoshi instance)
|
||||
# Schedule: weekly Sunday 03:00 UTC — satisfies ADR-039 constraint golden-image-rebuild-cadence.
|
||||
#
|
||||
# This pipeline runs on a Woodpecker agent (k8s pod in daoshi), not on an
|
||||
# ephemeral runner. The nushell-hcloud tool image provides nu + hcloud CLI.
|
||||
# build-golden-image.nu provisions a fresh seed VM, bakes the snapshot, then
|
||||
# destroys the seed — the ephemeral runner infra is the output, not the vehicle.
|
||||
|
||||
cron:
|
||||
rebuild-golden:
|
||||
schedule: "0 3 * * 0"
|
||||
branch: main
|
||||
|
||||
when:
|
||||
- event: cron
|
||||
cron: rebuild-golden
|
||||
|
||||
steps:
|
||||
rebuild-golden-image:
|
||||
image: reg.librecloud.online/images/nushell-hcloud:latest
|
||||
environment:
|
||||
PROVISIONING_REPO: https://forgejo.librecloud.online/librecloud/provisioning
|
||||
secrets:
|
||||
- hcloud_token
|
||||
- orchestrator_ssh_key_name
|
||||
commands:
|
||||
- git clone --depth 1 $PROVISIONING_REPO /tmp/provisioning
|
||||
- >-
|
||||
HCLOUD_TOKEN=$HCLOUD_TOKEN
|
||||
nu /tmp/provisioning/extensions/components/buildkit_runner/scripts/build-golden-image.nu
|
||||
--location fsn1
|
||||
--server_type cax21
|
||||
--ssh_key $ORCHESTRATOR_SSH_KEY_NAME
|
||||
|
||||
gc-old-snapshots:
|
||||
image: reg.librecloud.online/images/nushell-hcloud:latest
|
||||
secrets:
|
||||
- hcloud_token
|
||||
commands:
|
||||
- git clone --depth 1 $PROVISIONING_REPO /tmp/provisioning
|
||||
- >-
|
||||
HCLOUD_TOKEN=$HCLOUD_TOKEN
|
||||
nu /tmp/provisioning/extensions/components/buildkit_runner/scripts/gc-snapshots.nu
|
||||
--keep 4
|
||||
environment:
|
||||
PROVISIONING_REPO: https://forgejo.librecloud.online/librecloud/provisioning
|
||||
depends_on: [rebuild-golden-image]
|
||||
16
components/buildkit_runner/metadata.ncl
Normal file
16
components/buildkit_runner/metadata.ncl
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
name = "buildkit_runner",
|
||||
version = "1.0.0",
|
||||
description = "Ephemeral BuildKit runner VM — spawned per build by the orchestrator, destroyed on build completion. Never carries persistent storage; all cache lives in zot.",
|
||||
tags = ["build", "ci", "ephemeral", "vm", "buildkit", "sccache"],
|
||||
modes = ["ephemeral_vm"],
|
||||
dependencies = [],
|
||||
provides = [{ id = "build-execution-ephemeral", version = "1.0", interface = "buildkit-runner" }],
|
||||
requires = [
|
||||
{ capability = "vm-lifecycle", kind = 'Required },
|
||||
{ capability = "image-storage-s3", kind = 'Required },
|
||||
{ capability = "ssh-access", kind = 'Required },
|
||||
],
|
||||
conflicts_with = [],
|
||||
best_practices = ["buildkit-runner-no-persistent-storage", "oom-retry-bounded"],
|
||||
}
|
||||
72
components/buildkit_runner/nickel/contracts.ncl
Normal file
72
components/buildkit_runner/nickel/contracts.ncl
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
let build_spec = import "schemas/lib/build_spec.ncl" in
|
||||
let _concerns_lib = import "schemas/lib/concerns.ncl" in
|
||||
|
||||
let NoPersistentStorage =
|
||||
std.contract.custom (
|
||||
fun _label =>
|
||||
fun value =>
|
||||
if value == false then
|
||||
'Ok value
|
||||
else
|
||||
'Error {
|
||||
message = "storage.persistent MUST be false — ADR-039 constraint buildkit-runner-no-persistent-storage: all state is ephemeral"
|
||||
}
|
||||
)
|
||||
in
|
||||
|
||||
{
|
||||
BuildkitRunner = {
|
||||
name | String,
|
||||
version | String,
|
||||
mode | [| 'ephemeral_vm |],
|
||||
|
||||
golden_image_name | String | default = "buildkit-runner-golden",
|
||||
golden_image_rebuild_cron | String | default = "0 3 * * 0",
|
||||
|
||||
default_size | {
|
||||
cpu | build_spec.BoundedCpu,
|
||||
memory_gb | build_spec.PositiveNumber,
|
||||
disk_gb | build_spec.PositiveNumber,
|
||||
},
|
||||
|
||||
storage | {
|
||||
persistent | NoPersistentStorage,
|
||||
},
|
||||
|
||||
hcloud | {
|
||||
server_type_pool | Array String,
|
||||
location | String | default = "fsn1",
|
||||
},
|
||||
|
||||
ssh | {
|
||||
orchestrator_pubkey_secret | String,
|
||||
},
|
||||
|
||||
lease | {
|
||||
max_duration_min | Number,
|
||||
},
|
||||
|
||||
requires | {
|
||||
credentials | Array String | default = [],
|
||||
capabilities | Array String | default = [],
|
||||
} | default = {},
|
||||
|
||||
provides | {
|
||||
service | String | optional,
|
||||
interface | String | optional,
|
||||
} | default = {},
|
||||
|
||||
operations | {
|
||||
install | Bool | default = true,
|
||||
update | Bool | default = false,
|
||||
delete | Bool | default = false,
|
||||
health | Bool | default = false,
|
||||
} | default = {},
|
||||
|
||||
# ServiceConcerns umbrella (ADR-008). Build runner — ephemeral by design
|
||||
# (ADR-039); SystemBackupDef.builder_env captures any external state.
|
||||
concerns | _concerns_lib.ServiceConcerns | optional,
|
||||
|
||||
..
|
||||
},
|
||||
}
|
||||
67
components/buildkit_runner/nickel/defaults.ncl
Normal file
67
components/buildkit_runner/nickel/defaults.ncl
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
let _presets = (import "schemas/lib/concerns_presets.ncl").presets in
|
||||
|
||||
{
|
||||
buildkit_runner | default = {
|
||||
name | default = "buildkit_runner",
|
||||
version | default = "1.0.0",
|
||||
mode | default = 'ephemeral_vm,
|
||||
|
||||
live_check | default = { strategy = 'on_demand },
|
||||
|
||||
golden_image_name | default = "buildkit-runner-golden",
|
||||
golden_image_rebuild_cron | default = "0 3 * * 0",
|
||||
|
||||
default_size | default = {
|
||||
cpu = 4,
|
||||
memory_gb = 8,
|
||||
disk_gb = 40,
|
||||
},
|
||||
|
||||
storage | default = {
|
||||
persistent = false,
|
||||
},
|
||||
|
||||
hcloud | default = {
|
||||
server_type_pool = ["cax21", "cax31", "cax41", "ccx13"],
|
||||
location = "fsn1",
|
||||
},
|
||||
|
||||
ssh | default = {
|
||||
orchestrator_pubkey_secret = "orchestrator-ssh-pubkey",
|
||||
},
|
||||
|
||||
lease | default = {
|
||||
max_duration_min = 60,
|
||||
},
|
||||
|
||||
requires | default = {
|
||||
credentials = ["HCLOUD_TOKEN", "ORCHESTRATOR_SSH_PUBKEY"],
|
||||
capabilities = ["vm-lifecycle", "image-storage-s3", "ssh-access"],
|
||||
},
|
||||
|
||||
provides | default = {
|
||||
service = "buildkit_runner",
|
||||
interface = "buildkit-runner",
|
||||
},
|
||||
|
||||
operations | default = {
|
||||
install = true,
|
||||
health = true,
|
||||
},
|
||||
|
||||
# ServiceConcerns default — buildkit-runner; ephemeral by design (ADR-039).
|
||||
# infrastructure_glue preset with custom tls/backup reasons.
|
||||
# ── Access-model asymmetry with buildkit_lite (intentional, do not converge) ──
|
||||
# buildkit_lite: persistent K8s Deployment exposed via VPN+NodePort → requires mTLS
|
||||
# (anyone on the wuwei network could otherwise dial the daemon).
|
||||
# buildkit_runner: per-build ephemeral Hetzner VM; buildkitd listens on unix socket
|
||||
# only; orchestrator opens an SSH tunnel using ssh.orchestrator_pubkey_secret;
|
||||
# the SSH session itself IS the authentication boundary, so a second
|
||||
# TLS layer would add no benefit. cloud-init/runner.yaml line 33:
|
||||
# ExecStart=/usr/local/bin/buildkitd --addr unix:///run/buildkit/buildkitd.sock
|
||||
concerns | default = _presets.infrastructure_glue & {
|
||||
tls | force = { kind = 'disabled, reason = "buildkitd binds unix socket only; orchestrator SSH tunnel provides the auth boundary — no TCP surface, no mTLS surface" },
|
||||
backup | force = { kind = 'disabled, reason = "ephemeral by design (ADR-039); persistent state captured by SystemBackupDef.builder_env" },
|
||||
},
|
||||
},
|
||||
}
|
||||
14
components/buildkit_runner/nickel/main.ncl
Normal file
14
components/buildkit_runner/nickel/main.ncl
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
let contracts_lib = import "contracts.ncl" in
|
||||
let defaults_lib = import "defaults.ncl" in
|
||||
let version = import "version.ncl" in
|
||||
|
||||
{
|
||||
defaults = defaults_lib,
|
||||
|
||||
make_buildkit_runner | not_exported = fun overrides =>
|
||||
defaults_lib.buildkit_runner & overrides,
|
||||
|
||||
DefaultBuildkitRunner = defaults_lib.buildkit_runner,
|
||||
BuildkitRunner | contracts_lib.BuildkitRunner = defaults_lib.buildkit_runner,
|
||||
Version = version,
|
||||
}
|
||||
1
components/buildkit_runner/nickel/version.ncl
Normal file
1
components/buildkit_runner/nickel/version.ncl
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ version = "1.0.0", nickel_api = "1.0" }
|
||||
178
components/buildkit_runner/scripts/build-golden-image.nu
Normal file
178
components/buildkit_runner/scripts/build-golden-image.nu
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
#!/usr/bin/env nu
|
||||
# Build a golden BuildKit runner snapshot from Debian 12 ARM64 base.
|
||||
# Idempotent: re-running with the same date tag is a no-op when the snapshot already exists.
|
||||
#
|
||||
# Requires: hcloud CLI authenticated (HCLOUD_TOKEN set), SSH agent with orchestrator key.
|
||||
# SSH key coupling: the orchestrator pubkey baked into the golden image is the one injected
|
||||
# via --ssh-key at server creation. Key rotation requires a fresh golden image build — document
|
||||
# in bootstrap playbook and verify fingerprint before declaring an image good.
|
||||
|
||||
use std log
|
||||
|
||||
const GOLDEN_IMAGE_PREFIX = "buildkit-runner-golden"
|
||||
const CLOUD_INIT_REL_PATH = ["..","cloud-init","runner.yaml"]
|
||||
const VERIFY_TOOLS = ["buildctl","sccache","nu"]
|
||||
|
||||
def main [
|
||||
--location: string = "fsn1"
|
||||
--server_type: string = "cax21"
|
||||
--ssh_key: string = ""
|
||||
--date_tag: string = ""
|
||||
--cloud_init: string = ""
|
||||
]: nothing -> nothing {
|
||||
let tag = if ($date_tag | is-empty) {
|
||||
date now | format date "%Y-%m-%d"
|
||||
} else {
|
||||
$date_tag
|
||||
}
|
||||
|
||||
let snapshot_name = $"($GOLDEN_IMAGE_PREFIX):($tag)"
|
||||
let seed_name = $"buildkit-runner-seed-($tag)"
|
||||
|
||||
# Resolve ssh_key name to filesystem path for SSH commands.
|
||||
# Hetzner key names use dashes; look in ~/.ssh/ for the matching private key.
|
||||
let ssh_key_path = if ($ssh_key | is-not-empty) {
|
||||
let candidate = ($"~/.ssh/($ssh_key)" | path expand)
|
||||
if ($candidate | path exists) { $candidate } else { "" }
|
||||
} else { "" }
|
||||
|
||||
let cloud_init_path = if ($cloud_init | is-empty) {
|
||||
$env.FILE_PWD | path join ...$CLOUD_INIT_REL_PATH | path expand
|
||||
} else {
|
||||
$cloud_init | path expand
|
||||
}
|
||||
|
||||
if not ($cloud_init_path | path exists) {
|
||||
error make { msg: $"cloud-init file not found: ($cloud_init_path)" }
|
||||
}
|
||||
|
||||
log info $"Target snapshot: ($snapshot_name)"
|
||||
|
||||
# Idempotency check — skip if today's snapshot already exists
|
||||
let existing_out = do {
|
||||
^hcloud image list --type snapshot --selector $"app=($GOLDEN_IMAGE_PREFIX)" --output json
|
||||
} | complete
|
||||
if $existing_out.exit_code != 0 {
|
||||
error make { msg: $"hcloud image list failed:\n($existing_out.stderr)" }
|
||||
}
|
||||
let images = $existing_out.stdout | from json
|
||||
let already_exists = $images | where { ($in | get -o labels | default {} | get -o buildkit_tag | default "") == $tag }
|
||||
if ($already_exists | length) > 0 {
|
||||
log info $"Snapshot ($snapshot_name) already exists — no-op."
|
||||
return
|
||||
}
|
||||
|
||||
# Create seed server
|
||||
log info $"Creating seed server ($seed_name) at ($location) type ($server_type)..."
|
||||
let create_args = (
|
||||
["server","create","--name",$seed_name,"--image","debian-12","--type",$server_type,
|
||||
"--location",$location,"--user-data-from-file",$cloud_init_path,
|
||||
"--label",$"app=($GOLDEN_IMAGE_PREFIX)"]
|
||||
| if ($ssh_key | is-not-empty) { append ["--ssh-key",$ssh_key] } else { $in }
|
||||
)
|
||||
let create_out = do { ^hcloud ...$create_args } | complete
|
||||
if $create_out.exit_code != 0 {
|
||||
error make { msg: $"hcloud server create failed:\n($create_out.stderr)" }
|
||||
}
|
||||
|
||||
# Retrieve server details by name
|
||||
let describe_out = do { ^hcloud server describe $seed_name --output json } | complete
|
||||
if $describe_out.exit_code != 0 {
|
||||
error make { msg: $"hcloud server describe failed:\n($describe_out.stderr)" }
|
||||
}
|
||||
let server = $describe_out.stdout | from json
|
||||
let server_id = $server.id
|
||||
let server_ip = $server.public_net.ipv4.ip
|
||||
|
||||
log info $"Seed server id=($server_id) ip=($server_ip) — waiting for running state..."
|
||||
_wait_for_status $server_id "running" 300
|
||||
|
||||
log info "Waiting for SSH and cloud-init completion..."
|
||||
_wait_for_ssh $server_ip 420 $ssh_key_path
|
||||
|
||||
log info "Verifying tools..."
|
||||
_verify_tools $server_ip $ssh_key_path
|
||||
|
||||
# Snapshot the seed server
|
||||
log info $"Creating snapshot '($snapshot_name)'..."
|
||||
let snap_out = do {
|
||||
^hcloud server create-image $server_id --type snapshot --description $snapshot_name --label $"app=($GOLDEN_IMAGE_PREFIX)" --label $"buildkit_tag=($tag)"
|
||||
} | complete
|
||||
if $snap_out.exit_code != 0 {
|
||||
_cleanup_server $server_id
|
||||
error make { msg: $"Snapshot creation failed:\n($snap_out.stderr)" }
|
||||
}
|
||||
|
||||
_cleanup_server $server_id
|
||||
|
||||
let snap_list = do {
|
||||
^hcloud image list --type snapshot --selector $"buildkit_tag=($tag)" --output json
|
||||
} | complete
|
||||
let snap_id = if $snap_list.exit_code == 0 {
|
||||
$snap_list.stdout | from json | get -o 0 | get -o id | default "unknown"
|
||||
} else { "unknown" }
|
||||
log info $"Golden image ($snapshot_name) ready. id=($snap_id)"
|
||||
}
|
||||
|
||||
def _wait_for_status [server_id: int, target_status: string, timeout_sec: int]: nothing -> nothing {
|
||||
let deadline = (date now) + ($timeout_sec * 1sec)
|
||||
loop {
|
||||
let r = do { ^hcloud server describe $server_id --output json } | complete
|
||||
if $r.exit_code == 0 {
|
||||
let status = $r.stdout | from json | get status
|
||||
if $status == $target_status { return }
|
||||
}
|
||||
if (date now) > $deadline {
|
||||
error make { msg: $"Timeout: server ($server_id) did not reach status '($target_status)'" }
|
||||
}
|
||||
sleep 10sec
|
||||
}
|
||||
}
|
||||
|
||||
def _wait_for_ssh [host: string, timeout_sec: int, ssh_key: string = ""]: nothing -> nothing {
|
||||
let deadline = (date now) + ($timeout_sec * 1sec)
|
||||
let key_args = if ($ssh_key | is-not-empty) { ["-i" $ssh_key] } else { [] }
|
||||
loop {
|
||||
let r = do {
|
||||
^ssh ...$key_args -o StrictHostKeyChecking=no -o ConnectTimeout=5 -o BatchMode=yes $"root@($host)" "
|
||||
if [ -f /var/lib/cloud/instance/boot-finished ]; then
|
||||
grep -iq error /var/log/cloud-init.log && echo error || echo done
|
||||
else
|
||||
echo waiting
|
||||
fi
|
||||
"
|
||||
} | complete
|
||||
if $r.exit_code == 0 {
|
||||
let out = ($r.stdout | str trim)
|
||||
if $out == "done" { return }
|
||||
if $out == "error" {
|
||||
error make { msg: $"cloud-init reported errors on ($host) — check /var/log/cloud-init.log" }
|
||||
}
|
||||
}
|
||||
if (date now) > $deadline {
|
||||
error make { msg: $"Timeout waiting for SSH + cloud-init on ($host)" }
|
||||
}
|
||||
sleep 15sec
|
||||
}
|
||||
}
|
||||
|
||||
def _verify_tools [host: string, ssh_key: string = ""]: nothing -> nothing {
|
||||
let key_args = if ($ssh_key | is-not-empty) { ["-i" $ssh_key] } else { [] }
|
||||
for tool in $VERIFY_TOOLS {
|
||||
let r = do {
|
||||
^ssh ...$key_args -o StrictHostKeyChecking=no -o BatchMode=yes $"root@($host)" $"command -v ($tool) && ($tool) --version 2>&1 | head -1"
|
||||
} | complete
|
||||
if $r.exit_code != 0 {
|
||||
error make { msg: $"Tool '($tool)' not found on runner — cloud-init may have failed.\nSSH output: ($r.stdout)\n($r.stderr)" }
|
||||
}
|
||||
log info $" ($tool): ($r.stdout | str trim)"
|
||||
}
|
||||
}
|
||||
|
||||
def _cleanup_server [server_id: int]: nothing -> nothing {
|
||||
log info $"Destroying seed server ($server_id)..."
|
||||
let r = do { ^hcloud server delete $server_id } | complete
|
||||
if $r.exit_code != 0 {
|
||||
log warning $"Server delete returned non-zero (server may need manual cleanup): ($r.stderr)"
|
||||
}
|
||||
}
|
||||
46
components/buildkit_runner/scripts/destroy-runner.nu
Normal file
46
components/buildkit_runner/scripts/destroy-runner.nu
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
#!/usr/bin/env nu
|
||||
# Destroy an ephemeral buildkit runner VM by lease_id label.
|
||||
# Idempotent: exits 0 with status="not_found" when the server is already gone.
|
||||
# Outputs JSON {status, lease_id, server_id?} to stdout.
|
||||
|
||||
use std log
|
||||
|
||||
def main [
|
||||
--lease_id: string
|
||||
]: nothing -> nothing {
|
||||
if ($lease_id | is-empty) {
|
||||
error make { msg: "--lease_id is required" }
|
||||
}
|
||||
|
||||
let list_out = do {
|
||||
^hcloud server list --selector $"lease_id=($lease_id)" --output json
|
||||
} | complete
|
||||
if $list_out.exit_code != 0 {
|
||||
error make { msg: $"hcloud server list failed:\n($list_out.stderr)" }
|
||||
}
|
||||
|
||||
let servers = $list_out.stdout | from json
|
||||
if ($servers | length) == 0 {
|
||||
log info $"No server found for lease_id=($lease_id) — already destroyed or never created."
|
||||
{ status: "not_found", lease_id: $lease_id } | to json
|
||||
return
|
||||
}
|
||||
|
||||
if ($servers | length) > 1 {
|
||||
log warning $"Multiple servers found for lease_id=($lease_id) — destroying all ($servers | length)."
|
||||
}
|
||||
|
||||
for server in $servers {
|
||||
let server_id = $server.id
|
||||
let server_name = $server.name
|
||||
log info $"Deleting server ($server_name) id=($server_id)..."
|
||||
let del_out = do { ^hcloud server delete $server_id } | complete
|
||||
if $del_out.exit_code != 0 {
|
||||
error make { msg: $"hcloud server delete ($server_id) failed:\n($del_out.stderr)" }
|
||||
}
|
||||
log info $"Server ($server_name) id=($server_id) deleted."
|
||||
}
|
||||
|
||||
let first_id = $servers | first | get id
|
||||
{ status: "deleted", lease_id: $lease_id, server_id: $first_id } | to json
|
||||
}
|
||||
34
components/buildkit_runner/scripts/gc-snapshots.nu
Normal file
34
components/buildkit_runner/scripts/gc-snapshots.nu
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#!/usr/bin/env nu
|
||||
# Garbage-collect golden runner snapshots, retaining the N newest.
|
||||
# Requires HCLOUD_TOKEN in environment.
|
||||
|
||||
def main [
|
||||
--keep: int = 4 # How many snapshots to keep (newest first)
|
||||
] {
|
||||
let result = (do {
|
||||
^hcloud image list --type snapshot --selector app=buildkit-runner-golden --output json
|
||||
} | complete)
|
||||
|
||||
if $result.exit_code != 0 {
|
||||
error make { msg: $"hcloud image list failed: ($result.stderr)" }
|
||||
}
|
||||
|
||||
let snapshots = $result.stdout | from json | sort-by created | reverse
|
||||
|
||||
let count = $snapshots | length
|
||||
if $count <= $keep {
|
||||
print $"($count) snapshots present, threshold ($keep) — nothing to delete"
|
||||
return
|
||||
}
|
||||
|
||||
let to_delete = $snapshots | skip $keep
|
||||
print $"Deleting ($to_delete | length) snapshots \(keeping newest ($keep)\)"
|
||||
|
||||
for snap in $to_delete {
|
||||
print $" delete id=($snap.id) created=($snap.created) description=($snap.description)"
|
||||
let del = (do { ^hcloud image delete ($snap.id | into string) } | complete)
|
||||
if $del.exit_code != 0 {
|
||||
error make { msg: $"Failed to delete snapshot ($snap.id): ($del.stderr)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
133
components/buildkit_runner/scripts/spawn-runner.nu
Normal file
133
components/buildkit_runner/scripts/spawn-runner.nu
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
#!/usr/bin/env nu
|
||||
# Hcloud adapter for lian-build runner dispatch.
|
||||
# Protocol: nu spawn-runner.nu <spawn|destroy|list> — JSON on stdin, JSON on stdout.
|
||||
# Contract: lian-build/schemas/adapter.ncl
|
||||
|
||||
use std log
|
||||
|
||||
const GOLDEN_IMAGE_PREFIX = "buildkit-runner-golden"
|
||||
const DEFAULT_SSH_PORT = 22
|
||||
|
||||
# ── Subcommands ───────────────────────────────────────────────────────────────
|
||||
|
||||
# Spawn an ephemeral hcloud VM. Reads SpawnInput JSON from stdin.
|
||||
# Returns SpawnOutput JSON: {handle, ssh_host, ssh_port, expires_at}
|
||||
def "main spawn" [] {
|
||||
let input = (^cat | from json)
|
||||
|
||||
let image_id = _resolve_image $input.image_ref
|
||||
let server_name = $"buildkit-runner-(random uuid | str substring 0..7)"
|
||||
let expires_at = (date now) + ($input.time_budget_min * 60sec) | format date "%Y-%m-%dT%H:%M:%SZ"
|
||||
let server_type = _server_type $input.cpu $input.memory_gb
|
||||
|
||||
log info $"Spawning ($server_name) type=($server_type) location=($input.location)…"
|
||||
|
||||
let base_args = [
|
||||
"server", "create",
|
||||
"--name", $server_name,
|
||||
"--image", ($image_id | into string),
|
||||
"--type", $server_type,
|
||||
"--location", $input.location,
|
||||
"--label", "app=buildkit-runner",
|
||||
"--label", $"expires_at=($expires_at)",
|
||||
]
|
||||
let with_key = if ($input.ssh_key_ref | is-not-empty) { $base_args | append ["--ssh-key", $input.ssh_key_ref] } else { $base_args }
|
||||
let with_net = if (($input.network_ref? | default "") | is-not-empty) { $with_key | append ["--network", ($input.network_ref?)] } else { $with_key }
|
||||
let create_args = if (($input.firewall_ref? | default "") | is-not-empty) { $with_net | append ["--firewall", ($input.firewall_ref?)] } else { $with_net }
|
||||
|
||||
let create_out = do { ^hcloud ...$create_args } | complete
|
||||
if $create_out.exit_code != 0 {
|
||||
error make { msg: $"hcloud server create failed:\n($create_out.stderr)" }
|
||||
}
|
||||
|
||||
let describe_out = do { ^hcloud server describe $server_name --output json } | complete
|
||||
if $describe_out.exit_code != 0 {
|
||||
error make { msg: $"hcloud server describe ($server_name) failed:\n($describe_out.stderr)" }
|
||||
}
|
||||
let server = $describe_out.stdout | from json
|
||||
let server_ip = $server.public_net.ipv4.ip
|
||||
|
||||
log info $"Server id=($server.id) ip=($server_ip) — waiting for SSH…"
|
||||
_wait_for_ssh $server_ip 180
|
||||
|
||||
let dns = $input.internal_dns? | default ""
|
||||
if ($dns | is-not-empty) {
|
||||
_inject_resolv_conf $server_ip $dns
|
||||
}
|
||||
|
||||
{ handle: $server_name, ssh_host: $server_ip, ssh_port: $DEFAULT_SSH_PORT, expires_at: $expires_at } | to json
|
||||
}
|
||||
|
||||
# Destroy a runner by handle. Reads DestroyInput JSON {handle} from stdin.
|
||||
def "main destroy" [] {
|
||||
let input = (^cat | from json)
|
||||
let handle = $input.handle
|
||||
log info $"Destroying runner ($handle)…"
|
||||
let out = do { ^hcloud server delete $handle } | complete
|
||||
if $out.exit_code != 0 {
|
||||
error make { msg: $"hcloud server delete ($handle) failed:\n($out.stderr)" }
|
||||
}
|
||||
}
|
||||
|
||||
# List all buildkit-runner instances managed by this adapter.
|
||||
def "main list" [] {
|
||||
let out = do { ^hcloud server list --selector app=buildkit-runner --output json } | complete
|
||||
if $out.exit_code != 0 {
|
||||
error make { msg: $"hcloud server list failed:\n($out.stderr)" }
|
||||
}
|
||||
$out.stdout | from json | each {|s|
|
||||
{
|
||||
handle: $s.name,
|
||||
ssh_host: $s.public_net.ipv4.ip,
|
||||
ssh_port: $DEFAULT_SSH_PORT,
|
||||
status: (if $s.status == "running" { "running" } else if $s.status == "initializing" { "starting" } else { "unknown" }),
|
||||
created_at: $s.created,
|
||||
}
|
||||
} | to json
|
||||
}
|
||||
|
||||
def main [] { print "Usage: spawn-runner.nu <spawn|destroy|list>" }
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_image [image_ref: string] {
|
||||
let by_name = do { ^hcloud image list --type snapshot --name $image_ref --output json } | complete
|
||||
if $by_name.exit_code == 0 {
|
||||
let imgs = $by_name.stdout | from json
|
||||
if ($imgs | length) > 0 { return ($imgs | first).id }
|
||||
}
|
||||
let by_label = do { ^hcloud image list --type snapshot --selector $"app=($GOLDEN_IMAGE_PREFIX)" --output json } | complete
|
||||
if $by_label.exit_code != 0 {
|
||||
error make { msg: $"hcloud image list failed:\n($by_label.stderr)" }
|
||||
}
|
||||
let imgs = $by_label.stdout | from json
|
||||
if ($imgs | length) == 0 {
|
||||
error make { msg: $"No snapshot found — image_ref=($image_ref), label app=($GOLDEN_IMAGE_PREFIX). Run build-golden-image.nu first." }
|
||||
}
|
||||
($imgs | sort-by created | last).id
|
||||
}
|
||||
|
||||
def _server_type [cpu: int, memory_gb: int] {
|
||||
if $cpu <= 2 { "cax11" } else if $cpu <= 4 { "cax21" } else if $cpu <= 8 { "cax31" } else { "cax41" }
|
||||
}
|
||||
|
||||
def _wait_for_ssh [host: string, timeout_sec: int] {
|
||||
let deadline = (date now) + ($timeout_sec * 1sec)
|
||||
loop {
|
||||
let r = do { ^ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -o BatchMode=yes $"root@($host)" true } | complete
|
||||
if $r.exit_code == 0 { return }
|
||||
if (date now) > $deadline {
|
||||
error make { msg: $"Timeout waiting for SSH on ($host)" }
|
||||
}
|
||||
sleep 5sec
|
||||
}
|
||||
}
|
||||
|
||||
def _inject_resolv_conf [host: string, dns_ip: string] {
|
||||
let r = do {
|
||||
^ssh -o StrictHostKeyChecking=no -o BatchMode=yes $"root@($host)" $"printf 'nameserver ($dns_ip)\\n' > /etc/resolv.conf"
|
||||
} | complete
|
||||
if $r.exit_code != 0 {
|
||||
log warning $"resolv.conf inject failed on ($host): ($r.stderr)"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
# Tool image for Woodpecker steps that need nushell + hcloud CLI.
|
||||
# Used by the golden-image-rebuild pipeline (TASK 10).
|
||||
# Build this image once during bootstrap and push to reg.librecloud.online/images/nushell-hcloud:latest
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ARG NUSHELL_VERSION=0.112.2
|
||||
ARG HCLOUD_VERSION=1.47.0
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl git openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -fsSL \
|
||||
"https://github.com/nushell/nushell/releases/download/${NUSHELL_VERSION}/nu-${NUSHELL_VERSION}-aarch64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz --strip-components=1 -C /usr/local/bin \
|
||||
"nu-${NUSHELL_VERSION}-aarch64-unknown-linux-gnu/nu"
|
||||
|
||||
RUN curl -fsSL \
|
||||
"https://github.com/hetznercloud/cli/releases/download/v${HCLOUD_VERSION}/hcloud-linux-arm64.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin hcloud
|
||||
|
||||
RUN nu --version && hcloud version
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/nu"]
|
||||
145
components/cap/cluster/cap-lib.sh
Normal file
145
components/cap/cluster/cap-lib.sh
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
#!/bin/bash
|
||||
# Methods library for cap — sourced by install-cap.sh and generated run-*.sh scripts.
|
||||
# SCRIPT_DIR must be set before sourcing.
|
||||
|
||||
_kubectl() {
|
||||
if command -v kubectl >/dev/null 2>&1; then
|
||||
command kubectl "$@"
|
||||
elif command -v k0s >/dev/null 2>&1; then
|
||||
k0s kubectl "$@"
|
||||
else
|
||||
echo "ERROR: no kubectl or k0s binary found" >&2; exit 127
|
||||
fi
|
||||
}
|
||||
|
||||
_require_env() {
|
||||
local var="$1"
|
||||
if [ -z "${!var:-}" ]; then
|
||||
echo "ERROR: required variable $var is not set" >&2; exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
_pod_name() {
|
||||
_kubectl get pod \
|
||||
--namespace "$CAP_NAMESPACE" \
|
||||
--selector "app.kubernetes.io/name=${CAP_NAME}" \
|
||||
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null
|
||||
}
|
||||
|
||||
_wait_for_pod() {
|
||||
local timeout="${1:-300}"
|
||||
_kubectl wait pod \
|
||||
--namespace "$CAP_NAMESPACE" \
|
||||
--selector "app.kubernetes.io/name=${CAP_NAME}" \
|
||||
--for=condition=Ready \
|
||||
--timeout="${timeout}s"
|
||||
}
|
||||
|
||||
# ── Manifest plan helpers ─────────────────────────────────────────────────────
|
||||
|
||||
_plan_apply() {
|
||||
local file="$1"
|
||||
local path="$SCRIPT_DIR/manifests/${file}.yaml"
|
||||
[ -f "$path" ] || { echo " [skip] ${file}.yaml not in manifests/"; return 0; }
|
||||
_kubectl apply -f "$path"
|
||||
}
|
||||
|
||||
_plan_apply_skip() {
|
||||
local file="$1"
|
||||
local path="$SCRIPT_DIR/manifests/${file}.yaml"
|
||||
[ -f "$path" ] || { echo " [skip] ${file}.yaml not in manifests/"; return 0; }
|
||||
if _kubectl get -f "$path" >/dev/null 2>&1; then
|
||||
echo " [skip] ${file} already exists in cluster"
|
||||
return 0
|
||||
fi
|
||||
_kubectl apply -f "$path"
|
||||
}
|
||||
|
||||
_plan_rollout_restart() {
|
||||
local file="$1"
|
||||
local path="$SCRIPT_DIR/manifests/${file}.yaml"
|
||||
[ -f "$path" ] || { echo " [skip] ${file}.yaml not in manifests/"; return 0; }
|
||||
_kubectl rollout restart -f "$path"
|
||||
}
|
||||
|
||||
_plan_delete() {
|
||||
local file="$1"
|
||||
local path="$SCRIPT_DIR/manifests/${file}.yaml"
|
||||
[ -f "$path" ] || { echo " [skip] ${file}.yaml not in manifests/"; return 0; }
|
||||
_kubectl delete -f "$path" --ignore-not-found
|
||||
}
|
||||
|
||||
# shellcheck source=lib/gateway-tls.sh
|
||||
source "${SCRIPT_DIR}/lib/gateway-tls.sh"
|
||||
|
||||
_method_patch-gateway-https() {
|
||||
_lib_gateway_patch_https \
|
||||
"${TLS_HOOK_GATEWAY_NAME:-libre-wuji}" \
|
||||
"${TLS_HOOK_GATEWAY_NS:-kube-system}" \
|
||||
"${TLS_HOOK_LISTENER_NAME}" \
|
||||
"${TLS_HOOK_DOMAIN}" \
|
||||
"${TLS_HOOK_TLS_SECRET}" \
|
||||
"${TLS_HOOK_NAMESPACE}"
|
||||
}
|
||||
|
||||
_method_remove-gateway-https() {
|
||||
_lib_gateway_remove_https \
|
||||
"${TLS_HOOK_GATEWAY_NAME:-libre-wuji}" \
|
||||
"${TLS_HOOK_GATEWAY_NS:-kube-system}" \
|
||||
"${TLS_HOOK_LISTENER_NAME}"
|
||||
}
|
||||
|
||||
# ── Component methods ─────────────────────────────────────────────────────────
|
||||
|
||||
_method_create-credentials() {
|
||||
[ -f "${SCRIPT_DIR}/_credentials.env" ] && set -a && source "${SCRIPT_DIR}/_credentials.env" && set +a
|
||||
_require_env ADMIN_KEY
|
||||
_kubectl create secret generic "${CAP_NAME}-credentials" \
|
||||
--namespace "$CAP_NAMESPACE" \
|
||||
--from-literal=ADMIN_KEY="$ADMIN_KEY" \
|
||||
--dry-run=client -o yaml | _kubectl apply -f -
|
||||
echo " [ok] credentials secret '${CAP_NAME}-credentials' created in ${CAP_NAMESPACE}"
|
||||
}
|
||||
|
||||
_method_wait-valkey() {
|
||||
local valkey_name="${CAP_NAME}-valkey"
|
||||
echo " waiting for valkey pod ${valkey_name} to be ready..."
|
||||
_kubectl wait pod \
|
||||
--namespace "$CAP_NAMESPACE" \
|
||||
--selector "app.kubernetes.io/name=${valkey_name}" \
|
||||
--for=condition=Ready \
|
||||
--timeout=120s
|
||||
echo " [ok] valkey ready"
|
||||
}
|
||||
|
||||
_method_wait-ready() {
|
||||
_wait_for_pod 300
|
||||
}
|
||||
|
||||
_method_protect-volume() {
|
||||
local pvc="${PLAN_PARAM_PVC:-${CAP_NAME}-valkey-data}"
|
||||
|
||||
echo " waiting for PVC '${pvc}' to bind..."
|
||||
_kubectl wait pvc "$pvc" \
|
||||
--namespace "$CAP_NAMESPACE" \
|
||||
--for=jsonpath='{.status.phase}'=Bound \
|
||||
--timeout=120s
|
||||
|
||||
local pv_name
|
||||
pv_name=$(_kubectl get pvc "$pvc" \
|
||||
--namespace "$CAP_NAMESPACE" \
|
||||
-o jsonpath='{.spec.volumeName}')
|
||||
if [ -z "$pv_name" ]; then
|
||||
echo " [warn] could not resolve PV name for PVC '${pvc}' — skipping volume protection"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v hcloud >/dev/null 2>&1; then
|
||||
hcloud volume enable-protection "$pv_name" delete 2>/dev/null \
|
||||
&& echo " [ok] volume '${pv_name}' protected" \
|
||||
|| echo " [warn] hcloud protect failed — run manually: hcloud volume enable-protection ${pv_name} delete"
|
||||
else
|
||||
echo " [warn] hcloud CLI not found — skip volume protection for '${pv_name}'"
|
||||
fi
|
||||
}
|
||||
|
||||
26
components/cap/cluster/env-cap.j2
Normal file
26
components/cap/cluster/env-cap.j2
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
CAP_NAME={{ taskserv.name }}
|
||||
CAP_NAMESPACE={{ taskserv.namespace }}
|
||||
CAP_IMAGE={{ taskserv.image }}:{{ taskserv.image_tag }}
|
||||
CAP_HTTP_PORT={{ taskserv.http_port }}
|
||||
CAP_DOMAIN={{ taskserv.domain }}
|
||||
CAP_DNS_ZONE={{ taskserv.dns_zone }}
|
||||
CAP_CLUSTER_ISSUER={{ taskserv.cluster_issuer }}
|
||||
CAP_GATEWAY_NAME={{ taskserv.gateway_name | default(value="libre-wuji") }}
|
||||
CAP_GATEWAY_NS={{ taskserv.gateway_ns | default(value="kube-system") }}
|
||||
CAP_GATEWAY_IP={{ gateway_ip | default(value="") }}
|
||||
CAP_CF_SECRET_NAME={{ cf_secret_name }}
|
||||
CAP_TLS_SECRET={{ tls_secret }}
|
||||
CAP_REDIS_URL={{ redis_url }}
|
||||
CAP_VALKEY_IMAGE={{ taskserv.valkey_image | default(value="valkey/valkey") }}:{{ taskserv.valkey_image_tag | default(value="9-alpine") }}
|
||||
CAP_VALKEY_STORAGE_CLASS={{ taskserv.valkey_storage_class | default(value="longhorn-retain") }}
|
||||
CAP_VALKEY_STORAGE_SIZE={{ taskserv.valkey_storage_size | default(value="500Mi") }}
|
||||
KUBECONFIG={{ taskserv.kubeconfig | default(value="/etc/kubernetes/admin.conf") }}
|
||||
# TLS hook standard vars — consumed by lib/tls-hook-methods.sh
|
||||
TLS_HOOK_CF_SECRET_NAME={{ cf_secret_name }}
|
||||
TLS_HOOK_GATEWAY_NAME={{ taskserv.gateway_name | default(value="libre-wuji") }}
|
||||
TLS_HOOK_GATEWAY_NS={{ taskserv.gateway_ns | default(value="kube-system") }}
|
||||
TLS_HOOK_LISTENER_NAME=https-{{ taskserv.name }}
|
||||
TLS_HOOK_DOMAIN={{ taskserv.domain }}
|
||||
TLS_HOOK_TLS_SECRET={{ tls_secret }}
|
||||
TLS_HOOK_NAMESPACE={{ taskserv.namespace }}
|
||||
TLS_HOOK_CLUSTER_ISSUER={{ taskserv.cluster_issuer }}
|
||||
114
components/cap/cluster/install-cap.sh
Normal file
114
components/cap/cluster/install-cap.sh
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
#!/bin/bash
|
||||
# Tier-1 dispatch for cap component operations.
|
||||
# Delegates to generated run-{op}.sh scripts when present (manifest-plan workflow),
|
||||
# or falls back to inline _do_{op} functions for direct invocation.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
[ -f "${SCRIPT_DIR}/env-cap" ] && source "${SCRIPT_DIR}/env-cap"
|
||||
|
||||
CAP_NAME="${CAP_NAME:-cap}"
|
||||
CAP_NAMESPACE="${CAP_NAMESPACE:-cap-svc}"
|
||||
CAP_HTTP_PORT="${CAP_HTTP_PORT:-3000}"
|
||||
|
||||
CMD_TSK="${CMD_TSK:-${1:-install}}"
|
||||
|
||||
_resolve_kubeconfig() {
|
||||
if [ -n "${KUBECONFIG:-}" ] && [ -f "$KUBECONFIG" ]; then
|
||||
return
|
||||
fi
|
||||
for candidate in /var/lib/k0s/pki/admin.conf /etc/kubernetes/admin.conf /root/.kube/config; do
|
||||
if [ -f "$candidate" ]; then
|
||||
export KUBECONFIG="$candidate"
|
||||
return
|
||||
fi
|
||||
done
|
||||
echo "ERROR: kubeconfig not found — set KUBECONFIG explicitly" >&2; exit 1
|
||||
}
|
||||
|
||||
_kubectl() {
|
||||
if command -v kubectl >/dev/null 2>&1; then
|
||||
command kubectl "$@"
|
||||
elif command -v k0s >/dev/null 2>&1; then
|
||||
k0s kubectl "$@"
|
||||
else
|
||||
echo "ERROR: no kubectl or k0s binary found" >&2; exit 127
|
||||
fi
|
||||
}
|
||||
|
||||
_wait_for_pod() {
|
||||
local timeout="${1:-300}"
|
||||
_kubectl wait pod \
|
||||
--namespace "$CAP_NAMESPACE" \
|
||||
--selector "app.kubernetes.io/name=${CAP_NAME}" \
|
||||
--for=condition=Ready \
|
||||
--timeout="${timeout}s"
|
||||
}
|
||||
|
||||
_do_install() {
|
||||
for manifest in namespace.yaml certificate.yaml referencegrant.yaml \
|
||||
valkey-pvc.yaml valkey-deployment.yaml valkey-service.yaml \
|
||||
deployment.yaml service.yaml httproute.yaml; do
|
||||
local path="$SCRIPT_DIR/manifests/${manifest}"
|
||||
[ -f "$path" ] && _kubectl apply -f "$path"
|
||||
done
|
||||
echo "Waiting for ${CAP_NAME} pod to be ready..."
|
||||
_wait_for_pod 300
|
||||
echo "${CAP_NAME} installed in namespace ${CAP_NAMESPACE}"
|
||||
}
|
||||
|
||||
_do_update() {
|
||||
for manifest in httproute.yaml deployment.yaml; do
|
||||
local path="$SCRIPT_DIR/manifests/${manifest}"
|
||||
[ -f "$path" ] && _kubectl apply -f "$path"
|
||||
done
|
||||
_kubectl rollout restart "deployment/${CAP_NAME}" --namespace "$CAP_NAMESPACE"
|
||||
echo "Waiting for ${CAP_NAME} to restart..."
|
||||
_wait_for_pod 180
|
||||
echo "${CAP_NAME} updated in namespace ${CAP_NAMESPACE}"
|
||||
}
|
||||
|
||||
_do_delete() {
|
||||
for manifest in httproute.yaml deployment.yaml service.yaml \
|
||||
valkey-service.yaml valkey-deployment.yaml; do
|
||||
local path="$SCRIPT_DIR/manifests/${manifest}"
|
||||
[ -f "$path" ] && _kubectl delete -f "$path" --ignore-not-found
|
||||
done
|
||||
echo "${CAP_NAME} deleted from namespace ${CAP_NAMESPACE}"
|
||||
}
|
||||
|
||||
_do_restart() {
|
||||
_kubectl rollout restart "deployment/${CAP_NAME}" --namespace "$CAP_NAMESPACE"
|
||||
_wait_for_pod 180
|
||||
echo "${CAP_NAME} restarted"
|
||||
}
|
||||
|
||||
_do_health() {
|
||||
local pod
|
||||
pod=$(_kubectl get pod \
|
||||
--namespace "$CAP_NAMESPACE" \
|
||||
--selector "app.kubernetes.io/name=${CAP_NAME}" \
|
||||
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null)
|
||||
if [ -z "$pod" ]; then
|
||||
echo "UNHEALTHY: no pod found for ${CAP_NAME} in ${CAP_NAMESPACE}" >&2; exit 1
|
||||
fi
|
||||
if _kubectl exec --namespace "$CAP_NAMESPACE" "$pod" -- \
|
||||
wget -qO- "http://localhost:${CAP_HTTP_PORT}/" >/dev/null 2>&1; then
|
||||
echo "HEALTHY: ${CAP_NAME} responding"
|
||||
else
|
||||
echo "UNHEALTHY: ${CAP_NAME} did not respond" >&2; exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
_resolve_kubeconfig
|
||||
|
||||
case "$CMD_TSK" in
|
||||
install) [ -f "$SCRIPT_DIR/run-init.sh" ] && exec bash "$SCRIPT_DIR/run-init.sh"; _do_install ;;
|
||||
update) [ -f "$SCRIPT_DIR/run-update.sh" ] && exec bash "$SCRIPT_DIR/run-update.sh"; _do_update ;;
|
||||
delete) [ -f "$SCRIPT_DIR/run-delete.sh" ] && exec bash "$SCRIPT_DIR/run-delete.sh"; _do_delete ;;
|
||||
restart) [ -f "$SCRIPT_DIR/run-restart.sh" ] && exec bash "$SCRIPT_DIR/run-restart.sh"; _do_restart ;;
|
||||
health) _do_health ;;
|
||||
*) echo "ERROR: unknown CMD_TSK='${CMD_TSK}'. Valid: install|update|delete|restart|health" >&2; exit 1 ;;
|
||||
esac
|
||||
59
components/cap/cluster/manifest_plan.ncl
Normal file
59
components/cap/cluster/manifest_plan.ncl
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
let mp = import "schemas/lib/manifest_plan.ncl" in
|
||||
|
||||
{
|
||||
manifest_plan | mp.ManifestPlan = {
|
||||
init = [
|
||||
{ file = "namespace", action = 'apply, skip_if_exists = true },
|
||||
{ file = "certificate", action = 'apply, skip_if_exists = true },
|
||||
{ file = "referencegrant", action = 'apply },
|
||||
{ action = 'patch-gateway-https },
|
||||
{ action = 'create-credentials },
|
||||
{ file = "valkey-pvc", action = 'apply, skip_if_exists = true },
|
||||
{ file = "valkey-deployment", action = 'apply },
|
||||
{ file = "valkey-service", action = 'apply },
|
||||
{ action = 'wait-valkey },
|
||||
{ file = "deployment", action = 'apply },
|
||||
{ file = "service", action = 'apply },
|
||||
{ file = "httproute", action = 'apply },
|
||||
{
|
||||
action = 'wait-ready,
|
||||
post = [
|
||||
{ action = 'protect-volume, params = { pvc = "cap-valkey-data" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
update = [
|
||||
{ file = "certificate", action = 'apply, skip_if_exists = true },
|
||||
{ file = "referencegrant", action = 'apply },
|
||||
{ action = 'patch-gateway-https },
|
||||
{ file = "httproute", action = 'apply },
|
||||
{
|
||||
file = "deployment",
|
||||
action = 'rollout-restart,
|
||||
delay = 3,
|
||||
post = [
|
||||
{ action = 'wait-ready },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
delete = [
|
||||
{ file = "httproute", action = 'delete },
|
||||
{ file = "deployment", action = 'delete },
|
||||
{ file = "service", action = 'delete },
|
||||
{ file = "valkey-service", action = 'delete },
|
||||
{ file = "valkey-deployment", action = 'delete },
|
||||
],
|
||||
|
||||
restart = [
|
||||
{
|
||||
file = "deployment",
|
||||
action = 'rollout-restart,
|
||||
post = [
|
||||
{ action = 'wait-ready },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
19
components/cap/cluster/templates/certificate.yaml.j2
Normal file
19
components/cap/cluster/templates/certificate.yaml.j2
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: {{ tls_secret }}
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.name }}
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
spec:
|
||||
secretName: {{ tls_secret }}
|
||||
issuerRef:
|
||||
name: {{ taskserv.cluster_issuer }}
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- "{{ taskserv.domain }}"
|
||||
usages:
|
||||
- server auth
|
||||
- digital signature
|
||||
- key encipherment
|
||||
118
components/cap/cluster/templates/deployment.yaml.j2
Normal file
118
components/cap/cluster/templates/deployment.yaml.j2
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ taskserv.name }}
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.name }}
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ taskserv.name }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.name }}
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
spec:
|
||||
{% if taskserv.placement is defined and taskserv.placement.node_class %}
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
{% if taskserv.placement.mode == "require" %}
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: node-class.{{ taskserv.placement.node_class.0 }}
|
||||
operator: In
|
||||
values: ["true"]
|
||||
{% else %}
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
{% for c in taskserv.placement.node_class %}
|
||||
- weight: {{ 100 - loop.index0 * 10 }}
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: node-class.{{ c }}
|
||||
operator: In
|
||||
values: ["true"]
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
containers:
|
||||
- name: {{ taskserv.name }}
|
||||
image: {{ taskserv.image }}:{{ taskserv.image_tag }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ taskserv.http_port }}
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: REDIS_URL
|
||||
value: "{{ redis_url }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ taskserv.name }}-credentials
|
||||
{% if taskserv.probes is defined and taskserv.probes.readiness is defined %}
|
||||
readinessProbe:
|
||||
{% if taskserv.probes.readiness.type == "tcp" %}
|
||||
tcpSocket:
|
||||
port: {{ taskserv.probes.readiness.port | default(value=taskserv.http_port) }}
|
||||
{% else %}
|
||||
httpGet:
|
||||
path: {{ taskserv.probes.readiness.path }}
|
||||
port: {{ taskserv.probes.readiness.port | default(value=taskserv.http_port) }}
|
||||
{% endif %}
|
||||
initialDelaySeconds: {{ taskserv.probes.readiness.initial_delay }}
|
||||
periodSeconds: {{ taskserv.probes.readiness.period }}
|
||||
failureThreshold: {{ taskserv.probes.readiness.failure_threshold }}
|
||||
{% else %}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: {{ taskserv.http_port }}
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
failureThreshold: 6
|
||||
{% endif %}
|
||||
{% if taskserv.probes is defined and taskserv.probes.liveness is defined %}
|
||||
livenessProbe:
|
||||
{% if taskserv.probes.liveness.type == "tcp" %}
|
||||
tcpSocket:
|
||||
port: {{ taskserv.probes.liveness.port | default(value=taskserv.http_port) }}
|
||||
{% else %}
|
||||
httpGet:
|
||||
path: {{ taskserv.probes.liveness.path }}
|
||||
port: {{ taskserv.probes.liveness.port | default(value=taskserv.http_port) }}
|
||||
{% endif %}
|
||||
initialDelaySeconds: {{ taskserv.probes.liveness.initial_delay }}
|
||||
periodSeconds: {{ taskserv.probes.liveness.period }}
|
||||
failureThreshold: {{ taskserv.probes.liveness.failure_threshold }}
|
||||
{% else %}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: {{ taskserv.http_port }}
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
failureThreshold: 3
|
||||
{% endif %}
|
||||
{% if taskserv.resources_effective is defined %}
|
||||
resources:
|
||||
requests:
|
||||
cpu: "{{ taskserv.resources_effective.cpu.request }}"
|
||||
memory: "{{ taskserv.resources_effective.memory.request }}"
|
||||
limits:
|
||||
cpu: "{{ taskserv.resources_effective.cpu.limit }}"
|
||||
memory: "{{ taskserv.resources_effective.memory.limit }}"
|
||||
{% else %}
|
||||
resources:
|
||||
requests:
|
||||
cpu: "10m"
|
||||
memory: "64Mi"
|
||||
limits:
|
||||
cpu: "500m"
|
||||
memory: "256Mi"
|
||||
{% endif %}
|
||||
terminationGracePeriodSeconds: 30
|
||||
26
components/cap/cluster/templates/httproute.yaml.j2
Normal file
26
components/cap/cluster/templates/httproute.yaml.j2
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ taskserv.name }}-route
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.name }}
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
spec:
|
||||
parentRefs:
|
||||
- name: {{ taskserv.gateway_name }}
|
||||
namespace: {{ taskserv.gateway_ns }}
|
||||
sectionName: https-{{ taskserv.name }}
|
||||
- name: {{ taskserv.gateway_name }}
|
||||
namespace: {{ taskserv.gateway_ns }}
|
||||
sectionName: http
|
||||
hostnames:
|
||||
- "{{ taskserv.domain }}"
|
||||
rules:
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /
|
||||
backendRefs:
|
||||
- name: {{ taskserv.name }}
|
||||
port: 80
|
||||
7
components/cap/cluster/templates/namespace.yaml.j2
Normal file
7
components/cap/cluster/templates/namespace.yaml.j2
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
layer: application
|
||||
17
components/cap/cluster/templates/referencegrant.yaml.j2
Normal file
17
components/cap/cluster/templates/referencegrant.yaml.j2
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
apiVersion: gateway.networking.k8s.io/v1beta1
|
||||
kind: ReferenceGrant
|
||||
metadata:
|
||||
name: {{ tls_secret }}-from-{{ taskserv.gateway_ns }}
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.name }}
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
spec:
|
||||
from:
|
||||
- group: gateway.networking.k8s.io
|
||||
kind: Gateway
|
||||
namespace: {{ taskserv.gateway_ns }}
|
||||
to:
|
||||
- group: ""
|
||||
kind: Secret
|
||||
name: {{ tls_secret }}
|
||||
17
components/cap/cluster/templates/service.yaml.j2
Normal file
17
components/cap/cluster/templates/service.yaml.j2
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ taskserv.name }}
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.name }}
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: {{ taskserv.name }}
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: {{ taskserv.http_port }}
|
||||
protocol: TCP
|
||||
56
components/cap/cluster/templates/valkey-deployment.yaml.j2
Normal file
56
components/cap/cluster/templates/valkey-deployment.yaml.j2
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ taskserv.name }}-valkey
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.name }}-valkey
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ taskserv.name }}-valkey
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.name }}-valkey
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
spec:
|
||||
containers:
|
||||
- name: valkey
|
||||
image: {{ taskserv.valkey_image }}:{{ taskserv.valkey_image_tag }}
|
||||
command: ["valkey-server", "--maxmemory-policy", "noeviction", "--save", "60", "1"]
|
||||
ports:
|
||||
- name: redis
|
||||
containerPort: 6379
|
||||
protocol: TCP
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["valkey-cli", "ping"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
failureThreshold: 6
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["valkey-cli", "ping"]
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: "10m"
|
||||
memory: "32Mi"
|
||||
limits:
|
||||
cpu: "200m"
|
||||
memory: "128Mi"
|
||||
terminationGracePeriodSeconds: 30
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ taskserv.name }}-valkey-data
|
||||
17
components/cap/cluster/templates/valkey-pvc.yaml.j2
Normal file
17
components/cap/cluster/templates/valkey-pvc.yaml.j2
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ taskserv.name }}-valkey-data
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.name }}-valkey
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
annotations:
|
||||
provisioning.io/lifecycle: durable
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: {{ taskserv.valkey_storage_class }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ taskserv.valkey_storage_size }}
|
||||
17
components/cap/cluster/templates/valkey-service.yaml.j2
Normal file
17
components/cap/cluster/templates/valkey-service.yaml.j2
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ taskserv.name }}-valkey
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ taskserv.name }}-valkey
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: {{ taskserv.name }}-valkey
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: redis
|
||||
port: 6379
|
||||
targetPort: 6379
|
||||
protocol: TCP
|
||||
48
components/cap/cluster/vars.nu
Normal file
48
components/cap/cluster/vars.nu
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env nu
|
||||
# Derived variables for cap bundle rendering.
|
||||
# Reads the component JSON, outputs extra Tera context vars as JSON.
|
||||
|
||||
def zone_to_cf_secret [zone: string]: nothing -> string {
|
||||
$"dns-($zone | str replace --all '.' '-')"
|
||||
}
|
||||
|
||||
def resolve_fip_ip [fip_name: string]: nothing -> string {
|
||||
let result = (do { ^hcloud floating-ip list -o json } | complete)
|
||||
if $result.exit_code != 0 { return "" }
|
||||
let matched = ($result.stdout | from json | where name == $fip_name)
|
||||
if ($matched | is-empty) { return "" }
|
||||
$matched | first | get ip
|
||||
}
|
||||
|
||||
def main [component_json: string]: nothing -> nothing {
|
||||
let d = ($component_json | from json)
|
||||
|
||||
let dns_zone = ($d | get -o dns_zone | default "")
|
||||
let gateway_fip = ($d | get -o gateway_fip | default "")
|
||||
let gateway_ip = ($d | get -o gateway_ip | default "")
|
||||
let name = ($d | get -o name | default "cap")
|
||||
let namespace = ($d | get -o namespace | default "cap-svc")
|
||||
|
||||
let resolved_cf_secret = if ($dns_zone | is-not-empty) {
|
||||
zone_to_cf_secret $dns_zone
|
||||
} else { "" }
|
||||
|
||||
let resolved_gateway_ip = if ($gateway_ip | is-not-empty) {
|
||||
$gateway_ip
|
||||
} else if ($gateway_fip | is-not-empty) {
|
||||
resolve_fip_ip $gateway_fip
|
||||
} else { "" }
|
||||
|
||||
if ($resolved_gateway_ip | is-empty) and ($gateway_fip | is-not-empty) {
|
||||
print $"[warn] vars: could not resolve IP for gateway FIP '($gateway_fip)' — DNS reconcile will be skipped"
|
||||
}
|
||||
|
||||
{
|
||||
cf_secret_name: $resolved_cf_secret,
|
||||
gateway_ip: $resolved_gateway_ip,
|
||||
tls_secret: $"($name)-tls",
|
||||
redis_url: $"redis://($name)-valkey.($namespace).svc.libre-wuji.local:6379",
|
||||
}
|
||||
| to json --raw
|
||||
| print
|
||||
}
|
||||
12
components/cap/metadata.ncl
Normal file
12
components/cap/metadata.ncl
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
name = "cap",
|
||||
version = "1.0.0",
|
||||
description = "Privacy-first proof-of-work CAPTCHA (Bun/JS) — co-deploys Valkey session store in the same namespace",
|
||||
tags = ["captcha", "webapp", "privacy", "bun", "valkey"],
|
||||
modes = ["cluster"],
|
||||
dependencies = [],
|
||||
provides = [{ id = "cap", version = "latest", interface = "cap" }],
|
||||
requires = [{ capability = "storage", kind = 'Mandatory }],
|
||||
conflicts_with = [],
|
||||
best_practices = [],
|
||||
}
|
||||
61
components/cap/nickel/contracts.ncl
Normal file
61
components/cap/nickel/contracts.ncl
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
let _concerns_lib = import "schemas/lib/concerns.ncl" in
|
||||
let _context_lib = import "schemas/catalog/context.ncl" in
|
||||
|
||||
{
|
||||
Cap = {
|
||||
context | _context_lib.ComponentContext | optional,
|
||||
|
||||
name | String,
|
||||
namespace | String,
|
||||
catalog_ref | String | optional,
|
||||
|
||||
image | String | default = "tiago2/cap",
|
||||
image_tag | String | default = "3.1.2",
|
||||
http_port | Number | default = 3000,
|
||||
|
||||
domain | String,
|
||||
dns_zone | String,
|
||||
acme_email | String,
|
||||
cluster_issuer | String,
|
||||
|
||||
gateway_fip | String,
|
||||
gateway_name | String | default = "libre-wuji",
|
||||
gateway_ns | String | default = "kube-system",
|
||||
|
||||
valkey_image | String | default = "valkey/valkey",
|
||||
valkey_image_tag | String | default = "9-alpine",
|
||||
valkey_storage_class | String | default = "longhorn-retain",
|
||||
valkey_storage_size | String | default = "500Mi",
|
||||
|
||||
requires | {
|
||||
storage | { size | String, persistent | Bool } | optional,
|
||||
ports | Array {
|
||||
port | Number,
|
||||
exposure | [| 'public, 'private, 'internal |] | default = 'public,
|
||||
} | default = [],
|
||||
credentials | Array String | default = [],
|
||||
} | default = {},
|
||||
|
||||
provides | {
|
||||
service | String | optional,
|
||||
port | Number | optional,
|
||||
endpoints | Array String | default = [],
|
||||
} | default = {},
|
||||
|
||||
operations | {
|
||||
install | Bool | default = true,
|
||||
update | Bool | default = true,
|
||||
delete | Bool | default = true,
|
||||
health | Bool | default = true,
|
||||
} | default = {},
|
||||
|
||||
live_check | {
|
||||
strategy | [| 'k8s_pods, 'http_get |] | default = 'k8s_pods,
|
||||
scope | [| 'cp_only, 'all_nodes |] | default = 'cp_only,
|
||||
} | default = {},
|
||||
|
||||
concerns | _concerns_lib.ServiceConcerns | optional,
|
||||
|
||||
..
|
||||
},
|
||||
}
|
||||
88
components/cap/nickel/defaults.ncl
Normal file
88
components/cap/nickel/defaults.ncl
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
let _presets = (import "schemas/lib/concerns_presets.ncl").presets in
|
||||
|
||||
{
|
||||
cap | default = {
|
||||
name | default = "cap",
|
||||
namespace | default = "cap-svc",
|
||||
catalog_ref | default = "cap",
|
||||
image | default = "tiago2/cap",
|
||||
image_tag | default = "3.1.2",
|
||||
http_port | default = 3000,
|
||||
domain | default = "",
|
||||
dns_zone | default = "",
|
||||
acme_email | default = "",
|
||||
cluster_issuer | default = "letsencrypt-prod",
|
||||
gateway_fip | default = "",
|
||||
gateway_name | default = "libre-wuji",
|
||||
gateway_ns | default = "kube-system",
|
||||
valkey_image | default = "valkey/valkey",
|
||||
valkey_image_tag | default = "9-alpine",
|
||||
valkey_storage_class | default = "longhorn-retain",
|
||||
valkey_storage_size | default = "500Mi",
|
||||
|
||||
requires | default = {
|
||||
storage = { size = "500Mi", persistent = true },
|
||||
ports = [{ port = 3000, exposure = 'public }],
|
||||
credentials = ["ADMIN_KEY"],
|
||||
},
|
||||
|
||||
provides | default = {
|
||||
service = "cap",
|
||||
port = 3000,
|
||||
endpoints = [],
|
||||
},
|
||||
|
||||
operations | default = {
|
||||
install = true,
|
||||
update = true,
|
||||
delete = true,
|
||||
health = true,
|
||||
},
|
||||
|
||||
live_check | default = {
|
||||
strategy = 'k8s_pods,
|
||||
scope = 'cp_only,
|
||||
},
|
||||
|
||||
concerns | default =
|
||||
let _name = name in
|
||||
let _domain = domain in
|
||||
let _dns_zone = dns_zone in
|
||||
let _acme_email = acme_email in
|
||||
let _cluster_issuer = cluster_issuer in
|
||||
(_presets.tls_endpoint_with_acme {
|
||||
tls_secret = "%{_name}-tls",
|
||||
cluster_issuer = _cluster_issuer,
|
||||
hostnames = [_domain],
|
||||
dns_internal = [],
|
||||
dns_zone = _dns_zone,
|
||||
acme_server = "https://acme-v02.api.letsencrypt.org/directory",
|
||||
acme_email = _acme_email,
|
||||
cert_secret_ref = "",
|
||||
cert_provider = 'cloudflare,
|
||||
backup_backlog_ref = "BACKUP-CAP-001",
|
||||
}) & {
|
||||
backup | force = {
|
||||
kind = 'pending,
|
||||
reason = "Valkey session store is ephemeral by design — PVC holds proof-of-work state only",
|
||||
backlog_ref = "BACKUP-CAP-001",
|
||||
},
|
||||
manifest_hooks = {
|
||||
init = { pre = [
|
||||
{ file = "certificate", action = 'apply, skip_if_exists = true },
|
||||
{ file = "referencegrant", action = 'apply },
|
||||
{ action = 'patch-gateway-https },
|
||||
] },
|
||||
update = { pre = [
|
||||
{ file = "certificate", action = 'apply, skip_if_exists = true },
|
||||
{ file = "referencegrant", action = 'apply },
|
||||
{ action = 'patch-gateway-https },
|
||||
] },
|
||||
delete = { pre = [
|
||||
{ action = 'remove-gateway-https },
|
||||
{ file = "referencegrant", action = 'delete },
|
||||
] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
13
components/cap/nickel/main.ncl
Normal file
13
components/cap/nickel/main.ncl
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
let contracts_lib = import "./contracts.ncl" in
|
||||
let defaults_lib = import "./defaults.ncl" in
|
||||
|
||||
{
|
||||
defaults = defaults_lib,
|
||||
|
||||
make_cap | not_exported = fun overrides =>
|
||||
defaults_lib.cap & overrides,
|
||||
|
||||
DefaultCap = defaults_lib.cap,
|
||||
Cap | contracts_lib.Cap = defaults_lib.cap,
|
||||
contracts = contracts_lib,
|
||||
}
|
||||
150
components/cert_manager/cluster/cert_manager-lib.sh
Normal file
150
components/cert_manager/cluster/cert_manager-lib.sh
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
#!/bin/bash
|
||||
# Methods library for cert_manager — sourced by run-*.sh and install-cert_manager.sh.
|
||||
# SCRIPT_DIR must be set by the caller before sourcing.
|
||||
|
||||
_kubectl() {
|
||||
if command -v kubectl >/dev/null 2>&1; then
|
||||
command kubectl "$@"
|
||||
elif command -v k0s >/dev/null 2>&1; then
|
||||
k0s kubectl "$@"
|
||||
else
|
||||
echo "ERROR: no kubectl or k0s binary found" >&2; exit 127
|
||||
fi
|
||||
}
|
||||
|
||||
_resolve_kubeconfig() {
|
||||
if [ -n "${KUBECONFIG:-}" ] && [ -f "$KUBECONFIG" ]; then
|
||||
return
|
||||
fi
|
||||
for candidate in \
|
||||
/var/lib/k0s/pki/admin.conf \
|
||||
/etc/kubernetes/admin.conf \
|
||||
/root/.kube/config; do
|
||||
if [ -f "$candidate" ]; then
|
||||
export KUBECONFIG="$candidate"
|
||||
return
|
||||
fi
|
||||
done
|
||||
if command -v k0s >/dev/null 2>&1; then
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
k0s kubeconfig admin > "$tmp" 2>/dev/null && export KUBECONFIG="$tmp" && return
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
echo "ERROR: kubeconfig not found — set KUBECONFIG explicitly" >&2; exit 1
|
||||
}
|
||||
|
||||
_require_env() {
|
||||
local var="$1"
|
||||
if [ -z "${!var:-}" ]; then
|
||||
echo "ERROR: required variable $var is not set" >&2; exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
_plan_apply() {
|
||||
local file="$1"
|
||||
local path="$SCRIPT_DIR/manifests/${file}.yaml"
|
||||
[ -f "$path" ] || { echo " [skip] ${file}.yaml not in manifests/"; return 0; }
|
||||
local content
|
||||
content=$(sed '/^\s*#/d; /^\s*$/d' "$path" | tr -d '[:space:]')
|
||||
[ -n "$content" ] || { echo " [skip] ${file}.yaml has no content"; return 0; }
|
||||
_kubectl apply -f "$path"
|
||||
}
|
||||
|
||||
_plan_apply_skip() {
|
||||
local file="$1"
|
||||
local path="$SCRIPT_DIR/manifests/${file}.yaml"
|
||||
[ -f "$path" ] || { echo " [skip] ${file}.yaml not in manifests/"; return 0; }
|
||||
[ -s "$path" ] || { echo " [skip] ${file}.yaml is empty"; return 0; }
|
||||
if _kubectl get -f "$path" >/dev/null 2>&1; then
|
||||
echo " [skip] $file already exists in cluster"
|
||||
return 0
|
||||
fi
|
||||
_kubectl apply -f "$path"
|
||||
}
|
||||
|
||||
_plan_delete() {
|
||||
local file="$1"
|
||||
local path="$SCRIPT_DIR/manifests/${file}.yaml"
|
||||
[ -f "$path" ] || { echo " [skip] ${file}.yaml not in manifests/"; return 0; }
|
||||
[ -s "$path" ] || { echo " [skip] ${file}.yaml is empty"; return 0; }
|
||||
_kubectl delete -f "$path" --ignore-not-found
|
||||
}
|
||||
|
||||
_method_apply_bundle() {
|
||||
local version="${CERT_MANAGER_VERSION:-v1.19.5}"
|
||||
local url="https://github.com/cert-manager/cert-manager/releases/download/${version}/cert-manager.yaml"
|
||||
local bundle="${SCRIPT_DIR}/cert-manager.yaml"
|
||||
if [ ! -f "$bundle" ]; then
|
||||
echo " [apply_bundle] downloading cert-manager ${version}"
|
||||
curl -fsSL "$url" -o "$bundle"
|
||||
else
|
||||
echo " [apply_bundle] bundle already present, skipping download"
|
||||
fi
|
||||
_kubectl apply --server-side -f "$bundle"
|
||||
}
|
||||
|
||||
# Patch the cert-manager controller deployment to bypass cluster CoreDNS during
|
||||
# DNS-01 challenge verification. Without this, _acme-challenge.* lookups can hit
|
||||
# split-horizon zone blocks that lack `fallthrough`, returning SERVFAIL and
|
||||
# stalling the challenge indefinitely.
|
||||
_method_patch_recursive_dns() {
|
||||
local ns="${CERT_MANAGER_NAMESPACE:-cert-manager}"
|
||||
local ns_csv="${CERT_MANAGER_DNS01_RECURSIVE_NS:-1.1.1.1:53,8.8.8.8:53}"
|
||||
local only="${CERT_MANAGER_DNS01_RECURSIVE_ONLY:-true}"
|
||||
[ -n "$ns_csv" ] || { echo " [patch_recursive_dns] no nameservers configured, skipping"; return 0; }
|
||||
|
||||
local args="[\"--dns01-recursive-nameservers=${ns_csv}\""
|
||||
if [ "$only" = "true" ] || [ "$only" = "1" ]; then
|
||||
args="${args},\"--dns01-recursive-nameservers-only\""
|
||||
fi
|
||||
args="${args}]"
|
||||
|
||||
echo " [patch_recursive_dns] dns01 recursive nameservers: ${ns_csv} (only=${only})"
|
||||
|
||||
local current
|
||||
current=$(_kubectl get deployment cert-manager -n "$ns" -o jsonpath='{.spec.template.spec.containers[0].args}' 2>/dev/null || true)
|
||||
|
||||
local needs_ns_arg=true needs_only_arg=false patched=false
|
||||
[[ "$current" == *"--dns01-recursive-nameservers=${ns_csv}"* ]] && needs_ns_arg=false
|
||||
if [ "$only" = "true" ] || [ "$only" = "1" ]; then
|
||||
[[ "$current" != *"--dns01-recursive-nameservers-only"* ]] && needs_only_arg=true
|
||||
fi
|
||||
|
||||
if $needs_ns_arg; then
|
||||
_kubectl patch deployment cert-manager \
|
||||
--namespace "$ns" \
|
||||
--type=json \
|
||||
-p "[{\"op\":\"add\",\"path\":\"/spec/template/spec/containers/0/args/-\",\"value\":\"--dns01-recursive-nameservers=${ns_csv}\"}]" \
|
||||
>/dev/null 2>&1 || true
|
||||
patched=true
|
||||
fi
|
||||
|
||||
if $needs_only_arg; then
|
||||
_kubectl patch deployment cert-manager \
|
||||
--namespace "$ns" \
|
||||
--type=json \
|
||||
-p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--dns01-recursive-nameservers-only"}]' \
|
||||
>/dev/null 2>&1 || true
|
||||
patched=true
|
||||
fi
|
||||
|
||||
if $patched; then
|
||||
echo " [patch_recursive_dns] args added to deployment"
|
||||
else
|
||||
echo " [patch_recursive_dns] args already present — no change"
|
||||
fi
|
||||
_kubectl rollout status deployment cert-manager -n "$ns" --timeout=120s
|
||||
}
|
||||
|
||||
_method_wait-ready() {
|
||||
local kind="${PLAN_PARAM_KIND:-deployment}"
|
||||
local ns="${PLAN_PARAM_NAMESPACE:-cert-manager}"
|
||||
local name="${PLAN_PARAM_NAME:-}"
|
||||
local timeout="${PLAN_PARAM_TIMEOUT:-120s}"
|
||||
[ -n "$name" ] || { echo "ERROR: PLAN_PARAM_NAME not set for wait-ready" >&2; exit 1; }
|
||||
_kubectl wait --for=condition=Available \
|
||||
"${kind}/${name}" \
|
||||
--namespace "$ns" \
|
||||
--timeout="$timeout"
|
||||
}
|
||||
4
components/cert_manager/cluster/env-cert_manager.j2
Normal file
4
components/cert_manager/cluster/env-cert_manager.j2
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
CERT_MANAGER_NAMESPACE="{{ taskserv.namespace | default(value="cert-manager") }}"
|
||||
CERT_MANAGER_VERSION="{{ taskserv.version | default(value="v1.19.5") }}"
|
||||
CERT_MANAGER_DNS01_RECURSIVE_NS="{% if taskserv.dns01_recursive_nameservers is defined %}{{ taskserv.dns01_recursive_nameservers | join(sep=',') }}{% else %}1.1.1.1:53,8.8.8.8:53{% endif %}"
|
||||
CERT_MANAGER_DNS01_RECURSIVE_ONLY="{% if taskserv.dns01_recursive_only is defined %}{{ taskserv.dns01_recursive_only }}{% else %}true{% endif %}"
|
||||
24
components/cert_manager/cluster/install-cert_manager.sh
Normal file
24
components/cert_manager/cluster/install-cert_manager.sh
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
# shellcheck source=/dev/null
|
||||
set -a
|
||||
[ -f "${SCRIPT_DIR}/env-cert_manager" ] && source "${SCRIPT_DIR}/env-cert_manager"
|
||||
set +a
|
||||
|
||||
CERT_MANAGER_NAMESPACE="${CERT_MANAGER_NAMESPACE:-cert-manager}"
|
||||
CERT_MANAGER_VERSION="${CERT_MANAGER_VERSION:-v1.19.5}"
|
||||
|
||||
CMD_TSK="${CMD_TSK:-${1:-install}}"
|
||||
|
||||
source "${SCRIPT_DIR}/cert_manager-lib.sh"
|
||||
_resolve_kubeconfig
|
||||
|
||||
case "$CMD_TSK" in
|
||||
install|init) bash "${SCRIPT_DIR}/run-init.sh" ;;
|
||||
update) bash "${SCRIPT_DIR}/run-update.sh" ;;
|
||||
delete) bash "${SCRIPT_DIR}/run-delete.sh" ;;
|
||||
*) echo "ERROR: unknown CMD_TSK='$CMD_TSK'. Valid: install|update|delete" >&2; exit 1 ;;
|
||||
esac
|
||||
33
components/cert_manager/cluster/manifest_plan.ncl
Normal file
33
components/cert_manager/cluster/manifest_plan.ncl
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
let mp = import "schemas/lib/manifest_plan.ncl" in
|
||||
|
||||
{
|
||||
manifest_plan | mp.ManifestPlan = {
|
||||
init = [
|
||||
{ action = 'apply_bundle, params = { source = "download" } },
|
||||
{ action = 'wait-ready, params = {
|
||||
kind = "deployment",
|
||||
namespace = "cert-manager",
|
||||
name = "cert-manager-webhook",
|
||||
timeout = "180s",
|
||||
}},
|
||||
{ action = 'patch_recursive_dns },
|
||||
{ file = "cf-token-secret", action = 'apply },
|
||||
{ file = "cluster-issuer", action = 'apply },
|
||||
{ file = "tls-backup", action = 'apply },
|
||||
{ file = "csi-driver", action = 'apply },
|
||||
],
|
||||
update = [
|
||||
{ action = 'patch_recursive_dns },
|
||||
{ file = "cf-token-secret", action = 'apply },
|
||||
{ file = "cluster-issuer", action = 'apply },
|
||||
{ file = "tls-backup", action = 'apply },
|
||||
{ file = "csi-driver", action = 'apply },
|
||||
],
|
||||
delete = [
|
||||
{ file = "csi-driver", action = 'delete },
|
||||
{ file = "cluster-issuer", action = 'delete },
|
||||
{ file = "tls-backup", action = 'delete },
|
||||
{ file = "cf-token-secret", action = 'delete },
|
||||
],
|
||||
},
|
||||
}
|
||||
6
components/cert_manager/cluster/manifests/README
Normal file
6
components/cert_manager/cluster/manifests/README
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
The install.sh downloads the upstream cert-manager bundle at deploy time:
|
||||
|
||||
curl -fsSL https://github.com/cert-manager/cert-manager/releases/download/{{CERT_MANAGER_VERSION}}/cert-manager.yaml
|
||||
|
||||
No pre-downloaded YAML is required. If offline deploy is needed, place cert-manager.yaml here
|
||||
and install.sh will skip the download step (file presence check).
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{% if cf_secret_ref %}apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ cf_secret_ref }}
|
||||
namespace: {{ taskserv.namespace }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
api-token: "{{ cf_api_token }}"{% else %}# cf-token-secret: no cluster issuers configured{% endif %}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{% if issuers_block %}{{ issuers_block | safe }}{% else %}# cluster-issuer: no cluster issuers configured{% endif %}
|
||||
158
components/cert_manager/cluster/templates/csi-driver.yaml.j2
Normal file
158
components/cert_manager/cluster/templates/csi-driver.yaml.j2
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
{% if taskserv.csi_driver_enabled %}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: cert-manager-csi-driver
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: cert-manager-csi-driver
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: cert-manager-csi-driver
|
||||
labels:
|
||||
app.kubernetes.io/name: cert-manager-csi-driver
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["events"]
|
||||
verbs: ["create", "patch"]
|
||||
- apiGroups: ["cert-manager.io"]
|
||||
resources: ["certificaterequests"]
|
||||
verbs: ["get", "list", "watch", "create", "delete", "update", "patch"]
|
||||
- apiGroups: ["cert-manager.io"]
|
||||
resources: ["certificaterequests/status"]
|
||||
verbs: ["update", "patch"]
|
||||
- apiGroups: ["cert-manager.io"]
|
||||
resources: ["certificates"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: cert-manager-csi-driver
|
||||
labels:
|
||||
app.kubernetes.io/name: cert-manager-csi-driver
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: cert-manager-csi-driver
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: cert-manager-csi-driver
|
||||
namespace: {{ taskserv.namespace }}
|
||||
---
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: CSIDriver
|
||||
metadata:
|
||||
name: csi.cert-manager.io
|
||||
labels:
|
||||
app.kubernetes.io/name: cert-manager-csi-driver
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
spec:
|
||||
attachRequired: false
|
||||
podInfoOnMount: true
|
||||
volumeLifecycleModes:
|
||||
- Ephemeral
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: cert-manager-csi-driver
|
||||
namespace: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/name: cert-manager-csi-driver
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cert-manager-csi-driver
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cert-manager-csi-driver
|
||||
app.kubernetes.io/name: cert-manager-csi-driver
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
spec:
|
||||
serviceAccountName: cert-manager-csi-driver
|
||||
hostNetwork: false
|
||||
containers:
|
||||
- name: node-driver-registrar
|
||||
image: registry.k8s.io/sig-storage/csi-node-driver-registrar:v2.11.1
|
||||
args:
|
||||
- --v=5
|
||||
- --csi-address=/plugin/csi.sock
|
||||
- --kubelet-registration-path=/var/lib/kubelet/plugins/csi.cert-manager.io/csi.sock
|
||||
env:
|
||||
- name: KUBE_NODE_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: spec.nodeName
|
||||
volumeMounts:
|
||||
- name: plugin-dir
|
||||
mountPath: /plugin
|
||||
- name: registration-dir
|
||||
mountPath: /registration
|
||||
- name: csi-driver
|
||||
image: quay.io/jetstack/cert-manager-csi-driver:{{ taskserv.csi_driver_version }}
|
||||
args:
|
||||
- --log-level=1
|
||||
- --driver-name=csi.cert-manager.io
|
||||
- --node-id=$(NODE_ID)
|
||||
- --endpoint=$(CSI_ENDPOINT)
|
||||
- --data-root=/csi-data-dir
|
||||
env:
|
||||
- name: NODE_ID
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: spec.nodeName
|
||||
- name: CSI_ENDPOINT
|
||||
value: unix://plugin/csi.sock
|
||||
securityContext:
|
||||
privileged: true
|
||||
runAsUser: 0
|
||||
volumeMounts:
|
||||
- name: plugin-dir
|
||||
mountPath: /plugin
|
||||
- name: pods-mount-dir
|
||||
mountPath: /var/lib/kubelet/pods
|
||||
mountPropagation: Bidirectional
|
||||
- name: csi-data-dir
|
||||
mountPath: /csi-data-dir
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
volumes:
|
||||
- name: plugin-dir
|
||||
hostPath:
|
||||
path: /var/lib/kubelet/plugins/csi.cert-manager.io
|
||||
type: DirectoryOrCreate
|
||||
- name: pods-mount-dir
|
||||
hostPath:
|
||||
path: /var/lib/kubelet/pods
|
||||
type: Directory
|
||||
- name: registration-dir
|
||||
hostPath:
|
||||
path: /var/lib/kubelet/plugins_registry
|
||||
type: Directory
|
||||
- name: csi-data-dir
|
||||
hostPath:
|
||||
path: /var/lib/cert-manager-csi
|
||||
type: DirectoryOrCreate
|
||||
{% else %}
|
||||
# csi_driver_enabled = false — cert-manager CSI driver not installed
|
||||
{% endif %}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: {{ taskserv.namespace }}
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: provisioning
|
||||
225
components/cert_manager/cluster/templates/tls-backup.yaml.j2
Normal file
225
components/cert_manager/cluster/templates/tls-backup.yaml.j2
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
{% if tls_backup_enabled %}---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: tls-backup
|
||||
namespace: {{ taskserv.namespace }}
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: tls-backup-reader
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["get", "list"]
|
||||
- apiGroups: ["cert-manager.io"]
|
||||
resources: ["certificates", "clusterissuers"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: tls-backup-reader
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: tls-backup-reader
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: tls-backup
|
||||
namespace: {{ taskserv.namespace }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: restic-tls
|
||||
namespace: {{ taskserv.namespace }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
RESTIC_REPOSITORY: "{{ restic_repository }}"
|
||||
RESTIC_PASSWORD: "{{ restic_password }}"
|
||||
AWS_ACCESS_KEY_ID: "{{ aws_access_key_id }}"
|
||||
AWS_SECRET_ACCESS_KEY: "{{ aws_secret_access_key }}"
|
||||
AWS_DEFAULT_REGION: "{{ aws_default_region }}"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: tls-backup-watcher
|
||||
namespace: {{ taskserv.namespace }}
|
||||
data:
|
||||
watch.nu: |
|
||||
#!/usr/bin/env nu
|
||||
|
||||
$env.PATH = ($env.PATH | append "/kubectl-bin")
|
||||
|
||||
const CERT_NS = "cert-manager"
|
||||
|
||||
def dump_all [] {
|
||||
print "[tls-backup] dumping TLS secrets"
|
||||
let r = (do { ^kubectl get secrets -A -l cert-manager.io/certificate-name -o yaml } | complete)
|
||||
if $r.exit_code == 0 {
|
||||
$r.stdout | save --force /dump/tls-secrets.yaml
|
||||
} else {
|
||||
print $"[tls-backup] warn: secrets dump failed: ($r.stderr | str trim)"
|
||||
}
|
||||
|
||||
print "[tls-backup] dumping ACME keys"
|
||||
let ri = (do { ^kubectl get clusterissuers -o json } | complete)
|
||||
if $ri.exit_code == 0 {
|
||||
$ri.stdout | from json | get -o items | default []
|
||||
| each {|i| $i | get -o spec.acme.privateKeySecretRef.name | default ""}
|
||||
| where {|k| $k | is-not-empty}
|
||||
| each {|key|
|
||||
let rk = (do { ^kubectl get secret -n $CERT_NS $key -o yaml } | complete)
|
||||
if $rk.exit_code == 0 { $rk.stdout | save --force $"/dump/acme-key--($key).yaml" }
|
||||
}
|
||||
| ignore
|
||||
}
|
||||
|
||||
"" | save --force /trigger/ready
|
||||
print "[tls-backup] backup trigger written"
|
||||
}
|
||||
|
||||
def process_event [line: string] {
|
||||
let parts = ($line | split row ' ')
|
||||
if ($parts | length) < 3 { return }
|
||||
let evtype = ($parts | get 0? | default "")
|
||||
let ns = ($parts | get 1? | default "")
|
||||
let name = ($parts | get 2? | default "")
|
||||
if $evtype != "MODIFIED" { return }
|
||||
let fmt = 'jsonpath={.status.conditions[?(@.type=="Ready")].status}'
|
||||
let r = (do { ^kubectl get certificate $name -n $ns -o $fmt } | complete)
|
||||
if ($r.stdout | str trim) == "True" {
|
||||
print $"[tls-backup] ($ns)/($name) Ready"
|
||||
dump_all
|
||||
}
|
||||
}
|
||||
|
||||
print "[tls-backup] watcher started"
|
||||
loop {
|
||||
print "[tls-backup] watching certificates"
|
||||
let watch_fmt = 'jsonpath={.type} {.object.metadata.namespace} {.object.metadata.name}{"\n"}'
|
||||
try {
|
||||
^kubectl get certificates -A -w --output-watch-events=true -o $watch_fmt
|
||||
| lines
|
||||
| where {|l| $l | str trim | is-not-empty}
|
||||
| each {|line| process_event $line; null}
|
||||
| ignore
|
||||
} catch {|e|
|
||||
print $"[tls-backup] watch ended: ($e.msg)"
|
||||
}
|
||||
sleep 10sec
|
||||
}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: tls-backup-watcher
|
||||
namespace: {{ taskserv.namespace }}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: tls-backup-watcher
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: tls-backup-watcher
|
||||
spec:
|
||||
serviceAccountName: tls-backup
|
||||
initContainers:
|
||||
- name: install-kubectl
|
||||
image: alpine:3.21
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
set -euo pipefail
|
||||
apk add --no-cache curl
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in x86_64) ARCH=amd64 ;; aarch64|arm64) ARCH=arm64 ;; esac
|
||||
curl -fsSL https://dl.k8s.io/release/{{ tls_backup_kubectl_version }}/bin/linux/${ARCH}/kubectl \
|
||||
-o /kubectl-bin/kubectl
|
||||
chmod +x /kubectl-bin/kubectl
|
||||
echo "[tls-backup] kubectl installed (arch=${ARCH})"
|
||||
volumeMounts:
|
||||
- name: kubectl-bin
|
||||
mountPath: /kubectl-bin
|
||||
volumes:
|
||||
- name: kubectl-bin
|
||||
emptyDir: {}
|
||||
- name: dump
|
||||
emptyDir: {}
|
||||
- name: trigger
|
||||
emptyDir: {}
|
||||
- name: scripts
|
||||
configMap:
|
||||
name: tls-backup-watcher
|
||||
defaultMode: 0755
|
||||
containers:
|
||||
- name: watcher
|
||||
image: {{ tls_backup_nushell_image }}
|
||||
command: ["nu", "/scripts/watch.nu"]
|
||||
volumeMounts:
|
||||
- name: kubectl-bin
|
||||
mountPath: /kubectl-bin
|
||||
- name: dump
|
||||
mountPath: /dump
|
||||
- name: trigger
|
||||
mountPath: /trigger
|
||||
- name: scripts
|
||||
mountPath: /scripts
|
||||
- name: backup
|
||||
image: restic/restic:0.17.3
|
||||
env:
|
||||
- name: RESTIC_REPOSITORY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: restic-tls
|
||||
key: RESTIC_REPOSITORY
|
||||
- name: RESTIC_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: restic-tls
|
||||
key: RESTIC_PASSWORD
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: restic-tls
|
||||
key: AWS_ACCESS_KEY_ID
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: restic-tls
|
||||
key: AWS_SECRET_ACCESS_KEY
|
||||
- name: AWS_DEFAULT_REGION
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: restic-tls
|
||||
key: AWS_DEFAULT_REGION
|
||||
volumeMounts:
|
||||
- name: dump
|
||||
mountPath: /dump
|
||||
readOnly: true
|
||||
- name: trigger
|
||||
mountPath: /trigger
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
set -euo pipefail
|
||||
restic snapshots >/dev/null 2>&1 || restic init
|
||||
echo "[tls-backup] backup container ready, watching for trigger"
|
||||
while true; do
|
||||
if [ -f /trigger/ready ]; then
|
||||
echo "[tls-backup] trigger detected, running backup"
|
||||
restic backup /dump \
|
||||
--tag tls --tag workspace=libre-wuji \
|
||||
--host libre-wuji-cluster
|
||||
restic forget --keep-daily 14 --keep-weekly 8 --keep-monthly 12 --prune
|
||||
rm -f /trigger/ready
|
||||
echo "[tls-backup] backup complete"
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
{% else %}# tls-backup: not enabled (set tls_backup.enabled = true in component config){% endif %}
|
||||
109
components/cert_manager/cluster/vars.nu
Normal file
109
components/cert_manager/cluster/vars.nu
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
#!/usr/bin/env nu
|
||||
# Derived variables for cert_manager bundle rendering.
|
||||
# cf_api_token is injected by components.nu via SOPS decryption before this script runs.
|
||||
# CF_TOKEN_<ZONE> env var still works as an override (CI/CD pipelines).
|
||||
# Renders per-issuer blocks into flat strings for {{var}} substitution.
|
||||
|
||||
def render_solver [s: record]: nothing -> string {
|
||||
# selector must nest INSIDE this solver's own list item, as a sibling of
|
||||
# dns01 (cert-manager's Solver type) — not a sibling of the "solvers"
|
||||
# array, which YAML would otherwise happily accept with no warning while
|
||||
# cert-manager silently misapplies it. Built as an explicit line list so
|
||||
# indentation is exact regardless of which fields are present.
|
||||
let dns_zones = ($s.dns_zones? | default [])
|
||||
let lines = (
|
||||
if ($dns_zones | is-not-empty) {
|
||||
let zone_lines = ($dns_zones | each {|z| $" - \"($z)\"" })
|
||||
[" - selector:", " dnsZones:"] ++ $zone_lines ++ [" dns01:"]
|
||||
} else {
|
||||
[" - dns01:"]
|
||||
}
|
||||
) ++ [
|
||||
" cloudflare:",
|
||||
" apiTokenSecretRef:",
|
||||
$" name: ($s.secret_ref)",
|
||||
" key: api-token",
|
||||
]
|
||||
$lines | str join "\n"
|
||||
}
|
||||
|
||||
# One issuer can carry multiple DNS-01 solvers — a catch-all (no dns_zones)
|
||||
# plus zone-scoped ones for certs whose SANs span more than one Cloudflare
|
||||
# zone. issuer.solvers takes precedence; issuer.secret_ref (single, legacy
|
||||
# shape) is used only when solvers is absent, for backward compatibility.
|
||||
def render_acme_issuer [issuer: record]: nothing -> string {
|
||||
let server = ($issuer.acme_server? | default "https://acme-v02.api.letsencrypt.org/directory")
|
||||
let email = ($issuer.email? | default "")
|
||||
let solvers = if ($issuer.solvers? | default [] | is-not-empty) {
|
||||
$issuer.solvers
|
||||
} else {
|
||||
[{ secret_ref: ($issuer.secret_ref? | default "") }]
|
||||
}
|
||||
let solvers_block = ($solvers | each {|s| render_solver $s } | str join "\n")
|
||||
$"apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: ($issuer.name)
|
||||
spec:
|
||||
acme:
|
||||
server: ($server)
|
||||
email: ($email)
|
||||
privateKeySecretRef:
|
||||
name: ($issuer.name)-acme-key
|
||||
solvers:
|
||||
($solvers_block)"
|
||||
}
|
||||
|
||||
def cf_token_from_env [secret_ref: string]: nothing -> string {
|
||||
let env_var = ($"CF_TOKEN_($secret_ref | str upcase | str replace --all '-' '_')")
|
||||
$env | get -o $env_var | default ""
|
||||
}
|
||||
|
||||
def main [component_json: string]: nothing -> nothing {
|
||||
let d = ($component_json | from json)
|
||||
let ns = ($d.namespace? | default "cert-manager")
|
||||
let version = ($d.version? | default "v1.19.5")
|
||||
|
||||
let issuers_block = (
|
||||
$d.cluster_issuers?
|
||||
| default []
|
||||
| where { |i| ($i.kind? | default "") == "acme" }
|
||||
| each { |i| render_acme_issuer $i }
|
||||
| str join "\n---\n"
|
||||
)
|
||||
|
||||
let acme_issuers = (
|
||||
$d.cluster_issuers?
|
||||
| default []
|
||||
| where { |i| ($i.kind? | default "") == "acme" }
|
||||
)
|
||||
let cf_secret_ref = (
|
||||
if ($acme_issuers | length) > 0 {
|
||||
$acme_issuers | first | get -o secret_ref | default ""
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)
|
||||
let injected = ($d.cf_api_token? | default "")
|
||||
let cf_token = if ($injected | is-not-empty) { $injected } else { cf_token_from_env $cf_secret_ref }
|
||||
|
||||
let tls_backup = ($d.tls_backup? | default {})
|
||||
|
||||
{
|
||||
namespace: $ns,
|
||||
version: $version,
|
||||
issuers_block: $issuers_block,
|
||||
cf_secret_ref: $cf_secret_ref,
|
||||
cf_api_token: $cf_token,
|
||||
restic_repository: ($env.RESTIC_REPOSITORY? | default ""),
|
||||
restic_password: ($env.RESTIC_PASSWORD? | default ""),
|
||||
aws_access_key_id: ($env.AWS_ACCESS_KEY_ID? | default ""),
|
||||
aws_secret_access_key: ($env.AWS_SECRET_ACCESS_KEY? | default ""),
|
||||
aws_default_region: ($env.AWS_DEFAULT_REGION? | default ""),
|
||||
tls_backup_enabled: ($tls_backup.enabled? | default false),
|
||||
tls_backup_kubectl_version: ($tls_backup.kubectl_version? | default "v1.32.3"),
|
||||
tls_backup_nushell_image: ($tls_backup.nushell_image? | default "ghcr.io/nushell/nushell:latest"),
|
||||
}
|
||||
| to json --raw
|
||||
| print
|
||||
}
|
||||
9
components/cert_manager/metadata.ncl
Normal file
9
components/cert_manager/metadata.ncl
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
name = "cert_manager",
|
||||
version = "1.17.4",
|
||||
category = "component",
|
||||
description = "TLS certificate lifecycle controller — DNS-01 via Cloudflare, cert-manager v1.17.4",
|
||||
mode = "cluster",
|
||||
dependencies = ["cloudflare"],
|
||||
tags = ["tls", "certificates", "acme", "dns-01"],
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue