catalog: clean-start baseline — content library, OCI-distributed (constellation materialization)

This commit is contained in:
Jesús Pérez 2026-07-09 21:57:57 +01:00
parent 928a614f42
commit 55f841517c
Signed by: jesus
GPG key ID: 9F243E355E0BC939
1700 changed files with 126405 additions and 0 deletions

112
.gitignore vendored Normal file
View 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

View 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
View 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

View 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
View 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

View 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"}

View file

@ -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
View file

@ -0,0 +1,2 @@
untrusted comment: minisign public key 63C2FCBB16EE788C
RWSMeO4Wu/zCY7O4EOAXvT4o4LOzqvkzfW+t6ay9VXNxgZnwHdydxnnd

34
.pre-commit-config.yaml Normal file
View 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
View 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
View 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

View 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`

View 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)"

View 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)"

View 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."

View 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)"

View 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)"
}

View 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.

View 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
```

View 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

View 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
}

View 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
}
}

View 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"],
}

View 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,
..
},
}

View 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" },
},
},
}

View 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,
}

View file

@ -0,0 +1 @@
"0.1.0"

View 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"

View 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=";") }}

View 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

View 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"

View file

@ -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

View 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

View file

@ -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

View 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
],
}

View 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,
},
},
}

View 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,
},
},
}

View 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,
}

View 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",
}

View 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}'"
}

View 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") }}

View 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

View 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 }] },
],
},
}

View file

@ -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

View file

@ -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 %}

View 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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: {{ taskserv.namespace }}
labels:
app.kubernetes.io/managed-by: provisioning

View 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 %}

View file

@ -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 %}

View 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

View 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,
..
},
}

View 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" },
},
},
}

View 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,
}

View file

@ -0,0 +1 @@
{ version = "0.1.0", nickel_api = "1.0" }

View 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

View file

@ -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]

View 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"],
}

View 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,
..
},
}

View 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" },
},
},
}

View 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,
}

View file

@ -0,0 +1 @@
{ version = "1.0.0", nickel_api = "1.0" }

View 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)"
}
}

View 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
}

View 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)" }
}
}
}

View 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)"
}
}

View file

@ -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"]

View 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
}

View 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 }}

View 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

View 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 },
],
},
],
},
}

View 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

View 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

View 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

View file

@ -0,0 +1,7 @@
apiVersion: v1
kind: Namespace
metadata:
name: {{ taskserv.namespace }}
labels:
app.kubernetes.io/managed-by: provisioning
layer: application

View 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 }}

View 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

View 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

View 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 }}

View 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

View 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
}

View 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 = [],
}

View 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,
..
},
}

View 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 },
] },
},
},
},
}

View 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,
}

View 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"
}

View 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 %}"

View 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

View 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 },
],
},
}

View 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).

View file

@ -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 %}

View file

@ -0,0 +1 @@
{% if issuers_block %}{{ issuers_block | safe }}{% else %}# cluster-issuer: no cluster issuers configured{% endif %}

View 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 %}

View file

@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: {{ taskserv.namespace }}
labels:
app.kubernetes.io/managed-by: provisioning

View 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 %}

View 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
}

View 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