Edge cluster from scratch -- every file you write by hand
For a repo without an existing edge cluster or its secrets: what to create,
where, under which name, with which content -- as cat <<'EOF' blocks to
copy. Everything not listed here is either generated (blueprints/flux
render-cluster-apps) or committed by the Flux bootstrap ; see the
runbook in runbook.md (Recreate from scratch ) for the order,
architecture.md for the picture.
Part
Files
Made with
Once per
0. The age key
outside the repo
age-keygen
repo / team
1. Persistent secrets
secrets/edge/*.enc.yaml, edge-root-ca.crt, infra/ca/edge-ca.enc.yaml
openssl, sops
ever (shared by every edge cluster: lab VMs and box)
2. Cluster files
clusters/<cluster>/… (k3s files: k3s.md )
cat <<'EOF'
cluster
2B. Flux files by hand
apps.yaml, the bundles, cluster-secrets/, the wiring -- instead of render-cluster-apps
cat <<'EOF', sops
cluster
3. Terraform env files
clusters/edge/terraform/*/env/<env>.auto.tfvars.json
cat <<'EOF'
environment
In phases, with a commit and a check after each:
step by step .
Conventions in every block below: run from the repo root; plaintext only under
umask 077 in a temp directory, shred -u it afterwards; never echo a
secret into a terminal or a log.
0. The age key
SOPS, age, the key and both ways to encrypt (CLI or Dagger) in general:
SOPS + age .
SOPS encrypts for an age public key. If the team has one, use it (it is the
"master key": every SOPS file in the repo is readable with it):
export SOPS_AGE_KEY = $( cat ~/.config/sops/age/keys.txt) # or from your password manager
export AGE_PUB = $( age-keygen -y <<< " $SOPS_AGE_KEY " )
None yet: age-keygen -o ~/.config/sops/age/keys.txt (mode 600), keep a copy
in the team's password manager. Losing it means losing every secret below.
The helpers used in all blocks: enc encrypts, getval reads one value.
Pick one set , with the sops CLI or with Dagger. Both give the same files.
# sops CLI
enc() { # enc <plaintext.yaml> <target.enc.yaml>: encrypt only data/stringData
sops --encrypt --age " $AGE_PUB " --encrypted-regex '^(data|stringData)$' \
--input-type yaml --output-type yaml " $1 " > " $2 " && shred -u " $1 "
}
getval() { # getval <file.enc.yaml> <key>: one stringData value, to stdout
sops -d --extract "[\"stringData\"][\" $2 \"]" " $1 "
}
1
2
3
4
5
6
7
8
9
10
11
12 # Dagger (github.com/stuttgart-things/dagger/sops) -- nothing to install but Dagger
SOPS_MOD = github.com/stuttgart-things/dagger/sops@v0.137.1
enc() {
env -u SSH_AUTH_SOCK dagger call -s -m $SOPS_MOD encrypt \
--age-key env:AGE_PUB --encrypted-regex '^(data|stringData)$' \
--plaintext-file " $1 " export --path " $2 " >/dev/null && shred -u " $1 "
}
getval() {
env -u SSH_AUTH_SOCK dagger call -s -m $SOPS_MOD decrypt \
--age-key env:SOPS_AGE_KEY --encrypted-file " $1 " \
--extract "[\"stringData\"][\" $2 \"]" contents
}
With Dagger the plaintext passes through the local Dagger engine. Each call
starts a container, so 2B's secrets take a few minutes this way.
1. Persistent secrets
Once ever. They are what makes a reinstall -- or another cluster, or the
move to the box -- invisible to clients and devices: same root CA, same
OpenBao seal key, same passwords. A second edge cluster reuses them
(clusters/edge-test2 does). Skip any that already exist.
1.1 The edge CA
Root (EC P-384, 20 years, key stays offline) and the intermediate for
cert-manager (EC P-256, 5 years, pathlen:0). The OpenBao intermediate is
not made here: its key is generated inside OpenBao
(terraform/openbao/sign-intermediate.sh).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 umask 077 ; W = $( mktemp -d) ; cd " $W "
openssl ecparam -name secp384r1 -genkey -noout -out root.key
openssl req -x509 -new -key root.key -sha384 -days 7300 \
-subj "/O=stuttgart-things/CN=stuttgart-things edge root CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "subjectKeyIdentifier=hash" -out root.crt
openssl ecparam -name prime256v1 -genkey -noout -out int.key
openssl req -new -key int.key \
-subj "/O=stuttgart-things/CN=stuttgart-things edge intermediate CA (cert-manager)" -out int.csr
cat > int.ext <<'EOF'
basicConstraints=critical,CA:TRUE,pathlen:0
keyUsage=critical,keyCertSign,cRLSign,digitalSignature
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid:always
EOF
openssl x509 -req -in int.csr -CA root.crt -CAkey root.key -CAcreateserial \
-sha384 -days 1826 -extfile int.ext -out int.crt
openssl verify -CAfile root.crt int.crt # int.crt: OK
cd - >/dev/null
Three files from it -- secrets/edge/root-ca.enc.yaml (root key and every
intermediate key; never applied, namespace: not-applied):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 mkdir -p secrets/edge
cat > " $W /root-ca.yaml" <<EOF
apiVersion: v1
kind: Secret
metadata:
name: edge-root-ca
namespace: not-applied
type: Opaque
stringData:
root.crt: |
$(sed 's/^/ /' "$W/root.crt")
root.key: |
$(sed 's/^/ /' "$W/root.key")
intermediate-cert-manager.crt: |
$(sed 's/^/ /' "$W/int.crt")
intermediate-cert-manager.key: |
$(sed 's/^/ /' "$W/int.key")
EOF
enc " $W /root-ca.yaml" secrets/edge/root-ca.enc.yaml
clusters/<cluster>/edge-root-ca.crt -- the public root, what clients and
devices trust (plaintext, committed; copy it into every edge cluster folder and
into lab/esp-mock/):
cp " $W /root.crt" clusters/edge/edge-root-ca.crt
clusters/<cluster>/infra/ca/edge-ca.enc.yaml -- the intermediate as the
Secret cert-manager/edge-ca that the ClusterIssuer edge-ca signs with
(tls.crt = intermediate + root):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 mkdir -p clusters/edge/infra/ca
cat > " $W /edge-ca.yaml" <<EOF
apiVersion: v1
kind: Secret
metadata:
name: edge-ca
namespace: cert-manager
type: kubernetes.io/tls
stringData:
tls.crt: |
$(cat "$W/int.crt" "$W/root.crt" | sed 's/^/ /')
tls.key: |
$(sed 's/^/ /' "$W/int.key")
ca.crt: |
$(sed 's/^/ /' "$W/root.crt")
EOF
enc " $W /edge-ca.yaml" clusters/edge/infra/ca/edge-ca.enc.yaml
find " $W " -type f -exec shred -u {} +; rmdir " $W "
1.2 The OpenBao seal key -- secrets/edge/openbao-seal.enc.yaml
32 random bytes, base64 (44 characters). Never change it once an OpenBao
has started with it: its raft data is sealed with this key.
1
2
3
4
5
6
7
8
9
10
11
12 umask 077 ; T = $( mktemp)
cat > " $T " <<EOF
apiVersion: v1
kind: Secret
metadata:
name: openbao-static-seal
namespace: openbao
type: Opaque
stringData:
key: "$(openssl rand -base64 32)"
EOF
enc " $T " secrets/edge/openbao-seal.enc.yaml
1.3 The app values -- secrets/edge/app-values.enc.yaml
Every value cluster-apps.yaml refers to with ref+sops:// (never applied
itself). Four pairs must be equal -- the two sides of one credential --
and one value is derived:
Key
Shape (example generator)
Read by
HOMERUN2_REDIS_PASSWORD
48 hex (openssl rand -hex 24)
the source of the next two
HOMERUN2_REDIS_PASSWORD_B64
base64 of HOMERUN2_REDIS_PASSWORD
homerun2, light-catcher-tabletennis
ZAEHLWERK_REDIS_PASSWORD
= HOMERUN2_REDIS_PASSWORD
zaehlwerk (panel)
HOMERUN2_OMNI_PITCHER_AUTH_TOKEN
48 hex
homerun2 omni-pitcher
ZAEHLWERK_OMNI_PITCHER_TOKEN
= HOMERUN2_OMNI_PITCHER_AUTH_TOKEN
zaehlwerk (panel)
HOMERUN2_SCOUT_AUTH_TOKEN
48 hex
homerun2 scout
TEAMS_WEBHOOK_URL
URL or empty (notification-catcher stays in dry run)
homerun2
SCHMETTERPAUSE_DB_USERNAME
schmetterpause (fixed by the catalog)
--
SCHMETTERPAUSE_DB_PASSWORD
48 hex
schmetterpause, CNPG
SCHMETTERPAUSE_SESSION_KEY
64 hex (openssl rand -hex 32)
schmetterpause (signs sessions; changing it signs everyone out)
SCHMETTERPAUSE_SCOREBOARD_TOKEN
64 hex
schmetterpause /api and zaehlwerk (one credential, read twice)
MINIO_ADMIN_USER / MINIO_ADMIN_PASSWORD
e.g. edge-admin / 48 hex
MinIO, terraform/minio
MINIO_CNPG_USER / MINIO_CNPG_PASSWORD
schmetterpause-cnpg / 48 hex
terraform/minio creates this user
SCHMETTERPAUSE_BACKUP_ACCESS_KEY_ID
= MINIO_CNPG_USER
CNPG backups
SCHMETTERPAUSE_BACKUP_SECRET_ACCESS_KEY
= MINIO_CNPG_PASSWORD
CNPG backups
OPENBAO_TERRAFORM_PASSWORD
32 alnum
OpenBao self-init user terraform, terraform/openbao
OPENBAO_ADMIN_PASSWORD
32 alnum
OpenBao self-init user admin (break-glass, by hand only)
HETZNER_DNS_TOKEN
from the Hetzner Console (project holding only the zone, Read & Write)
cert-manager-letsencrypt-hetzner
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36 umask 077 ; T = $( mktemp)
hex() { openssl rand -hex " $1 " ; }
aln() { openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c " $1 " ; }
REDIS = $( hex 24 ) ; OMNI = $( hex 24 ) ; CNPG = $( hex 24 )
read -rsp 'Hetzner DNS token (input hidden): ' HETZNER; echo
cat > " $T " <<EOF
apiVersion: v1
kind: Secret
metadata:
name: edge-app-values
namespace: not-applied
type: Opaque
stringData:
HOMERUN2_REDIS_PASSWORD: "$REDIS"
HOMERUN2_REDIS_PASSWORD_B64: "$(printf %s "$REDIS" | base64 -w0)"
ZAEHLWERK_REDIS_PASSWORD: "$REDIS"
HOMERUN2_OMNI_PITCHER_AUTH_TOKEN: "$OMNI"
ZAEHLWERK_OMNI_PITCHER_TOKEN: "$OMNI"
HOMERUN2_SCOUT_AUTH_TOKEN: "$(hex 24)"
TEAMS_WEBHOOK_URL: ""
SCHMETTERPAUSE_DB_USERNAME: schmetterpause
SCHMETTERPAUSE_DB_PASSWORD: "$(hex 24)"
SCHMETTERPAUSE_SESSION_KEY: "$(hex 32)"
SCHMETTERPAUSE_SCOREBOARD_TOKEN: "$(hex 32)"
MINIO_ADMIN_USER: edge-admin
MINIO_ADMIN_PASSWORD: "$(hex 24)"
MINIO_CNPG_USER: schmetterpause-cnpg
MINIO_CNPG_PASSWORD: "$CNPG"
SCHMETTERPAUSE_BACKUP_ACCESS_KEY_ID: schmetterpause-cnpg
SCHMETTERPAUSE_BACKUP_SECRET_ACCESS_KEY: "$CNPG"
OPENBAO_TERRAFORM_PASSWORD: "$(aln 32)"
OPENBAO_ADMIN_PASSWORD: "$(aln 32)"
HETZNER_DNS_TOKEN: "$HETZNER"
EOF
unset REDIS OMNI CNPG HETZNER
enc " $T " secrets/edge/app-values.enc.yaml
Later additions to an existing file -- one key, without decrypting the rest:
sops set secrets/edge/app-values.enc.yaml '["stringData"]["NEW_KEY"]' "\" $( openssl rand -hex 32 ) \""
Check
for f in secrets/edge/*.enc.yaml clusters/edge/infra/ca/edge-ca.enc.yaml; do
sops -d " $f " >/dev/null && echo "ok $f " ; done
sops -d secrets/edge/app-values.enc.yaml | yq '.stringData | keys | length' # 20
2. Cluster files
Per cluster, in clusters/<cluster>/ -- the example is edge-test2 (labul).
Set once per shell:
CLUSTER = edge-test2 # folder name: clusters/$CLUSTER
mkdir -p clusters/$CLUSTER /{ infra/ca,k3s}
2.1 clusters/$CLUSTER/cluster-vars.yaml
What differs between environments -- the only place with addresses and names.
Every layer substitutes ${EDGE_*} from it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 cat > clusters/$CLUSTER /cluster-vars.yaml <<'EOF'
---
# What differs between environments (lab VM / box). Only plain strings, no
# secrets. Every layer substitutes ${EDGE_*} from this ConfigMap.
apiVersion: v1
kind: ConfigMap
metadata:
name: cluster-vars
namespace: flux-system
data:
EDGE_CLUSTER_NAME: edge-tt-test2
# internal names: *.EDGE_DOMAIN -> EDGE_LB_IP (Clusterbook / the router)
EDGE_DOMAIN: edge-tt-test2.sthings-vsphere.labul.sva.de
EDGE_LB_IP: 10.31.102.7
EDGE_GATEWAY_NAME: edge-gateway
# public players' names: *.EDGE_PLAY_DOMAIN -> EDGE_PLAY_LB_IP (Hetzner DNS)
EDGE_PLAY_DOMAIN: test2.sthings-edge.com
EDGE_PLAY_LB_IP: 10.31.102.8
# lab only: the ESP mock ("0" while real boards play)
EDGE_ESP_MOCK_REPLICAS: "1"
EOF
2.2 clusters/$CLUSTER/cluster-apps.yaml
Option A only (step by step, phase 4 ):
if you write the Flux files by hand (2B ), skip this file.
The input of render-cluster-apps: what the cluster runs. Copy of the edge's,
with the cluster's path; values come from cluster-vars (${EDGE_*}) or, for
secrets, ref+sops:// into section 1. Drop the edge-lab layer for the box.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161 cat > clusters/$CLUSTER /cluster-apps.yaml <<'EOF'
---
# Input of blueprints/flux render-cluster-apps -- NOT a Kubernetes object
# (.sourceignore). See docs/edge/from-scratch.md and docs/edge/runbook.md.
kind: ClusterApps
metadata:
name: __CLUSTER__
spec:
source:
kind: OCIRepository
name: flux-repo
url: oci://ghcr.io/stuttgart-things/flux/repo
tag: v1.125.0
interval: 1h
path: ./clusters/__CLUSTER__
secrets:
mode: inline
wiring:
generate: true
extraResources: [cluster-vars.yaml]
layers:
edge-infra:
dir: infra
extraResources: [ca.yaml]
labels:
app.kubernetes.io/managed-by: flux
kustomization.stuttgart-things.com/type: infrastructure
timeout: 15m
substituteFrom:
- kind: ConfigMap
name: cluster-vars
optional: false
edge-apps:
dir: apps
labels:
app.kubernetes.io/managed-by: flux
kustomization.stuttgart-things.com/type: apps
dependsOn: [edge-infra]
wait: false
decryptionSecret: sops-age # pragma: allowlist secret
substituteFrom:
- kind: ConfigMap
name: cluster-vars
optional: false
# LAB ONLY -- remove for the box (and the lab/ folder)
edge-lab:
dir: lab
labels:
app.kubernetes.io/managed-by: flux
kustomization.stuttgart-things.com/type: infrastructure
dependsOn: [edge-infra]
timeout: 10m
substituteFrom:
- kind: ConfigMap
name: cluster-vars
optional: false
bundles:
infra-platform:
layer: edge-infra
timeout: 10m
apps-platform:
layer: edge-apps
vars:
INFRA_DOMAIN: ${EDGE_DOMAIN}
INFRA_GATEWAY_NAME: ${EDGE_GATEWAY_NAME}
INFRA_GATEWAY_NAMESPACE: default
INFRA_TLS_SECRET: wildcard-tls # pragma: allowlist secret
apps:
cilium-lb:
vars:
CILIUM_LB_IP_START: ${EDGE_LB_IP}
CILIUM_LB_IP_STOP: ${EDGE_LB_IP}
cilium-gateway: {}
cert-manager-install: {}
cert-manager-selfsigned:
vars:
CERT_MANAGER_SELFSIGNED_ISSUER: edge-ca
cert-manager-ca-from-secret:
vars:
CERT_MANAGER_CA_FROM_SECRET_ISSUER: edge-ca # pragma: allowlist secret
CERT_MANAGER_CA_FROM_SECRET_NAME: edge-ca # pragma: allowlist secret
trust-manager:
vars:
TRUST_BUNDLE_VAULT_CA_SECRET: edge-ca # pragma: allowlist secret
cert-manager-letsencrypt-hetzner:
secrets:
cert-manager-letsencrypt-hetzner-secrets:
HETZNER_DNS_TOKEN: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/HETZNER_DNS_TOKEN # pragma: allowlist secret
cilium-gateway-extra:
vars:
CILIUM_GATEWAY_EXTRA_NAME: edge-play-gateway
CILIUM_GATEWAY_EXTRA_NAMESPACE: default
CILIUM_GATEWAY_EXTRA_POOL_NAME: edge-play-pool
CILIUM_GATEWAY_EXTRA_IP: ${EDGE_PLAY_LB_IP}
CILIUM_GATEWAY_EXTRA_DOMAIN: ${EDGE_PLAY_DOMAIN}
CILIUM_GATEWAY_EXTRA_ISSUER: letsencrypt-hetzner
CILIUM_GATEWAY_EXTRA_ISSUER_KIND: ClusterIssuer
CILIUM_GATEWAY_EXTRA_TLS_SECRET: play-wildcard-tls # pragma: allowlist secret
CILIUM_GATEWAY_EXTRA_ISSUER_KUSTOMIZATION: cert-manager-letsencrypt-hetzner
cnpg-operator: {}
cnpg-barman-cloud: {}
reloader: {}
minio:
vars:
MINIO_STORAGE_CLASS: local-path
MINIO_STORAGE_SIZE: 5Gi
MINIO_VERSION: 16.0.10
secrets:
minio-secrets:
MINIO_ADMIN_USER: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/MINIO_ADMIN_USER
MINIO_ADMIN_PASSWORD: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/MINIO_ADMIN_PASSWORD # pragma: allowlist secret
homerun2-sops:
vars:
HOMERUN2_NAMESPACE: homerun2
HOMERUN2_REDIS_STORAGE_CLASS: local-path
HOMERUN2_REDIS_STORAGE_SIZE: 1Gi
HOMERUN2_LED_CATCHER_UI_STREAM_PRESETS: messages,tabletennis
secrets:
homerun2-sops-secrets:
HOMERUN2_REDIS_PASSWORD_B64: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/HOMERUN2_REDIS_PASSWORD_B64 # pragma: allowlist secret
TEAMS_WEBHOOK_URL: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/TEAMS_WEBHOOK_URL
HOMERUN2_OMNI_PITCHER_AUTH_TOKEN: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/HOMERUN2_OMNI_PITCHER_AUTH_TOKEN # pragma: allowlist secret
HOMERUN2_SCOUT_AUTH_TOKEN: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/HOMERUN2_SCOUT_AUTH_TOKEN # pragma: allowlist secret
homerun2-light-catcher-tabletennis-sops:
secrets:
homerun2-light-catcher-tabletennis-sops-secrets:
HOMERUN2_REDIS_PASSWORD_B64: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/HOMERUN2_REDIS_PASSWORD_B64 # pragma: allowlist secret
tabletennis-sops-backup:
vars:
TABLETENNIS_ZAEHLWERK_PANEL: homerun2
TABLETENNIS_SCHMETTERPAUSE_DB_STORAGE_SIZE: 1Gi
TABLETENNIS_SCHMETTERPAUSE_BACKUP_BUCKET: schmetterpause-cnpg
TABLETENNIS_SCHMETTERPAUSE_BACKUP_S3_ENDPOINT: http://minio-deployment.minio.svc.cluster.local:9000
TABLETENNIS_SCHMETTERPAUSE_BACKUP_SERVER_NAME: schmetterpause-db-__CLUSTER__
TABLETENNIS_SCHMETTERPAUSE_BACKUP_RETENTION: 7d
TABLETENNIS_SCOREBOARD_HANDOVER: sops
TABLETENNIS_SCHMETTERPAUSE_ADMIN: 'on'
TABLETENNIS_SCHMETTERPAUSE_BOOTSTRAP_ADMIN: timoboll
secrets:
tabletennis-sops-secrets:
SCHMETTERPAUSE_DB_PASSWORD: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/SCHMETTERPAUSE_DB_PASSWORD # pragma: allowlist secret
SCHMETTERPAUSE_SESSION_KEY: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/SCHMETTERPAUSE_SESSION_KEY # pragma: allowlist secret
ZAEHLWERK_OMNI_PITCHER_TOKEN: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/ZAEHLWERK_OMNI_PITCHER_TOKEN # pragma: allowlist secret
ZAEHLWERK_REDIS_PASSWORD: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/ZAEHLWERK_REDIS_PASSWORD # pragma: allowlist secret
SCHMETTERPAUSE_BACKUP_ACCESS_KEY_ID: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/SCHMETTERPAUSE_BACKUP_ACCESS_KEY_ID # pragma: allowlist secret
SCHMETTERPAUSE_BACKUP_SECRET_ACCESS_KEY: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/SCHMETTERPAUSE_BACKUP_SECRET_ACCESS_KEY # pragma: allowlist secret
SCHMETTERPAUSE_SCOREBOARD_TOKEN: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/SCHMETTERPAUSE_SCOREBOARD_TOKEN # pragma: allowlist secret
openbao-sops:
vars:
OPENBAO_TOPOLOGY: single-node
OPENBAO_STORAGE_CLASS: local-path
OPENBAO_STORAGE_SIZE: 2Gi
OPENBAO_SEAL_KEY_ID: edge-20261004
OPENBAO_INIT: self-init-userpass
secrets:
openbao-sops-secrets:
OPENBAO_SEAL_STATIC_KEY: ref+sops://secrets/edge/openbao-seal.enc.yaml#/stringData/key # pragma: allowlist secret
OPENBAO_TERRAFORM_PASSWORD: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/OPENBAO_TERRAFORM_PASSWORD # pragma: allowlist secret
OPENBAO_ADMIN_PASSWORD: ref+sops://secrets/edge/app-values.enc.yaml#/stringData/OPENBAO_ADMIN_PASSWORD # pragma: allowlist secret
EOF
sed -i "s|__CLUSTER__| $CLUSTER |g" clusters/$CLUSTER /cluster-apps.yaml
Pick the newest spec.source.tag (the flux releases). The backup server name
must be unique per cluster when several archive into one bucket.
2.3 clusters/$CLUSTER/infra/ca.yaml + infra/ca/kustomization.yaml
The Flux Kustomization that applies the intermediate (section 1.1). Copy
edge-ca.enc.yaml from another edge cluster, or create it there.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39 cat > clusters/$CLUSTER /infra/ca.yaml <<'EOF'
---
# The persistent edge CA's intermediate (Secret cert-manager/edge-ca, SOPS),
# read by the ClusterIssuer edge-ca (cert-manager-ca-from-secret).
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: edge-ca
namespace: flux-system
labels:
app.kubernetes.io/managed-by: flux
kustomization.stuttgart-things.com/type: infrastructure
spec:
dependsOn:
- name: cert-manager-install
interval: 1h
retryInterval: 30s
timeout: 5m
prune: true
wait: true
sourceRef:
kind: GitRepository
name: flux-system
path: ./clusters/__CLUSTER__/infra/ca
decryption:
provider: sops
secretRef:
name: sops-age # pragma: allowlist secret
EOF
sed -i "s|__CLUSTER__| $CLUSTER |g" clusters/$CLUSTER /infra/ca.yaml
cat > clusters/$CLUSTER /infra/ca/kustomization.yaml <<'EOF'
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- edge-ca.enc.yaml
EOF
cp clusters/edge/infra/ca/edge-ca.enc.yaml clusters/$CLUSTER /infra/ca/
cp clusters/edge/edge-root-ca.crt clusters/$CLUSTER /
2.4 clusters/$CLUSTER/.sourceignore
cat > clusters/$CLUSTER /.sourceignore <<'EOF'
# Flux never reads these: run by hand / by dagger, not by a controller.
k3s/
terraform/
# the input of render-cluster-apps, no Kubernetes object
cluster-apps.yaml
EOF
2.5 clusters/$CLUSTER/k3s/ -- inventory, vars, collections
The three files with their content, and both runs: k3s.md ,
The files .
2.6 clusters/$CLUSTER/lab/ -- lab VMs only
The ESP mock and the players' route, copied from the edge's lab/ (the
LabDA-only parts -- Vault issuer, CoreDNS forward to the LabDA resolver -- stay
out on other labs):
1
2
3
4
5
6
7
8
9
10
11
12
13 mkdir -p clusters/$CLUSTER /lab
cp -r clusters/edge/lab/{ esp-mock,esp-mock.yaml,play-routes,play-routes.yaml} clusters/$CLUSTER /lab/
cp clusters/edge/edge-root-ca.crt clusters/$CLUSTER /lab/esp-mock/
cat > clusters/$CLUSTER /lab/kustomization.yaml <<'EOF'
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- play-routes.yaml
# an emulated ESP32 (piezo mock + ACME device certificate from OpenBao)
- esp-mock.yaml
EOF
sed -i "s|./clusters/edge/lab/|./clusters/ $CLUSTER /lab/|" clusters/$CLUSTER /lab/*.yaml
Then
Then either render the Flux files (option A) or write them by hand
(2B , option B). Render (runbook step 5: render-cluster-apps with
--cluster-apps clusters/$CLUSTER/cluster-apps.yaml; the first render
without --existing-secrets), merge, bootstrap Flux with
--destination-path clusters/$CLUSTER (step 6). Generated:
kustomization.yaml, apps.yaml, infra/kustomization.yaml,
infra/infra-platform.yaml, apps/*, cluster-secrets/; committed by the
bootstrap: config.yaml, secrets.yaml.
2B. Flux files by hand
Instead of ClusterApps + render-cluster-apps (option B in step by
step ): the files Flux applies, written
directly. Tested against the generator: for clusters/edge, the objects are the
same and the secrets are equal after decryption. The only difference is that
the secrets here are encrypted for the master key only, with no
per-cluster key and no escrow.
Do not write a cluster-apps.yaml (2.2) with this option. From now on,
these files are what you maintain. Uses enc and getval from section 0 (sops CLI or Dagger),
jq and yq.
2B.1 clusters/$CLUSTER/apps.yaml -- the source and the layers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97 cat > clusters/$CLUSTER /apps.yaml <<'EOF'
---
# The catalog (OCI) and the three layers. Every layer substitutes ${EDGE_*}
# from the ConfigMap cluster-vars.
apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
name: flux-repo
namespace: flux-system
spec:
interval: 1h
ref:
tag: v1.125.0
url: oci://ghcr.io/stuttgart-things/flux/repo
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: edge-infra
namespace: flux-system
labels:
app.kubernetes.io/managed-by: flux
kustomization.stuttgart-things.com/type: infrastructure
spec:
interval: 1h
retryInterval: 1m
timeout: 15m
prune: true
wait: true
sourceRef:
kind: GitRepository
name: flux-system
path: ./clusters/__CLUSTER__/infra
postBuild:
substituteFrom:
- kind: ConfigMap
name: cluster-vars
optional: false
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: edge-apps
namespace: flux-system
labels:
app.kubernetes.io/managed-by: flux
kustomization.stuttgart-things.com/type: apps
spec:
dependsOn:
- name: edge-infra
interval: 1h
retryInterval: 1m
timeout: 5m
prune: true
wait: false
sourceRef:
kind: GitRepository
name: flux-system
path: ./clusters/__CLUSTER__/apps
decryption:
provider: sops
secretRef:
name: sops-age # pragma: allowlist secret
postBuild:
substituteFrom:
- kind: ConfigMap
name: cluster-vars
optional: false
---
# LAB ONLY -- leave out on the box (and the lab/ folder)
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: edge-lab
namespace: flux-system
labels:
app.kubernetes.io/managed-by: flux
kustomization.stuttgart-things.com/type: infrastructure
spec:
dependsOn:
- name: edge-infra
interval: 1h
retryInterval: 1m
timeout: 10m
prune: true
wait: true
sourceRef:
kind: GitRepository
name: flux-system
path: ./clusters/__CLUSTER__/lab
postBuild:
substituteFrom:
- kind: ConfigMap
name: cluster-vars
optional: false
EOF
sed -i "s|__CLUSTER__| $CLUSTER |g" clusters/$CLUSTER /apps.yaml
One Flux Kustomization from the catalog (./infra/platform/root) that pulls
in one component per app; postBuild.substitute holds every app's vars.
To add an app: add its component and its vars.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66 cat > clusters/$CLUSTER /infra/infra-platform.yaml <<'EOF'
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: infra-platform
namespace: flux-system
labels:
app.kubernetes.io/managed-by: flux
kustomization.stuttgart-things.com/type: bundle
spec:
interval: 1h
retryInterval: 1m
timeout: 10m
prune: true
wait: true
force: false
suspend: false
sourceRef:
kind: OCIRepository
name: flux-repo
path: ./infra/platform/root
components:
- ../components/cert-manager-ca-from-secret
- ../components/cert-manager-install
- ../components/cert-manager-letsencrypt-hetzner
- ../components/cert-manager-selfsigned
- ../components/cilium-gateway
- ../components/cilium-gateway-extra
- ../components/cilium-lb
- ../components/cnpg-barman-cloud
- ../components/cnpg-operator
- ../components/reloader
- ../components/trust-manager
# the components' own Kustomizations read the OCI source too
patches:
- target:
group: kustomize.toolkit.fluxcd.io
kind: Kustomization
patch: |
- op: replace
path: /spec/sourceRef/kind
value: OCIRepository
postBuild:
substitute:
FLUX_SOURCE: flux-repo
INFRA_DOMAIN: ${EDGE_DOMAIN}
INFRA_GATEWAY_NAME: ${EDGE_GATEWAY_NAME}
INFRA_GATEWAY_NAMESPACE: default
INFRA_TLS_SECRET: wildcard-tls # pragma: allowlist secret
CILIUM_LB_IP_START: ${EDGE_LB_IP}
CILIUM_LB_IP_STOP: ${EDGE_LB_IP}
CERT_MANAGER_SELFSIGNED_ISSUER: edge-ca
CERT_MANAGER_CA_FROM_SECRET_ISSUER: edge-ca # pragma: allowlist secret
CERT_MANAGER_CA_FROM_SECRET_NAME: edge-ca # pragma: allowlist secret
TRUST_BUNDLE_VAULT_CA_SECRET: edge-ca # pragma: allowlist secret
CILIUM_GATEWAY_EXTRA_NAME: edge-play-gateway
CILIUM_GATEWAY_EXTRA_NAMESPACE: default
CILIUM_GATEWAY_EXTRA_POOL_NAME: edge-play-pool
CILIUM_GATEWAY_EXTRA_IP: ${EDGE_PLAY_LB_IP}
CILIUM_GATEWAY_EXTRA_DOMAIN: ${EDGE_PLAY_DOMAIN}
CILIUM_GATEWAY_EXTRA_ISSUER: letsencrypt-hetzner
CILIUM_GATEWAY_EXTRA_ISSUER_KIND: ClusterIssuer
CILIUM_GATEWAY_EXTRA_TLS_SECRET: play-wildcard-tls # pragma: allowlist secret
CILIUM_GATEWAY_EXTRA_ISSUER_KUSTOMIZATION: cert-manager-letsencrypt-hetzner
EOF
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67 mkdir -p clusters/$CLUSTER /apps
cat > clusters/$CLUSTER /apps/apps-platform.yaml <<'EOF'
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: apps-platform
namespace: flux-system
labels:
app.kubernetes.io/managed-by: flux
kustomization.stuttgart-things.com/type: bundle
spec:
interval: 1h
retryInterval: 1m
timeout: 15m
prune: true
wait: true
force: false
suspend: false
sourceRef:
kind: OCIRepository
name: flux-repo
path: ./apps/platform/root
components:
- ../components/homerun2-light-catcher-tabletennis-sops
- ../components/homerun2-sops
- ../components/minio
- ../components/openbao-sops
- ../components/tabletennis-sops-backup
patches:
- target:
group: kustomize.toolkit.fluxcd.io
kind: Kustomization
patch: |
- op: replace
path: /spec/sourceRef/kind
value: OCIRepository
postBuild:
substitute:
APPS_SOURCE: flux-repo
INFRA_DOMAIN: ${EDGE_DOMAIN}
INFRA_GATEWAY_NAME: ${EDGE_GATEWAY_NAME}
INFRA_GATEWAY_NAMESPACE: default
INFRA_TLS_SECRET: wildcard-tls # pragma: allowlist secret
MINIO_STORAGE_CLASS: local-path
MINIO_STORAGE_SIZE: "5Gi"
MINIO_VERSION: "16.0.10"
HOMERUN2_NAMESPACE: homerun2
HOMERUN2_REDIS_STORAGE_CLASS: local-path
HOMERUN2_REDIS_STORAGE_SIZE: "1Gi"
HOMERUN2_LED_CATCHER_UI_STREAM_PRESETS: messages,tabletennis
TABLETENNIS_ZAEHLWERK_PANEL: homerun2
TABLETENNIS_SCHMETTERPAUSE_DB_STORAGE_SIZE: "1Gi"
TABLETENNIS_SCHMETTERPAUSE_BACKUP_BUCKET: schmetterpause-cnpg
TABLETENNIS_SCHMETTERPAUSE_BACKUP_S3_ENDPOINT: http://minio-deployment.minio.svc.cluster.local:9000
TABLETENNIS_SCHMETTERPAUSE_BACKUP_SERVER_NAME: schmetterpause-db-__CLUSTER__
TABLETENNIS_SCHMETTERPAUSE_BACKUP_RETENTION: "7d"
TABLETENNIS_SCOREBOARD_HANDOVER: sops
TABLETENNIS_SCHMETTERPAUSE_ADMIN: "on"
TABLETENNIS_SCHMETTERPAUSE_BOOTSTRAP_ADMIN: timoboll
OPENBAO_TOPOLOGY: single-node
OPENBAO_STORAGE_CLASS: local-path
OPENBAO_STORAGE_SIZE: "2Gi"
OPENBAO_SEAL_KEY_ID: edge-20261004
OPENBAO_INIT: self-init-userpass
EOF
sed -i "s|__CLUSTER__| $CLUSTER |g" clusters/$CLUSTER /apps/apps-platform.yaml
Quote every value YAML could read as something else ("on", sizes): the
substitution needs strings.
2B.4 clusters/$CLUSTER/cluster-secrets/ -- the apps' secrets
One Secret <app>-secrets in flux-system per app that has secrets; the
app's Flux Kustomization reads it with substituteFrom. The values come out of
secrets/edge/ with getval, the result is encrypted for the
master key (data/stringData only -- Flux applies it). The plaintext never
touches the disk outside a umask 077 temp file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38 # mk_secret <name> KEY... -- KEY from secrets/edge/app-values.enc.yaml
# KEY=<file>#<key> -- KEY from another file's stringData
mk_secret() (
umask 077 ; name = $1 ; shift; T = $( mktemp)
printf 'apiVersion: v1\nkind: Secret\nmetadata:\n name: %s\n namespace: flux-system\ntype: Opaque\nstringData:\n' " $name " > " $T "
for spec; do
k = ${ spec %%=* } ; src = secrets/edge/app-values.enc.yaml; field = $k
[ " $spec " != " $k " ] && { src = ${ spec #*= } ; field = ${ src #*# } ; src = ${ src %#* } ; }
printf ' %s: %s\n' " $k " " $( printf %s " $( getval " $src " " $field " ) " | jq -Rs .) " >> " $T "
done
enc " $T " clusters/$CLUSTER /cluster-secrets/secrets/flux-system/$name .enc.yaml
)
mkdir -p clusters/$CLUSTER /cluster-secrets/secrets/flux-system
mk_secret cert-manager-letsencrypt-hetzner-secrets HETZNER_DNS_TOKEN
mk_secret minio-secrets MINIO_ADMIN_USER MINIO_ADMIN_PASSWORD
mk_secret homerun2-sops-secrets HOMERUN2_REDIS_PASSWORD_B64 TEAMS_WEBHOOK_URL \
HOMERUN2_OMNI_PITCHER_AUTH_TOKEN HOMERUN2_SCOUT_AUTH_TOKEN
mk_secret homerun2-light-catcher-tabletennis-sops-secrets HOMERUN2_REDIS_PASSWORD_B64
mk_secret tabletennis-sops-secrets SCHMETTERPAUSE_DB_PASSWORD SCHMETTERPAUSE_SESSION_KEY \
ZAEHLWERK_OMNI_PITCHER_TOKEN ZAEHLWERK_REDIS_PASSWORD \
SCHMETTERPAUSE_BACKUP_ACCESS_KEY_ID SCHMETTERPAUSE_BACKUP_SECRET_ACCESS_KEY \
SCHMETTERPAUSE_SCOREBOARD_TOKEN
mk_secret openbao-sops-secrets OPENBAO_SEAL_STATIC_KEY = secrets/edge/openbao-seal.enc.yaml#key \
OPENBAO_TERRAFORM_PASSWORD OPENBAO_ADMIN_PASSWORD
cat > clusters/$CLUSTER /cluster-secrets/secrets/kustomization.yaml <<'EOF'
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- flux-system/cert-manager-letsencrypt-hetzner-secrets.enc.yaml
- flux-system/homerun2-light-catcher-tabletennis-sops-secrets.enc.yaml
- flux-system/homerun2-sops-secrets.enc.yaml
- flux-system/minio-secrets.enc.yaml
- flux-system/openbao-sops-secrets.enc.yaml
- flux-system/tabletennis-sops-secrets.enc.yaml
EOF
A changed value in secrets/edge/: run its mk_secret line again.
2B.5 The wiring -- three kustomization.yaml
What flux-system applies (config.yaml and secrets.yaml come with the
Flux bootstrap), and what each layer applies:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 cat > clusters/$CLUSTER /kustomization.yaml <<'EOF'
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- config.yaml
- secrets.yaml
- cluster-vars.yaml
- apps.yaml
- cluster-secrets/secrets
EOF
cat > clusters/$CLUSTER /infra/kustomization.yaml <<'EOF'
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- infra-platform.yaml
- ca.yaml
EOF
cat > clusters/$CLUSTER /apps/kustomization.yaml <<'EOF'
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- apps-platform.yaml
EOF
Check
for d in infra apps cluster-secrets/secrets; do
kubectl kustomize clusters/$CLUSTER /$d >/dev/null && echo "ok $d " ; done
grep -L 'ENC\[AES256_GCM' clusters/$CLUSTER /cluster-secrets/secrets/flux-system/*.enc.yaml # empty
The Terraform roots are shared (clusters/edge/terraform/{openbao,minio});
each environment has its own address file, passed with --extra-files
(runbook step 8). No defaults: without the file a run fails.
1
2
3
4
5
6
7
8
9
10
11
12
13 ENV = lab-test2
cat > clusters/edge/terraform/openbao/env/$ENV .auto.tfvars.json <<'EOF'
{
"openbao_addr": "https://openbao.edge-tt-test2.sthings-vsphere.labul.sva.de",
"acme_allowed_domains": ["edge-tt-test2.sthings-vsphere.labul.sva.de"],
"acme_dns_resolver": ""
}
EOF
cat > clusters/edge/terraform/minio/env/$ENV .auto.tfvars.json <<'EOF'
{
"minio_server": "minio.edge-tt-test2.sthings-vsphere.labul.sva.de:443"
}
EOF
acme_dns_resolver: empty = the cluster DNS (lab); the router on the box
("192.168.8.1:53").