Compare commits
57 commits
chore/DEV-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c97a2d218e | ||
|
|
e69e3d8eb4 | ||
|
|
96c3846202 | ||
|
|
76e250c334 | ||
|
|
0887f87c82 | ||
|
|
a7e81db4e1 | ||
|
|
777a4d0404 | ||
|
|
3317401eba | ||
|
|
71496fc5ac | ||
|
|
6313b85999 | ||
|
|
7cbb603ec1 | ||
|
|
a226958b16 | ||
|
|
e6a94ae26a | ||
| e9fcf4cbb4 | |||
|
|
e155ab6a17 | ||
| 862dbbf203 | |||
|
|
8a80068b95 | ||
| c9a60a56c3 | |||
|
|
f5a0982222 | ||
| 2cf6c65d23 | |||
|
|
d1641c9047 | ||
|
|
e8ab9d4674 | ||
|
|
447ff93ccf | ||
| 82de4507a8 | |||
|
|
43610fb16d | ||
|
|
e848d225cd | ||
|
|
34350a03bf | ||
|
|
89cf3094d0 | ||
|
|
585d6ee114 | ||
|
|
19af6918f0 | ||
|
|
8dffba2c52 | ||
|
|
129464d4c7 | ||
|
|
05000b3e06 | ||
|
|
91983e8c8e | ||
|
|
810d22f0a6 | ||
|
|
ef62dde67c | ||
|
|
b1680431c6 | ||
|
|
550d1276f7 | ||
|
|
0c1c05fc50 | ||
|
|
49b724439b | ||
|
|
e434fa751b | ||
|
|
d495a5936f | ||
|
|
e2fd90a22b | ||
|
|
95de27437b | ||
|
|
890809c91c | ||
|
|
e4a7e5629a | ||
|
|
8cd2829cd8 | ||
|
|
c4551495e0 | ||
| 471b6a6109 | |||
|
|
9fe442ce0d | ||
|
|
a5ceedf4da | ||
|
|
63aa116ef1 | ||
|
|
9d1996051e | ||
|
|
4ed49fc6b4 | ||
|
|
3b4146de65 | ||
|
|
73430bf378 | ||
| ee56681f86 |
56 changed files with 4894 additions and 33761 deletions
|
|
@ -3,16 +3,27 @@ kind: Application
|
|||
metadata:
|
||||
name: argocd
|
||||
namespace: argocd
|
||||
finalizers:
|
||||
- resources-finalizer.argocd.argoproj.io
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: git@forgejo.forgejo.svc.cluster.local:basicstack/stack.basicstack.de.git
|
||||
targetRevision: main
|
||||
path: apps/argocd
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: argocd
|
||||
sources:
|
||||
- repoURL: https://argoproj.github.io/argo-helm
|
||||
chart: argo-cd
|
||||
targetRevision: 10.4.0
|
||||
helm:
|
||||
releaseName: argocd
|
||||
valueFiles:
|
||||
- $values/apps/argocd/values.yaml
|
||||
- repoURL: git@forgejo.forgejo.svc.cluster.local:basicstack/stack.basicstack.de.git
|
||||
targetRevision: main
|
||||
path: apps/argocd
|
||||
ref: values
|
||||
syncPolicy:
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
- ServerSideApply=true
|
||||
- ApplyOutOfSyncOnly=true
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: platform-prod
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: git@forgejo.forgejo.svc.cluster.local:basicstack/stack.basicstack.de.git
|
||||
targetRevision: main
|
||||
path: apps/platform-prod
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: platform-prod
|
||||
syncPolicy:
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
|
|
@ -2,19 +2,28 @@
|
|||
|
||||
This directory contains the Argo CD deployment configuration for the basicstack.de k3s cluster.
|
||||
|
||||
Argo CD itself is installed from the community Helm chart (`argoproj/argo-helm`, chart `argo-cd`). This directory holds the chart values file plus a small kustomize wrapper for the ingress and sealed secrets that stay in git.
|
||||
|
||||
## Files
|
||||
|
||||
- `argocd-install.yaml` - Auto-generated Argo CD installation manifest (DO NOT EDIT DIRECTLY)
|
||||
- `kustomization.yaml` - Kustomize overlay that adds resource limits and other customizations
|
||||
- `argocd-ingress.yaml` - Ingress configuration for Argo CD UI
|
||||
- `argocd-oidc-secret-sealed.yaml` - Sealed secret for OIDC integration
|
||||
- `repo-*.yaml` - Sealed secrets for Git repository access
|
||||
- `values.yaml` - Helm values for the `argo-cd` chart (image tag, OIDC/RBAC config, resource limits, ingress disabled).
|
||||
- `kustomization.yaml` - Kustomize wrapper for the ingress + sealed secrets (does NOT install Argo CD itself).
|
||||
- `argocd-ingress.yaml` - Ingress configuration for the Argo CD UI (Traefik + cert-manager `letsencrypt-prod`).
|
||||
- `argocd-oidc-secret-sealed.yaml` - Sealed secret for Pocket ID OIDC integration.
|
||||
- `repo-*.yaml` - Sealed secrets for Git repository access.
|
||||
|
||||
## Resource Limits
|
||||
## How the install is wired
|
||||
|
||||
**IMPORTANT**: Resource limits were added after DEV-281 (resource exhaustion incident on 2026-07-12).
|
||||
The root [`../app-argocd.yaml`](../app-argocd.yaml) is an Argo CD `Application` with two sources:
|
||||
|
||||
All Argo CD components now have memory limits to prevent OOM incidents:
|
||||
1. The public Helm chart at `https://argoproj.github.io/argo-helm`, chart `argo-cd`, `targetRevision` pinned in git.
|
||||
2. This repo (`ref: values`) providing the `values.yaml` used by source (1) AND the ingress/sealed secrets applied via `kustomize`.
|
||||
|
||||
Once bootstrapped, Argo CD manages its own install by syncing this Application.
|
||||
|
||||
## Resource limits
|
||||
|
||||
Memory limits were added after DEV-281 (resource exhaustion incident on 2026-07-12) and are now driven by `values.yaml`:
|
||||
|
||||
| Component | Memory Limit | Memory Request |
|
||||
|-----------|--------------|----------------|
|
||||
|
|
@ -25,46 +34,51 @@ All Argo CD components now have memory limits to prevent OOM incidents:
|
|||
| notifications-controller | 128Mi | 64Mi |
|
||||
| applicationset-controller | 256Mi | 128Mi |
|
||||
|
||||
These limits are based on observed usage patterns and provide headroom while preventing unlimited memory consumption.
|
||||
|
||||
## Deployment
|
||||
|
||||
### Option 1: Apply with kustomize (RECOMMENDED)
|
||||
### Steady state (managed by Argo CD)
|
||||
|
||||
Once the cluster is bootstrapped, changes to this directory are picked up by the root `argocd` Application on the next sync. No manual `kubectl apply` is required.
|
||||
|
||||
### First-time / disaster-recovery bootstrap
|
||||
|
||||
Argo CD cannot install itself while it is gone. Bootstrap with helm, then hand ownership back:
|
||||
|
||||
```bash
|
||||
kubectl apply -k apps/argocd/
|
||||
helm repo add argo https://argoproj.github.io/argo-helm
|
||||
helm repo update
|
||||
helm install argocd argo/argo-cd \
|
||||
--version 10.4.0 \
|
||||
--namespace argocd --create-namespace \
|
||||
--values apps/argocd/values.yaml \
|
||||
--wait --timeout 10m
|
||||
|
||||
kubectl apply -k apps/argocd/ # ingress + sealed secrets
|
||||
kubectl apply -f apps/app-argocd.yaml # hand ownership back to GitOps
|
||||
```
|
||||
|
||||
This will apply the base manifests plus all patches defined in `kustomization.yaml`.
|
||||
|
||||
### Option 2: Direct apply (not recommended)
|
||||
|
||||
```bash
|
||||
kubectl apply -f apps/argocd/argocd-install.yaml
|
||||
kubectl apply -f apps/argocd/argocd-ingress.yaml
|
||||
# etc.
|
||||
```
|
||||
|
||||
**Note**: This skips the resource limit patches and is NOT recommended.
|
||||
|
||||
## Updating Argo CD
|
||||
|
||||
When updating to a new Argo CD version:
|
||||
Bump the chart and the image tag in a single PR:
|
||||
|
||||
1. Download the new install manifest:
|
||||
1. Refresh the local helm repo cache and check what's available:
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml > argocd-install.yaml
|
||||
helm repo update
|
||||
helm search repo argo/argo-cd --versions | head
|
||||
```
|
||||
|
||||
2. Apply with kustomize (resource limits will be automatically applied):
|
||||
2. Bump both fields together, keeping them in sync with the chart's `appVersion`:
|
||||
- `apps/app-argocd.yaml` -> `spec.sources[0].targetRevision` (chart version, e.g. `10.4.0`)
|
||||
- `apps/argocd/values.yaml` -> `global.image.tag` (app version, e.g. `v3.5.1`)
|
||||
|
||||
3. Optionally render locally to sanity-check the output before opening the PR:
|
||||
```bash
|
||||
kubectl apply -k apps/argocd/
|
||||
helm template argocd argo/argo-cd \
|
||||
--version <new-chart-version> \
|
||||
-f apps/argocd/values.yaml -n argocd | less
|
||||
```
|
||||
|
||||
3. Verify resource limits are in place:
|
||||
```bash
|
||||
kubectl get statefulset,deployment -n argocd -o custom-columns='NAME:.metadata.name,MEMORY_LIMIT:.spec.template.spec.containers[0].resources.limits.memory'
|
||||
```
|
||||
4. Open the PR. After merge, Argo CD syncs itself onto the new version.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
|
@ -74,22 +88,21 @@ When updating to a new Argo CD version:
|
|||
kubectl top pods -n argocd
|
||||
```
|
||||
|
||||
### Check if resource limits are applied
|
||||
### Check applied resource limits
|
||||
|
||||
```bash
|
||||
kubectl get deployment,statefulset -n argocd -o json | jq '.items[] | {name: .metadata.name, limits: .spec.template.spec.containers[0].resources.limits}'
|
||||
kubectl get deployment,statefulset -n argocd -o custom-columns='NAME:.metadata.name,MEMORY_LIMIT:.spec.template.spec.containers[0].resources.limits.memory'
|
||||
```
|
||||
|
||||
### Rollback if needed
|
||||
|
||||
If there are issues after applying resource limits:
|
||||
### Rollback if a chart upgrade misbehaves
|
||||
|
||||
```bash
|
||||
# Remove limits from a specific component
|
||||
kubectl patch deployment -n argocd argocd-server --type='json' -p='[{"op": "remove", "path": "/spec/template/spec/containers/0/resources"}]'
|
||||
helm -n argocd history argocd
|
||||
helm -n argocd rollback argocd <previous-revision>
|
||||
```
|
||||
|
||||
## History
|
||||
|
||||
- **2026-07-12**: Added resource limits via kustomization to prevent OOM incidents (DEV-281)
|
||||
- **2026-07-11**: Initial deployment
|
||||
- **2026-08-23**: Switched to the community Helm chart, bumped to `v3.5.1` / chart `10.4.0` (DEV-519).
|
||||
- **2026-07-12**: Added resource limits via kustomization to prevent OOM incidents (DEV-281).
|
||||
- **2026-07-11**: Initial deployment (vendored `install.yaml`).
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -16,4 +16,6 @@ spec:
|
|||
creationTimestamp: null
|
||||
name: argocd-oidc-secret
|
||||
namespace: argocd
|
||||
labels:
|
||||
app.kubernetes.io/part-of: argocd
|
||||
type: Opaque
|
||||
|
|
|
|||
|
|
@ -4,82 +4,7 @@ kind: Kustomization
|
|||
namespace: argocd
|
||||
|
||||
resources:
|
||||
- argocd-install.yaml
|
||||
- argocd-ingress.yaml
|
||||
- argocd-oidc-secret-sealed.yaml
|
||||
- repo-basicstack-org-secret-sealed.yaml
|
||||
- repo-stack-basicstack-de-secret-sealed.yaml
|
||||
|
||||
patches:
|
||||
# Add memory limits to prevent OOM incidents (DEV-281)
|
||||
- target:
|
||||
kind: StatefulSet
|
||||
name: argocd-application-controller
|
||||
patch: |-
|
||||
- op: add
|
||||
path: /spec/template/spec/containers/0/resources
|
||||
value:
|
||||
limits:
|
||||
memory: 512Mi
|
||||
requests:
|
||||
memory: 256Mi
|
||||
|
||||
- target:
|
||||
kind: Deployment
|
||||
name: argocd-repo-server
|
||||
patch: |-
|
||||
- op: add
|
||||
path: /spec/template/spec/containers/0/resources
|
||||
value:
|
||||
limits:
|
||||
memory: 512Mi
|
||||
requests:
|
||||
memory: 256Mi
|
||||
|
||||
- target:
|
||||
kind: Deployment
|
||||
name: argocd-redis
|
||||
patch: |-
|
||||
- op: add
|
||||
path: /spec/template/spec/containers/0/resources
|
||||
value:
|
||||
limits:
|
||||
memory: 256Mi
|
||||
requests:
|
||||
memory: 128Mi
|
||||
|
||||
- target:
|
||||
kind: Deployment
|
||||
name: argocd-server
|
||||
patch: |-
|
||||
- op: add
|
||||
path: /spec/template/spec/containers/0/resources
|
||||
value:
|
||||
limits:
|
||||
memory: 256Mi
|
||||
requests:
|
||||
memory: 128Mi
|
||||
|
||||
- target:
|
||||
kind: Deployment
|
||||
name: argocd-notifications-controller
|
||||
patch: |-
|
||||
- op: add
|
||||
path: /spec/template/spec/containers/0/resources
|
||||
value:
|
||||
limits:
|
||||
memory: 128Mi
|
||||
requests:
|
||||
memory: 64Mi
|
||||
|
||||
- target:
|
||||
kind: Deployment
|
||||
name: argocd-applicationset-controller
|
||||
patch: |-
|
||||
- op: add
|
||||
path: /spec/template/spec/containers/0/resources
|
||||
value:
|
||||
limits:
|
||||
memory: 256Mi
|
||||
requests:
|
||||
memory: 128Mi
|
||||
|
|
|
|||
87
apps/argocd/values.yaml
Normal file
87
apps/argocd/values.yaml
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
global:
|
||||
image:
|
||||
tag: v3.5.1
|
||||
|
||||
configs:
|
||||
cm:
|
||||
url: https://argo.basicstack.de
|
||||
application.instanceLabelKey: argocd.argoproj.io/instance
|
||||
resource.exclusions: |
|
||||
- apiGroups: [cilium.io]
|
||||
kinds: [CiliumIdentity, CiliumEndpoint, CiliumEndpointSlice]
|
||||
- apiGroups: [kyverno.io, reports.kyverno.io, wgpolicyk8s.io]
|
||||
kinds: [PolicyReport, ClusterPolicyReport, EphemeralReport,
|
||||
ClusterEphemeralReport, AdmissionReport, ClusterAdmissionReport,
|
||||
BackgroundScanReport, ClusterBackgroundScanReport, UpdateRequest]
|
||||
oidc.config: |
|
||||
name: Pocket ID
|
||||
issuer: https://auth.basicstack.de
|
||||
clientID: $argocd-oidc-secret:oidc.pocketid.clientId
|
||||
clientSecret: $argocd-oidc-secret:oidc.pocketid.clientSecret
|
||||
requestedScopes: [openid, profile, email, groups]
|
||||
requestedIDTokenClaims:
|
||||
groups: {essential: true}
|
||||
|
||||
rbac:
|
||||
policy.default: role:readonly
|
||||
policy.csv: |
|
||||
g, argo_admins, role:admin
|
||||
p, role:admin, applications, *, */*, allow
|
||||
p, role:admin, clusters, *, *, allow
|
||||
p, role:admin, repositories, *, *, allow
|
||||
p, role:admin, projects, *, *, allow
|
||||
p, role:admin, accounts, *, *, allow
|
||||
p, role:admin, gpgkeys, *, *, allow
|
||||
p, role:admin, certificates, *, *, allow
|
||||
p, role:admin, exec, *, *, allow
|
||||
|
||||
params:
|
||||
server.insecure: "true"
|
||||
|
||||
ssh:
|
||||
extraHosts: |
|
||||
forgejo.forgejo.svc.cluster.local ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCnFlDTGVmri7geuOfZ1UvHzCRi1U5BqCq+9MwkrbGkZCmBmUt3ek95JBZzurLvR/0rE624B9tB1AvSJIEIQ1W1YXD4ydiDaHX6J0sip6lEfqhREx0y91C15zHgi9jN0UKidse1g0xlLHpwePUmz8/2BJcLHJuwbZSdUu7+uhDIYtIJ5+3trX3IABNeluRA5TkspPAC0ViEaz4saWgWQWjKL8dsb3jIR94DiWAVpQnhaCBEILkIStJrmWl0O5B10Jr3KWy18szr9UVd8edCkoEXriCR8gx69jHdmuem5WlZPGvsK5Adf0mXE8S3rdyHqWOkDRs6Wlwd9p/1nDY8c8wtE3vqhefCpt1BwpTys9PMmgUfG0at/W+NdvalRqgQz26Bso8Tf7hcfyA/B69U2pvjA0tbgdIGJ5kLeCzpz9kpCYn8wSIuNIJ86BpyfnjRYFCYJVc6Ls86i8j3fEZAjEX7bmbeDBHQnyyH+rjq+Llo8aUd2Uf7HoSA93EjeuWq/Ta+YbWEWrp9Mrd48jnypxHXsiwDzqkm+YGQbXHfasiarxji/eQ5UMMG8hCxpLp1lJLhGN2th4eCLkpwchFr5jZGWgyZCth1WzQGj7NHvDTxKRU6n4MfsEX1B6GF0D60qtM5vZynpmO902mkn6wtxo+pCkDMroj668a62zw3rSS5Bw==
|
||||
|
||||
controller:
|
||||
resources:
|
||||
requests:
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
|
||||
repoServer:
|
||||
resources:
|
||||
requests:
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
|
||||
server:
|
||||
resources:
|
||||
requests:
|
||||
memory: 128Mi
|
||||
limits:
|
||||
memory: 256Mi
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
redis:
|
||||
resources:
|
||||
requests:
|
||||
memory: 128Mi
|
||||
limits:
|
||||
memory: 256Mi
|
||||
|
||||
notifications:
|
||||
resources:
|
||||
requests:
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 128Mi
|
||||
|
||||
applicationSet:
|
||||
resources:
|
||||
requests:
|
||||
memory: 128Mi
|
||||
limits:
|
||||
memory: 256Mi
|
||||
|
|
@ -100,6 +100,8 @@ metadata:
|
|||
namespace: bookstack
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: bookstack
|
||||
|
|
@ -200,7 +202,7 @@ spec:
|
|||
cpu: "1000m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
path: /status
|
||||
port: 80
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 10
|
||||
|
|
@ -208,7 +210,7 @@ spec:
|
|||
failureThreshold: 6
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
path: /status
|
||||
port: 80
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 5
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ spec:
|
|||
fsGroup: 1000
|
||||
containers:
|
||||
- name: directus
|
||||
image: directus/directus:12.1.1
|
||||
image: directus/directus:12.3.0
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8055
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ spec:
|
|||
serviceAccountName: dozzle
|
||||
containers:
|
||||
- name: dozzle
|
||||
image: amir20/dozzle:v10.6.10
|
||||
image: amir20/dozzle:v10.7.3
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: dozzle-http
|
||||
|
|
|
|||
|
|
@ -50,17 +50,40 @@ kubectl apply -f apps/app-forgejo-runner.yaml
|
|||
|
||||
## Runner Configuration
|
||||
|
||||
The runner is deployed as a StatefulSet with Docker-in-Docker (dind) sidecar for proper isolation and state management.
|
||||
The runner is deployed as a Deployment (single replica) that bind-mounts the
|
||||
host's `/var/run/docker.sock` to execute job containers. This requires the
|
||||
Docker Engine (package `docker.io`) to be installed and running on the target
|
||||
node — see **Node prerequisites** below.
|
||||
|
||||
Configuration:
|
||||
- **Deployment type**: StatefulSet (stable pod identity, persistent storage)
|
||||
- **Docker execution**: Docker-in-Docker sidecar (privileged init container)
|
||||
- **Deployment type**: Deployment (replicas=1)
|
||||
- **Docker execution**: Host Docker socket (`/var/run/docker.sock`)
|
||||
- **Concurrent jobs**: 2 (configurable via config.yaml)
|
||||
- **Labels**: ubuntu-latest:docker://node:24-bookworm, ubuntu-22.04:docker://node:24-bookworm
|
||||
- **Forgejo URL**: https://forgejo.basicstack.de (external URL for proper webhook/API access)
|
||||
- **Persistent volumes**:
|
||||
- runner-data (1Gi): Runner registration and config
|
||||
- docker-data (20Gi): Docker image cache
|
||||
- **Node selector**: `basicstack.de/docker=true` — schedules only on nodes with the
|
||||
Docker Engine installed. See **Node prerequisites** below.
|
||||
|
||||
## Node prerequisites (required)
|
||||
|
||||
The runner uses the host's Docker daemon. Every worker node that should be
|
||||
eligible to run the runner MUST have `docker.io` installed, the `docker`
|
||||
systemd unit enabled, and be labeled `basicstack.de/docker=true`. Bootstrap a
|
||||
worker with:
|
||||
|
||||
```bash
|
||||
ssh root@<node>
|
||||
DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io
|
||||
systemctl enable --now docker
|
||||
# from the control plane:
|
||||
kubectl label node <node-name> basicstack.de/docker=true --overwrite
|
||||
```
|
||||
|
||||
Rolling OS updates that reimage a node will remove Docker. Re-run the bootstrap
|
||||
after any node reimage. Without Docker, the runner pod fails to mount
|
||||
`/var/run/docker.sock` (hostPath type check for `Socket` fails); the
|
||||
nodeSelector prevents that scheduling mistake by pinning to labeled nodes.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ spec:
|
|||
app: forgejo-runner
|
||||
spec:
|
||||
serviceAccountName: forgejo-runner
|
||||
nodeSelector:
|
||||
basicstack.de/docker: "true"
|
||||
containers:
|
||||
- name: runner
|
||||
image: code.forgejo.org/forgejo/runner:4.0.1
|
||||
|
|
|
|||
|
|
@ -1,3 +1,18 @@
|
|||
---
|
||||
# Forgejo pg_dump → restic → Hetzner Object Storage (DEV-514).
|
||||
#
|
||||
# Replaces the legacy volume-based backup that wrote pg_dump files to
|
||||
# an RWO PVC (`platform-backup-data`). The hcloud volume backing that
|
||||
# PVC was deleted externally on 2026-08-17; rather than re-provision
|
||||
# the same legacy pattern, this migrates to the restic→S3 pipeline
|
||||
# already used for loki/grafana/prometheus (DEV-485…DEV-492).
|
||||
#
|
||||
# initContainer runs pg_dump -F c into an emptyDir; main container
|
||||
# runs `restic backup --stdin-from-command` isn't used here because
|
||||
# the dump has to complete before restic starts (need a proper exit
|
||||
# code, and the custom-format dump is not resumable). Instead we
|
||||
# stage the dump on emptyDir and let restic dedup it into
|
||||
# `s3:${S3_ENDPOINT}/${S3_BUCKET}/restic/forgejo`.
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
|
|
@ -5,56 +20,54 @@ metadata:
|
|||
namespace: forgejo
|
||||
labels:
|
||||
app: forgejo-backup
|
||||
backend: restic
|
||||
spec:
|
||||
schedule: "0 3 * * *"
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 7
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
jobTemplate:
|
||||
metadata:
|
||||
labels:
|
||||
app: forgejo-backup
|
||||
backend: restic
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
activeDeadlineSeconds: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: forgejo-backup
|
||||
backend: restic
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: forgejo-backup
|
||||
initContainers:
|
||||
- name: pgdump
|
||||
image: postgres:16-alpine
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /bin/sh
|
||||
- -lc
|
||||
- -c
|
||||
- |
|
||||
set -euo pipefail
|
||||
ts=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
BACKUP_DIR="/data/forgejo-${ts}"
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
|
||||
echo "[${ts}] Starting Forgejo backup..."
|
||||
|
||||
# Backup PostgreSQL database
|
||||
echo "[$(date -u +%H:%M:%S)] Dumping PostgreSQL database..."
|
||||
set -eu
|
||||
echo "=== forgejo pg_dump started at $(date -u +%FT%TZ) ==="
|
||||
mkdir -p /dump
|
||||
PGPASSWORD="${POSTGRES_PASSWORD}" pg_dump \
|
||||
-h forgejo-postgres.forgejo.svc.cluster.local \
|
||||
-U "${POSTGRES_USER}" \
|
||||
-d "${POSTGRES_DB}" \
|
||||
-F c \
|
||||
-f "${BACKUP_DIR}/forgejo-db.dump"
|
||||
|
||||
echo "[$(date -u +%H:%M:%S)] Database backup complete: $(ls -lh ${BACKUP_DIR}/forgejo-db.dump | awk '{print $5}')"
|
||||
|
||||
# Create backup manifest
|
||||
echo "backup_timestamp=${ts}" > "${BACKUP_DIR}/manifest.txt"
|
||||
echo "type=postgresql_custom_dump" >> "${BACKUP_DIR}/manifest.txt"
|
||||
echo "database=forgejo" >> "${BACKUP_DIR}/manifest.txt"
|
||||
echo "restore_cmd=PGPASSWORD=<pass> pg_restore -h <host> -U forgejo -d forgejo -F c forgejo-db.dump" >> "${BACKUP_DIR}/manifest.txt"
|
||||
|
||||
# Cleanup old backups (keep 14 days)
|
||||
find /data -maxdepth 1 -type d -name "forgejo-*" -mtime +14 -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
echo "[$(date -u +%H:%M:%S)] Backup complete. Files:"
|
||||
ls -lh "${BACKUP_DIR}/"
|
||||
|
||||
echo "[$(date -u +%H:%M:%S)] All backups in storage:"
|
||||
ls -1 /data | grep "forgejo-"
|
||||
-f /dump/forgejo-db.dump
|
||||
echo "pg_dump size: $(wc -c < /dump/forgejo-db.dump) bytes"
|
||||
# Manifest lets a restic-restore consumer identify the
|
||||
# dump without touching the binary.
|
||||
{
|
||||
echo "backup_timestamp=$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
echo "type=postgresql_custom_dump"
|
||||
echo "database=${POSTGRES_DB}"
|
||||
echo "restore_cmd=PGPASSWORD=<pass> pg_restore -h <host> -U ${POSTGRES_USER} -d ${POSTGRES_DB} -F c forgejo-db.dump"
|
||||
} > /dump/manifest.txt
|
||||
echo "=== forgejo pg_dump finished at $(date -u +%FT%TZ) ==="
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
valueFrom:
|
||||
|
|
@ -72,9 +85,120 @@ spec:
|
|||
name: forgejo-postgres-secret
|
||||
key: POSTGRES_DB
|
||||
volumeMounts:
|
||||
- name: backup-data
|
||||
mountPath: /data
|
||||
- name: dump
|
||||
mountPath: /dump
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 512Mi
|
||||
containers:
|
||||
- name: restic
|
||||
image: harbor.basicstack.de/library/restic:0.19.1
|
||||
env:
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: forgejo-s3-backup
|
||||
key: access-key
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: forgejo-s3-backup
|
||||
key: secret-key
|
||||
- name: RESTIC_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: forgejo-s3-backup
|
||||
key: restic-password
|
||||
- name: S3_ENDPOINT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: forgejo-s3-backup
|
||||
key: endpoint
|
||||
- name: S3_BUCKET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: forgejo-s3-backup
|
||||
key: bucket
|
||||
- name: RESTIC_REPOSITORY
|
||||
value: "s3:$(S3_ENDPOINT)/$(S3_BUCKET)/restic/forgejo"
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
echo "=== backup-forgejo-restic started at $(date -u +%FT%TZ) ==="
|
||||
echo "Repository: ${RESTIC_REPOSITORY}"
|
||||
|
||||
if restic snapshots >/dev/null 2>&1; then
|
||||
echo "Repo exists, skipping init."
|
||||
else
|
||||
echo "Repo missing, initialising..."
|
||||
restic init
|
||||
fi
|
||||
|
||||
echo "--- restic backup /source ---"
|
||||
restic backup /source \
|
||||
--tag forgejo \
|
||||
--host k3s
|
||||
|
||||
echo "--- restic forget/prune ---"
|
||||
restic forget --tag forgejo \
|
||||
--keep-daily 7 \
|
||||
--keep-weekly 4 \
|
||||
--keep-monthly 6 \
|
||||
--prune
|
||||
|
||||
echo "--- restic check --read-data-subset=5% ---"
|
||||
CHECK_STATUS=0
|
||||
restic check --read-data-subset=5% || CHECK_STATUS=$?
|
||||
echo "restic check exit: ${CHECK_STATUS}"
|
||||
|
||||
echo "--- restic stats (repo size) ---"
|
||||
REPO_SIZE_BYTES=$(restic stats --json --mode raw-data 2>/dev/null \
|
||||
| grep -oE '"total_size":[0-9]+' \
|
||||
| head -1 \
|
||||
| cut -d: -f2)
|
||||
REPO_SIZE_BYTES=${REPO_SIZE_BYTES:-0}
|
||||
echo "restic repo size: ${REPO_SIZE_BYTES} bytes"
|
||||
|
||||
# Textfile-collector metrics, same wiring as
|
||||
# loki/grafana siblings (DEV-494). Atomic write.
|
||||
{
|
||||
echo "backup_forgejo_success $([ ${CHECK_STATUS} -eq 0 ] && echo 1 || echo 0)"
|
||||
echo "backup_forgejo_timestamp_seconds $(date +%s)"
|
||||
echo "backup_forgejo_check_status ${CHECK_STATUS}"
|
||||
echo "restic_repo_size_bytes{repo=\"forgejo\"} ${REPO_SIZE_BYTES}"
|
||||
} > /metrics/backup_forgejo.prom.tmp
|
||||
mv /metrics/backup_forgejo.prom.tmp /metrics/backup_forgejo.prom
|
||||
|
||||
echo "=== backup-forgejo-restic finished at $(date -u +%FT%TZ) ==="
|
||||
exit ${CHECK_STATUS}
|
||||
volumeMounts:
|
||||
- name: dump
|
||||
mountPath: /source
|
||||
readOnly: true
|
||||
- name: metrics
|
||||
mountPath: /metrics
|
||||
- name: cache
|
||||
mountPath: /root/.cache/restic
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 1500m
|
||||
memory: 1Gi
|
||||
volumes:
|
||||
- name: backup-data
|
||||
persistentVolumeClaim:
|
||||
claimName: platform-backup-data
|
||||
- name: dump
|
||||
emptyDir:
|
||||
sizeLimit: 5Gi
|
||||
- name: metrics
|
||||
hostPath:
|
||||
path: /var/lib/node_exporter/textfile_collector
|
||||
type: DirectoryOrCreate
|
||||
- name: cache
|
||||
emptyDir: {}
|
||||
|
|
|
|||
19
apps/forgejo/forgejo-s3-backup-sealed.yaml
Normal file
19
apps/forgejo/forgejo-s3-backup-sealed.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
apiVersion: bitnami.com/v1alpha1
|
||||
kind: SealedSecret
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: forgejo-s3-backup
|
||||
namespace: forgejo
|
||||
spec:
|
||||
encryptedData:
|
||||
access-key: AgAu/XawJy0uhi3l8vwtd+loShP2ITnbOEZ/9eu6dkypz7l1MmFJhnEGq8Sy1lsg7BKj6epYRI40kDJIAG6KMBD5i7FvLYCvbFuPJaS3yehA8b1ECreHXvx/VYRUZL4K3WtiPO0fXmUNkxAqIcfHOcHtOZ7YcgSSyTw4DaXzngtek4xcTQlisyIVeBgc1dLPY+vHGn10BGkU5R/LnsUQqgGdp6u24Y+m7OrQGqMH8xwtZR7siEEkasu4IWG0QcY+okcMeiCi8mgAF64rdeZnIeEWqRnKHXxwEurtm20NmmCeRR8OgZfWDesgasThsnNSlRySd4EKlP3DMz8Yyluz/LlSkPt24brnP4qKuEoRpCHHOWgOizX8hkkS+Al7Rnpmxv2pt5CGlRw9s/5uPY81PAN9WLIgjs+XIylvaJBSW06nw3v+MjCUr5jfimW9riTLN32cBSurdpNMzlgb5+DJzEvaGNEG+jSjjFj2eSLci23UWySh7oFdmXeBp3kDWCrrNlJOYqJEwAAunTwvtM/1XknW2qJyyJrD7hbWN7SX4Ymb5Q5q18fMGH+IzB/Si1jdBli8pi8B2Gd/jo6RSmhXUheO3xsB2sXYc09GuSBBBBNzr9MsRL3mvUVr22pay+YPcCbwbwtdR0qmrdhEHAychYSJpNGuwGvs0AthRsNGN6hr1/IJqnnSRkGdJPr6k+T3v7XJzouYy+8Dh8r6oPW3hOhHo4HpJw==
|
||||
bucket: AgAyQVCK+wE0wWxwVjx5DVnI2falTGNzsUrolv1YyoG2Bx6msYe2G00ovxCMJ6/dWws1Q/Sf6yoiFT0IByYflQFhY/ABzXRi7/gFxCWyYU/7yTo7w1Ue0+eEnBKFtkP/ZhIvtfubo6GDDtbmshpp4UtzPq7kB6qvE6oCFyf6awQD2pe/GKX8RZaDx5TCbxPxhedLkYdc7srR/8rFZBnQiXm/2ohtDOwvLA+RVOpnCW/bsVLvLYQmXyhszMv2M3zqXwM9ODYzQ4Y7lIw6T3Hsmel/1hHWJtN8/mlq+Q3Z/NbzhVeEWs8/xbK/ShC9RwZUgBWxAiW04HvwcxVYs60KaJ4oOMLtO4Iko4+iPTNonjra2wmziEpUtsotvtReRjwHdvk1bBn3q6uqzNaJFv9wLc/RVCvKSpqE5KOxmdbke9EDUs4wQHVQCAfFfwnzzpcF/Rf6ML3TUWc+U0W6UjCfVTWwROznpi0E18tA07apx+xm1y+MyuVYnSpZv++vXetnCjA+KFvqZSon/WnKQrmSO2RFR07FcMs6gQ+EOSaRXBPCnEcnF2IJUbJLdFsgpQbzYG6zo3m2KmxN7gaGrTUdhNg/N7LklnNbJML9KRa7e2Rv8t4NGoigOpZG6+soIijZOSfEJF6owN6MP3oecgUgNsnQ5ftKK/RznNaDtIeZ5KByHlQ7CsZ0MIkHBmoEkDt64xWNEoW4NrWPSxas6pOQGZMPMA==
|
||||
endpoint: AgBhXqp3TdUU6GxsexRIoeHNwjvxnd2v4Any9WGs7gtyZvfdGIuWHIQ+JpsuRDrEq+BvTaX49D0QYoqnoQ6ZgmmGUI7kJxc+Ombcqo/8PAsOmzfdUaz1NcLAeR/UTGu8BDxAQcySSFax2dyK/zkY3jcsmuUCJNJLB57EZwtwx4fyz+ota+zNBE7pY+sDpWkXKHHtWrvNkD7tSLYHQuS3uzATidRixngL+iaH8rQX3ZK6KaK4H4ZdFG3/1Fw0wjLt8+yVBntBnh/bCL9/JLWQWIiHtooQnKXDdEwYQHWhrktti1KDzy/S++o4XVcRG1alzsQ0vV7uzSZAh4xCmnzximJxeJKawzFXRgFUcoEPrOI/nVedYrAU0DzkuwPdqtQDi/fGoqzZPx3PYhB9wv2PJsLg20ZlFcfHYUCad3O6OmO+VlrpYYcUe4oStkOygrmK5bv89fQ4Tov7BB14+RQIo9ogKmLzZu3xStqIVK+/Y8OUC64zyo7FpPMoYKtRW5oawu8pxo7Gtb+27BMMXQBiMEPNf38C4aj3GGJOvm4CFxVHV510/7o2hSW2ITt5X2RzNOEzULJOHD3DvO8wdWzvpBykRFnZ8JTYv77Kbx7bd9Ylx62VXm2a+Bd0K1Nc/sNJE63pglVhdl05oLYsUTrcSsNoJ6Dz3pMKeue3pYrn9KNurG0s+BFfO6I2Aa4/L8iAggXBzf9G1ALSkn7A30C9rMeyV+/1n+q8GpdPqq4ltqLOHVwIZw==
|
||||
restic-password: AgB1JGFnSDe3N5ZaiT9xShfzUQiyDtGwH2wotYO5Ekk9nqAbS4xTNBXu/1Z1+cB8zVUP80hrPHHbVXjNu47AcLI+Bn8Ozn9fKIZFsnVHXqicG0Y+WHRMpQxuMijVEMl48oUNE+vw+xRhYTzibVWoxur+VEzyPCn8rLUX07tcNhY+jYptNVBYlVVXDrHcOs0s9wpvIlSWmc9RsINMu+6FaedXAs3nKY4gUpWxmNLEpLG8V7D2mNJZHk1adghTN9p8RMGcLtNylDY9W1Sdnziga/NG0WulZf+4IWV6e7wc2oBCSekTofCZuCva8HazJ1EIeqBvt2GuXoG1CjjbPNb2UwZeH21Iol1W0C5So1VFguxHZq5WL44mqD79NLlnSAMmQMSvxf7gXQVXEdtT5UlF9nYCkuQ+K+mT+8iRMy1YSCQSQ9kkD36unkrlYcLXKoPLYxHcoo7fGZ0MPIW8J+P77Hp9Z8jNPCyCaeSJYApCxybO8ujbUGdIr2FCP1z/JDP4/zZat9MkTA6G3H9kDNso7AJBHL/NfkWImvQnveLGj0CuhPpf2zwOylCqTeXrgoN8zztO2/qVDa3VRQbznap0njr0qzVrvOfTxYoiRmkGh3PXZ2mb/NqbeIvCFhj2VyVgavdMchlIbM3fCzXGEdCa2RN+ZYVtcFe/b6wR/qf7lrMaPHsTZiMfYuMJoC9F9wdbkOttT9c36Sob4x+aQYLs7/+XGCJdbw672qot6x6P7GHjY8zVKlMYLSZjKcoD48c=
|
||||
secret-key: AgAHz7lfqTLG0Kldt/IZGW/o8KKazICHGbdVklXs3Gzs6Mc2KaG3nggkZDRWwY6noochg4Wc9mDDMGY3XIQnEy3PXs/rjQgwym0Dbvc54CvjWUcfmxCJyIqY1uCUxoXp/WAEV18lpWFSkVx+5U9wSccYVmkOJXNJ/MgH0vzJPaNW6xpYDg1XRC4nj72vwqyvZ/gM/LEX41NOpPjBROLGql3uBk5UfzNZG4dBun0aaPiYx5KKAbz8JAbK0umvOOOctTPSv1hIhyv9xJRRB67MW/J8T6mB2YfezsisXSSOAVNuE2JWrafHFxKq/hSNQHSrQRKjt18k6vfxmfkICbgQUZl/7AnoKi/f+76Feu+3wuMT+XPsaJN+iWb3jNzcb1T+Aiv4rGyIA7uCB6FNOc9Ngf5ngDQJw0nDNHDSnUoowgcE96kuPefU1rmn2V+JJcwoYdzYE3Ds48C0rNT+WSIit4551SiyWuj7iCRLhyEf5vhojxkuoyCN+EC1a50XwIkt223EBmy6FFPeBxql6HyJbOE313BPsQU6enPrTKNFd7iZYqJXq1k+SRuGwJhMeo1BBNhmVqxFGOpyxkSVs3VPybeXd82Zk/xF0SWKyBp3XQnLB39ZmT83Z5jwrYfkZmWkkdRvCME6NmlQtftRDDk3efGbgw7bz4Eq0d08KPCezsaDSAGNewonYy3UOiv/CNQg5hQ5G6R55y7+kEMV4nNkjGj0qF1hrZURS0mV4I/7H0OwXpDYiVetfaYI
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: forgejo-s3-backup
|
||||
namespace: forgejo
|
||||
|
|
@ -16,7 +16,7 @@ spec:
|
|||
serviceAccountName: headlamp-admin
|
||||
containers:
|
||||
- name: headlamp
|
||||
image: ghcr.io/headlamp-k8s/headlamp:v0.43.0
|
||||
image: ghcr.io/headlamp-k8s/headlamp:v0.45.0
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- "-in-cluster"
|
||||
|
|
|
|||
58
apps/monitoring/README.md
Normal file
58
apps/monitoring/README.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# monitoring — backup CronJobs
|
||||
|
||||
Manifests recording the cluster-side monitoring backup CronJobs that were previously applied out-of-band. These files are the authoritative source (`kubectl apply -f apps/monitoring/`). See [DEV-464](/DEV/issues/DEV-464) for the repair context.
|
||||
|
||||
- `backup-k8s-resources-cronjob.yaml` — daily dump of cluster-scoped and per-namespace Kubernetes resources, streamed through `restic backup --stdin` to `hetzner-s3:${BUCKET}/restic/k8s-resources`. Uses `serviceAccountName: backup-sa` and no PVC mount (init container `alpine/k8s:1.29.4` writes an emptyDir, main container `restic/restic:0.19.1` reads it on stdin). Rewritten from the local-path tarball per the [DEV-482](/DEV/issues/DEV-482) Option 4 rollout ([DEV-487](/DEV/issues/DEV-487)).
|
||||
- `backup-loki-restic-cronjob.yaml` — daily restic backup of `loki-storage-encrypted` to `hetzner-s3:${BUCKET}/restic/loki`. Co-schedules with the Loki pod via `podAffinity` (RWO permits additional read-only mounts on the same node). Deployed per the [DEV-482](/DEV/issues/DEV-482) Option 4 rollout ([DEV-485](/DEV/issues/DEV-485)).
|
||||
- `backup-grafana-restic-cronjob.yaml` — daily restic backup of `grafana-storage` to `hetzner-s3:${BUCKET}/restic/grafana`. Pinned to `k3s-worker-2` via `nodeSelector` (the local-path PV anchors the grafana pod there already, no `podAffinity` needed). Schedule `15 3 * * *` — offset from the loki run at `03:00`. Deployed per the [DEV-482](/DEV/issues/DEV-482) Option 4 rollout ([DEV-486](/DEV/issues/DEV-486)).
|
||||
- `prometheus-backup-cronjob.yaml` + `prometheus-backup-sealed.yaml` — daily restic backup of `prometheus-data-encrypted` to `hetzner-s3:${BUCKET}/restic/prometheus`. Co-schedules with the Prometheus pod via `podAffinity` so the RWO PVC attaches on the same node. Schedule `30 3 * * *` — offset from the loki (03:00) and grafana (03:15) runs. Migrated from the DEV-465 `rclone sync` job to restic client-side encryption per [DEV-492](/DEV/issues/DEV-492) / [DEV-482](/DEV/issues/DEV-482) Option 4. **Compaction-race mitigation:** `--exclude wal/*` + `--exclude chunks_head/*` + accept `restic backup` exit code 3 (source file vanished mid-walk) as a warning, not a failure; restore drill re-runs `promtool tsdb analyze` per block.
|
||||
- `backup-restic-alerts.yaml` + `backup-restic-alerts.test.yaml` — `PrometheusRule` with freshness, integrity, and repo-size alerts covering the four restic repos (loki/grafana/k8s-resources/prometheus), plus a `promtool test rules` unit test proving each alert fires against synthetic samples ([DEV-490](/DEV/issues/DEV-490), extended in [DEV-492](/DEV/issues/DEV-492)).
|
||||
|
||||
## Shared SealedSecret
|
||||
|
||||
All four restic CronJobs read Hetzner S3 credentials + the restic repository password from **SealedSecret `monitoring-s3-backup`** (namespace `monitoring`). Keys:
|
||||
|
||||
| Key | Purpose |
|
||||
|------------------|------------------------------------------------------------------------------------------------------------------|
|
||||
| `access-key` | Hetzner Object Storage access key ID |
|
||||
| `secret-key` | Hetzner Object Storage secret access key |
|
||||
| `endpoint` | S3 endpoint hostname (e.g. `fsn1.your-objectstorage.com`) |
|
||||
| `bucket` | Bucket name (single bucket, per-prefix repos) |
|
||||
| `restic-password`| 32-byte random string sealed at [DEV-484](/DEV/issues/DEV-484); plaintext copy in Passbolt entry `restic / monitoring backups`. Rotation: `restic key add` → seal new value → `restic key remove` old id. Same key protects all four repos (loki/grafana/k8s-resources/prometheus) — rotating rewrites the key file on every repo. |
|
||||
|
||||
## Restore
|
||||
|
||||
Restore procedure for all four restic repos: [`docs/monitoring/restic-restore.md`](../../docs/monitoring/restic-restore.md). First drill (2026-08-16, loki/k8s-resources) passed — see [DEV-488](/DEV/issues/DEV-488). Prometheus repo added and drilled same day — see [DEV-492](/DEV/issues/DEV-492).
|
||||
|
||||
## Emitted metrics (textfile-collector format)
|
||||
|
||||
Each restic CronJob writes `/metrics/backup_<kind>.prom` (atomic — `.prom.tmp` + `mv`) into a `hostPath` volume mounted at the node-exporter textfile-collector directory (`/var/lib/node_exporter/textfile_collector`). The kube-prometheus-stack node-exporter DaemonSet has `--collector.textfile.directory=/host/textfile_collector` enabled ([DEV-494](/DEV/issues/DEV-494), applied via [`apps/observability/patches/node-exporter-textfile-collector.yaml`](../observability/patches/node-exporter-textfile-collector.yaml)) and surfaces those samples in Prometheus.
|
||||
|
||||
| Metric | Emitted by |
|
||||
|--------------------------------------------|----------------------------------------------------------------------------------|
|
||||
| `backup_<kind>_success` (0/1) | `backup-{loki,grafana,k8s-resources}-*-cronjob.yaml`, `prometheus-backup-cronjob.yaml` |
|
||||
| `backup_<kind>_timestamp_seconds` | same |
|
||||
| `backup_<kind>_check_status` (exit code) | same — from `restic check --read-data-subset=5%` |
|
||||
| `backup_prometheus_backup_status` | `prometheus-backup-cronjob.yaml` — `restic backup` exit code (3 = accepted compaction race) |
|
||||
| `restic_repo_size_bytes{repo="<kind>"}` | same — from `restic stats --json --mode raw-data` (added in DEV-490) |
|
||||
|
||||
**Cross-node staleness note.** Because a backup CronJob may run on a different worker across days (loki/prometheus follow their app pods, `k8s-resources` is unpinned), a `.prom` file can linger on a node the job has since left and node-exporter keeps exposing it. The freshness alerts collapse the per-node samples with `max()` so the freshest sample wins; check/size alerts fire when *any* node reports a bad value, which is intentional — a recent failure is still a signal until the file is manually cleaned or the job returns to that node.
|
||||
|
||||
## Alerts
|
||||
|
||||
`backup-restic-alerts.yaml` defines nine alerts (all `severity: warning`):
|
||||
|
||||
- `BackupLokiStale` / `BackupGrafanaStale` / `BackupK8sResourcesStale` / `BackupPrometheusStale` — `time() - max(backup_<kind>_timestamp_seconds) > 28h`. Daily schedule + 4 h grace. `max()` collapses per-node samples so a stale `.prom` on a node the job has left does not fire.
|
||||
- `BackupLokiCheckFailed` / `BackupGrafanaCheckFailed` / `BackupK8sResourcesCheckFailed` / `BackupPrometheusCheckFailed` — `backup_<kind>_check_status != 0`.
|
||||
- `ResticRepoOversize` — `restic_repo_size_bytes > 20 GiB`. Baseline expected < 5 GiB; catches retention/prune regressions.
|
||||
|
||||
All alerts carry a `Runbook: docs/monitoring/restic-restore.md` annotation. To iterate on the rule file locally:
|
||||
|
||||
```bash
|
||||
awk '/^spec:/{f=1;next} f{sub(/^ /,"");print}' \
|
||||
apps/monitoring/backup-restic-alerts.yaml > /tmp/backup-restic-rules.yaml
|
||||
promtool check rules /tmp/backup-restic-rules.yaml
|
||||
promtool test rules apps/monitoring/backup-restic-alerts.test.yaml
|
||||
```
|
||||
|
||||
The legacy `backup-volumes` CronJob and its 100 Gi local-path `backup-storage` PVC were retired in [DEV-489](/DEV/issues/DEV-489) once the restic pipeline was proven end-to-end. With the shared destination PVC gone, the DEV-483 bridge `nodeSelector` pinning Loki to `k3s-worker-2` was also removed — `backup-loki-restic` follows the Loki pod via `podAffinity` regardless of which node the RWO CSI volume lands on. `backup-grafana-restic` still nodeSelects `k3s-worker-2` because its source PV (`grafana-storage`, local-path) is anchored there. `backup-k8s-resources` has no PVC dep and stays unpinned. `prometheus-backup` uses `podAffinity` on `app=prometheus` (RWO PVC on Hetzner CSI, single-node attach) and follows the Prometheus pod between workers.
|
||||
160
apps/monitoring/backup-grafana-restic-cronjob.yaml
Normal file
160
apps/monitoring/backup-grafana-restic-cronjob.yaml
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
---
|
||||
# Grafana data backup via restic to Hetzner Object Storage (DEV-486,
|
||||
# DEV-482 Option 4). Step 3 of the Option 4 rollout.
|
||||
#
|
||||
# Streams the RWO PVC `grafana-storage` (mounted read-only) into
|
||||
# `s3:${S3_ENDPOINT}/${S3_BUCKET}/restic/grafana`, a client-side
|
||||
# encrypted restic repository.
|
||||
#
|
||||
# `grafana-storage` is a local-path PV anchored on k3s-worker-2, so
|
||||
# the grafana pod is already pinned there; a plain nodeSelector on
|
||||
# the same host is enough (no podAffinity like the loki job needed).
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: backup-grafana-restic
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app: backup
|
||||
type: grafana
|
||||
backend: restic
|
||||
spec:
|
||||
schedule: "15 3 * * *"
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
jobTemplate:
|
||||
metadata:
|
||||
labels:
|
||||
app: backup
|
||||
type: grafana
|
||||
backend: restic
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
activeDeadlineSeconds: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: backup
|
||||
type: grafana
|
||||
backend: restic
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: k3s-worker-2
|
||||
containers:
|
||||
- name: restic
|
||||
image: harbor.basicstack.de/library/restic:0.19.1
|
||||
# Mirrored from docker.io/restic/restic:0.19.1 (DEV-493) —
|
||||
# deterministic ingress via Harbor. Retag procedure in
|
||||
# docs/monitoring/restic-restore.md § "Tag-bump procedure".
|
||||
env:
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: access-key
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: secret-key
|
||||
- name: RESTIC_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: restic-password
|
||||
- name: S3_ENDPOINT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: endpoint
|
||||
- name: S3_BUCKET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: bucket
|
||||
- name: RESTIC_REPOSITORY
|
||||
value: "s3:$(S3_ENDPOINT)/$(S3_BUCKET)/restic/grafana"
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
echo "=== backup-grafana-restic started at $(date -u +%FT%TZ) ==="
|
||||
echo "Repository: ${RESTIC_REPOSITORY}"
|
||||
|
||||
# First-run tolerance: init if the repo isn't there yet.
|
||||
if restic snapshots >/dev/null 2>&1; then
|
||||
echo "Repo exists, skipping init."
|
||||
else
|
||||
echo "Repo missing, initialising..."
|
||||
restic init
|
||||
fi
|
||||
|
||||
echo "--- restic backup /source ---"
|
||||
restic backup /source \
|
||||
--tag grafana \
|
||||
--host k3s \
|
||||
--exclude '*.tmp'
|
||||
|
||||
echo "--- restic forget/prune ---"
|
||||
restic forget --tag grafana \
|
||||
--keep-daily 7 \
|
||||
--keep-weekly 4 \
|
||||
--keep-monthly 6 \
|
||||
--prune
|
||||
|
||||
echo "--- restic check --read-data-subset=5% ---"
|
||||
CHECK_STATUS=0
|
||||
restic check --read-data-subset=5% || CHECK_STATUS=$?
|
||||
echo "restic check exit: ${CHECK_STATUS}"
|
||||
|
||||
echo "--- restic stats (repo size) ---"
|
||||
REPO_SIZE_BYTES=$(restic stats --json --mode raw-data 2>/dev/null \
|
||||
| grep -oE '"total_size":[0-9]+' \
|
||||
| head -1 \
|
||||
| cut -d: -f2)
|
||||
REPO_SIZE_BYTES=${REPO_SIZE_BYTES:-0}
|
||||
echo "restic repo size: ${REPO_SIZE_BYTES} bytes"
|
||||
|
||||
# Textfile-collector metrics; identical wiring to the
|
||||
# loki sibling. See that file for the atomic-write
|
||||
# rationale (DEV-494).
|
||||
{
|
||||
echo "backup_grafana_success $([ ${CHECK_STATUS} -eq 0 ] && echo 1 || echo 0)"
|
||||
echo "backup_grafana_timestamp_seconds $(date +%s)"
|
||||
echo "backup_grafana_check_status ${CHECK_STATUS}"
|
||||
echo "restic_repo_size_bytes{repo=\"grafana\"} ${REPO_SIZE_BYTES}"
|
||||
} > /metrics/backup_grafana.prom.tmp
|
||||
mv /metrics/backup_grafana.prom.tmp /metrics/backup_grafana.prom
|
||||
|
||||
echo "=== backup-grafana-restic finished at $(date -u +%FT%TZ) ==="
|
||||
exit ${CHECK_STATUS}
|
||||
volumeMounts:
|
||||
- name: grafana-data
|
||||
mountPath: /source
|
||||
readOnly: true
|
||||
- name: metrics
|
||||
mountPath: /metrics
|
||||
- name: cache
|
||||
mountPath: /root/.cache/restic
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 1500m
|
||||
memory: 1Gi
|
||||
volumes:
|
||||
- name: grafana-data
|
||||
persistentVolumeClaim:
|
||||
claimName: grafana-storage
|
||||
- name: metrics
|
||||
hostPath:
|
||||
# node-exporter's textfile-collector directory
|
||||
# (DEV-494). See sibling loki cronjob for detail.
|
||||
path: /var/lib/node_exporter/textfile_collector
|
||||
type: DirectoryOrCreate
|
||||
- name: cache
|
||||
emptyDir: {}
|
||||
205
apps/monitoring/backup-k8s-resources-cronjob.yaml
Normal file
205
apps/monitoring/backup-k8s-resources-cronjob.yaml
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
---
|
||||
# Kubernetes-resource backup via restic to Hetzner Object Storage
|
||||
# (DEV-487, DEV-482 Option 4). Step 4 of the Option 4 rollout.
|
||||
#
|
||||
# Streams a concatenated YAML dump of cluster-scoped and per-namespace
|
||||
# resources through `restic backup --stdin` into
|
||||
# `s3:${S3_ENDPOINT}/${S3_BUCKET}/restic/k8s-resources`. No PVC mount
|
||||
# (drops the local-path `backup-storage` dependency), no node pin.
|
||||
#
|
||||
# Two-container pattern:
|
||||
# 1. `kubectl-dump` init container (`alpine/k8s:1.29.4`) writes
|
||||
# /dump/cluster.yaml into an emptyDir. Uses `serviceAccountName:
|
||||
# backup-sa` (unchanged from the legacy job).
|
||||
# 2. `restic` main container (`restic/restic:0.19.1`, matches the
|
||||
# loki/grafana siblings) reads that file on stdin and streams it
|
||||
# into the restic repo with `--stdin-filename cluster.yaml`.
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: backup-k8s-resources
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app: backup
|
||||
type: k8s-resources
|
||||
backend: restic
|
||||
spec:
|
||||
schedule: "0 2 * * *"
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
jobTemplate:
|
||||
metadata:
|
||||
labels:
|
||||
app: backup
|
||||
type: k8s-resources
|
||||
backend: restic
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
activeDeadlineSeconds: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: backup
|
||||
type: k8s-resources
|
||||
backend: restic
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: backup-sa
|
||||
initContainers:
|
||||
- name: kubectl-dump
|
||||
image: alpine/k8s:1.29.4
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
echo "=== kubectl-dump started at $(date -u +%FT%TZ) ==="
|
||||
DUMP=/dump/cluster.yaml
|
||||
: > "${DUMP}"
|
||||
|
||||
echo "--- namespaces ---"
|
||||
kubectl get namespaces -o yaml >> "${DUMP}"
|
||||
echo "---" >> "${DUMP}"
|
||||
|
||||
echo "--- cluster-scoped resources ---"
|
||||
kubectl get persistentvolumes,storageclasses,clusterroles,clusterrolebindings \
|
||||
-o yaml >> "${DUMP}"
|
||||
echo "---" >> "${DUMP}"
|
||||
|
||||
echo "--- namespaced resources ---"
|
||||
for ns in $(kubectl get namespaces -o jsonpath='{.items[*].metadata.name}'); do
|
||||
echo " ns=${ns}"
|
||||
kubectl get \
|
||||
configmaps,secrets,services,deployments,statefulsets,daemonsets,jobs,cronjobs,ingresses,persistentvolumeclaims \
|
||||
-n "${ns}" -o yaml >> "${DUMP}" 2>/dev/null || true
|
||||
echo "---" >> "${DUMP}"
|
||||
done
|
||||
|
||||
echo "dump size: $(wc -c < ${DUMP}) bytes"
|
||||
echo "=== kubectl-dump finished at $(date -u +%FT%TZ) ==="
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
volumeMounts:
|
||||
- name: dump
|
||||
mountPath: /dump
|
||||
containers:
|
||||
- name: restic
|
||||
image: harbor.basicstack.de/library/restic:0.19.1
|
||||
# Mirrored from docker.io/restic/restic:0.19.1 (DEV-493) —
|
||||
# deterministic ingress via Harbor. Retag procedure in
|
||||
# docs/monitoring/restic-restore.md § "Tag-bump procedure".
|
||||
env:
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: access-key
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: secret-key
|
||||
- name: RESTIC_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: restic-password
|
||||
- name: S3_ENDPOINT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: endpoint
|
||||
- name: S3_BUCKET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: bucket
|
||||
- name: RESTIC_REPOSITORY
|
||||
value: "s3:$(S3_ENDPOINT)/$(S3_BUCKET)/restic/k8s-resources"
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
echo "=== backup-k8s-resources-restic started at $(date -u +%FT%TZ) ==="
|
||||
echo "Repository: ${RESTIC_REPOSITORY}"
|
||||
|
||||
# First-run tolerance: init if the repo isn't there yet.
|
||||
if restic snapshots >/dev/null 2>&1; then
|
||||
echo "Repo exists, skipping init."
|
||||
else
|
||||
echo "Repo missing, initialising..."
|
||||
restic init
|
||||
fi
|
||||
|
||||
echo "--- restic backup --stdin cluster.yaml ---"
|
||||
restic backup --stdin \
|
||||
--stdin-filename cluster.yaml \
|
||||
--tag k8s-resources \
|
||||
--host k3s < /dump/cluster.yaml
|
||||
|
||||
echo "--- restic forget/prune ---"
|
||||
restic forget --tag k8s-resources \
|
||||
--keep-daily 7 \
|
||||
--keep-weekly 4 \
|
||||
--keep-monthly 6 \
|
||||
--prune
|
||||
|
||||
echo "--- restic check --read-data-subset=5% ---"
|
||||
CHECK_STATUS=0
|
||||
restic check --read-data-subset=5% || CHECK_STATUS=$?
|
||||
echo "restic check exit: ${CHECK_STATUS}"
|
||||
|
||||
echo "--- restic stats (repo size) ---"
|
||||
REPO_SIZE_BYTES=$(restic stats --json --mode raw-data 2>/dev/null \
|
||||
| grep -oE '"total_size":[0-9]+' \
|
||||
| head -1 \
|
||||
| cut -d: -f2)
|
||||
REPO_SIZE_BYTES=${REPO_SIZE_BYTES:-0}
|
||||
echo "restic repo size: ${REPO_SIZE_BYTES} bytes"
|
||||
|
||||
# Textfile-collector metrics; identical wiring to the
|
||||
# loki/grafana siblings. See loki cronjob for the
|
||||
# atomic-write rationale (DEV-494).
|
||||
{
|
||||
echo "backup_k8s_resources_success $([ ${CHECK_STATUS} -eq 0 ] && echo 1 || echo 0)"
|
||||
echo "backup_k8s_resources_timestamp_seconds $(date +%s)"
|
||||
echo "backup_k8s_resources_check_status ${CHECK_STATUS}"
|
||||
echo "restic_repo_size_bytes{repo=\"k8s-resources\"} ${REPO_SIZE_BYTES}"
|
||||
} > /metrics/backup_k8s_resources.prom.tmp
|
||||
mv /metrics/backup_k8s_resources.prom.tmp /metrics/backup_k8s_resources.prom
|
||||
|
||||
echo "=== backup-k8s-resources-restic finished at $(date -u +%FT%TZ) ==="
|
||||
exit ${CHECK_STATUS}
|
||||
volumeMounts:
|
||||
- name: dump
|
||||
mountPath: /dump
|
||||
readOnly: true
|
||||
- name: metrics
|
||||
mountPath: /metrics
|
||||
- name: cache
|
||||
mountPath: /root/.cache/restic
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 1500m
|
||||
memory: 1Gi
|
||||
volumes:
|
||||
- name: dump
|
||||
emptyDir: {}
|
||||
- name: metrics
|
||||
hostPath:
|
||||
# node-exporter's textfile-collector directory
|
||||
# (DEV-494). See sibling loki cronjob for detail.
|
||||
path: /var/lib/node_exporter/textfile_collector
|
||||
type: DirectoryOrCreate
|
||||
- name: cache
|
||||
emptyDir: {}
|
||||
187
apps/monitoring/backup-loki-restic-cronjob.yaml
Normal file
187
apps/monitoring/backup-loki-restic-cronjob.yaml
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
---
|
||||
# Loki data backup via restic to Hetzner Object Storage (DEV-485,
|
||||
# DEV-482 Option 4). Step 2 of the Option 4 rollout.
|
||||
#
|
||||
# Streams the RWO PVC `loki-storage-encrypted` (mounted read-only)
|
||||
# into `s3:${S3_ENDPOINT}/${S3_BUCKET}/restic/loki`, a client-side
|
||||
# encrypted restic repository.
|
||||
#
|
||||
# podAffinity co-schedules with the Loki pod (app=loki, topology
|
||||
# kubernetes.io/hostname). RWO permits additional read-only mounts
|
||||
# on the node that holds the PVC's VolumeAttachment, so this
|
||||
# survives Loki being rescheduled to a different worker.
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: backup-loki-restic
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app: backup
|
||||
type: loki
|
||||
backend: restic
|
||||
spec:
|
||||
schedule: "0 3 * * *"
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
jobTemplate:
|
||||
metadata:
|
||||
labels:
|
||||
app: backup
|
||||
type: loki
|
||||
backend: restic
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
activeDeadlineSeconds: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: backup
|
||||
type: loki
|
||||
backend: restic
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
affinity:
|
||||
podAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchExpressions:
|
||||
- key: app
|
||||
operator: In
|
||||
values:
|
||||
- loki
|
||||
topologyKey: kubernetes.io/hostname
|
||||
containers:
|
||||
- name: restic
|
||||
image: harbor.basicstack.de/library/restic:0.19.1
|
||||
# Mirrored from docker.io/restic/restic:0.19.1 (DEV-493) —
|
||||
# deterministic ingress via Harbor. Retag procedure in
|
||||
# docs/monitoring/restic-restore.md § "Tag-bump procedure".
|
||||
env:
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: access-key
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: secret-key
|
||||
- name: RESTIC_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: restic-password
|
||||
- name: S3_ENDPOINT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: endpoint
|
||||
- name: S3_BUCKET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: bucket
|
||||
- name: RESTIC_REPOSITORY
|
||||
value: "s3:$(S3_ENDPOINT)/$(S3_BUCKET)/restic/loki"
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
echo "=== backup-loki-restic started at $(date -u +%FT%TZ) ==="
|
||||
echo "Repository: ${RESTIC_REPOSITORY}"
|
||||
|
||||
# First-run tolerance: init if the repo isn't there yet.
|
||||
# `restic cat config` is the tightest existence probe; use
|
||||
# `snapshots` per plan spec — either exits 0 iff the repo
|
||||
# is initialised.
|
||||
if restic snapshots >/dev/null 2>&1; then
|
||||
echo "Repo exists, skipping init."
|
||||
else
|
||||
echo "Repo missing, initialising..."
|
||||
restic init
|
||||
fi
|
||||
|
||||
echo "--- restic backup /source ---"
|
||||
restic backup /source \
|
||||
--tag loki \
|
||||
--host k3s \
|
||||
--exclude '*.tmp'
|
||||
|
||||
echo "--- restic forget/prune ---"
|
||||
restic forget --tag loki \
|
||||
--keep-daily 7 \
|
||||
--keep-weekly 4 \
|
||||
--keep-monthly 6 \
|
||||
--prune
|
||||
|
||||
echo "--- restic check --read-data-subset=5% ---"
|
||||
CHECK_STATUS=0
|
||||
restic check --read-data-subset=5% || CHECK_STATUS=$?
|
||||
echo "restic check exit: ${CHECK_STATUS}"
|
||||
|
||||
echo "--- restic stats (repo size) ---"
|
||||
# `restic stats --json --mode raw-data` prints e.g.
|
||||
# {"total_size":123,"total_file_count":45,...}. Extract
|
||||
# total_size without jq (not present in the restic image)
|
||||
# via grep/cut; fall back to 0 on empty output.
|
||||
REPO_SIZE_BYTES=$(restic stats --json --mode raw-data 2>/dev/null \
|
||||
| grep -oE '"total_size":[0-9]+' \
|
||||
| head -1 \
|
||||
| cut -d: -f2)
|
||||
REPO_SIZE_BYTES=${REPO_SIZE_BYTES:-0}
|
||||
echo "restic repo size: ${REPO_SIZE_BYTES} bytes"
|
||||
|
||||
# Textfile-collector metrics. Written to the shared
|
||||
# host directory that node-exporter's textfile collector
|
||||
# scrapes (DEV-494). Atomic write: build the file with a
|
||||
# `.tmp` extension (ignored by node-exporter) and rename
|
||||
# into place, so a mid-write read never surfaces a
|
||||
# truncated sample.
|
||||
{
|
||||
echo "backup_loki_success $([ ${CHECK_STATUS} -eq 0 ] && echo 1 || echo 0)"
|
||||
echo "backup_loki_timestamp_seconds $(date +%s)"
|
||||
echo "backup_loki_check_status ${CHECK_STATUS}"
|
||||
echo "restic_repo_size_bytes{repo=\"loki\"} ${REPO_SIZE_BYTES}"
|
||||
} > /metrics/backup_loki.prom.tmp
|
||||
mv /metrics/backup_loki.prom.tmp /metrics/backup_loki.prom
|
||||
|
||||
echo "=== backup-loki-restic finished at $(date -u +%FT%TZ) ==="
|
||||
exit ${CHECK_STATUS}
|
||||
volumeMounts:
|
||||
- name: loki-data
|
||||
mountPath: /source
|
||||
readOnly: true
|
||||
- name: metrics
|
||||
mountPath: /metrics
|
||||
- name: cache
|
||||
mountPath: /root/.cache/restic
|
||||
resources:
|
||||
# Requests deliberately lowered from the plan doc's
|
||||
# 200m/256Mi — worker-2 (Loki node) has ~150m free CPU
|
||||
# and podAffinity forces us onto it. 100m/128Mi mirrors
|
||||
# the sibling backup CronJobs; limits stay generous so
|
||||
# restic can burst during pack/check.
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 1500m
|
||||
memory: 1Gi
|
||||
volumes:
|
||||
- name: loki-data
|
||||
persistentVolumeClaim:
|
||||
claimName: loki-storage-encrypted
|
||||
- name: metrics
|
||||
hostPath:
|
||||
# node-exporter's textfile-collector directory
|
||||
# (DEV-494). `DirectoryOrCreate` lets kubelet create
|
||||
# the dir on the current node if it does not yet
|
||||
# exist — nodes were pre-created out-of-band, this
|
||||
# is a safety net.
|
||||
path: /var/lib/node_exporter/textfile_collector
|
||||
type: DirectoryOrCreate
|
||||
- name: cache
|
||||
emptyDir: {}
|
||||
260
apps/monitoring/backup-restic-alerts.test.yaml
Normal file
260
apps/monitoring/backup-restic-alerts.test.yaml
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
---
|
||||
# promtool test file for backup-restic-alerts.yaml (DEV-490 DoD).
|
||||
#
|
||||
# Extract the rules from the PrometheusRule wrapper first:
|
||||
# awk '/^spec:/{f=1;next} f{sub(/^ /,"");print}' \
|
||||
# apps/monitoring/backup-restic-alerts.yaml > /tmp/rules.yaml
|
||||
# Then run:
|
||||
# promtool test rules apps/monitoring/backup-restic-alerts.test.yaml
|
||||
# (this file uses `rule_files: [/tmp/rules.yaml]` — pass the extracted
|
||||
# path explicitly).
|
||||
rule_files:
|
||||
- /tmp/backup-restic-rules.yaml
|
||||
|
||||
# Evaluation cadence for the test scenarios. The alerts' `for:` uses 5m/15m/30m,
|
||||
# so we run 60m of series and evaluate at 60m so all alerts have had time to
|
||||
# settle into the firing state.
|
||||
evaluation_interval: 1m
|
||||
|
||||
tests:
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Freshness alerts fire when timestamp is stale (>28h old).
|
||||
# ---------------------------------------------------------------------------
|
||||
- interval: 1m
|
||||
input_series:
|
||||
# Timestamp value = "current time - 29h" in Unix seconds. During the
|
||||
# test, promtool's clock starts at the Unix epoch (0). We fix the
|
||||
# value to a constant far in the past so `time() - value > 28*3600`
|
||||
# for the entire test run.
|
||||
#
|
||||
# Concretely: time() returns the sample timestamp in seconds. Over a
|
||||
# 60-minute test starting at t=0, time() runs 0 .. 3600. A constant
|
||||
# value of -104400 (=-29h) keeps `time() - value` >= 29h across the
|
||||
# whole window, safely above the 28h threshold.
|
||||
- series: 'backup_loki_timestamp_seconds'
|
||||
values: '-104400x60'
|
||||
- series: 'backup_grafana_timestamp_seconds'
|
||||
values: '-104400x60'
|
||||
- series: 'backup_k8s_resources_timestamp_seconds'
|
||||
values: '-104400x60'
|
||||
- series: 'backup_prometheus_timestamp_seconds'
|
||||
values: '-104400x60'
|
||||
alert_rule_test:
|
||||
- eval_time: 30m
|
||||
alertname: BackupLokiStale
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: loki
|
||||
exp_annotations:
|
||||
summary: "Loki restic backup is stale (>28h)"
|
||||
description: |
|
||||
`backup-loki-restic` has not written a fresh
|
||||
`backup_loki_timestamp_seconds` sample in more than
|
||||
28 hours. Expected daily at 03:00 UTC.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
- eval_time: 30m
|
||||
alertname: BackupGrafanaStale
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: grafana
|
||||
exp_annotations:
|
||||
summary: "Grafana restic backup is stale (>28h)"
|
||||
description: |
|
||||
`backup-grafana-restic` has not written a fresh
|
||||
`backup_grafana_timestamp_seconds` sample in more
|
||||
than 28 hours. Expected daily at 03:15 UTC.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
- eval_time: 30m
|
||||
alertname: BackupK8sResourcesStale
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: k8s-resources
|
||||
exp_annotations:
|
||||
summary: "K8s-resources restic backup is stale (>28h)"
|
||||
description: |
|
||||
`backup-k8s-resources` has not written a fresh
|
||||
`backup_k8s_resources_timestamp_seconds` sample in
|
||||
more than 28 hours. Expected daily at 02:00 UTC.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
- eval_time: 30m
|
||||
alertname: BackupPrometheusStale
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: prometheus
|
||||
exp_annotations:
|
||||
summary: "Prometheus restic backup is stale (>28h)"
|
||||
description: |
|
||||
`prometheus-backup` has not written a fresh
|
||||
`backup_prometheus_timestamp_seconds` sample in more
|
||||
than 28 hours. Expected daily at 03:30 UTC.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Freshness alerts stay silent when timestamp is fresh (<28h).
|
||||
# ---------------------------------------------------------------------------
|
||||
- interval: 1m
|
||||
input_series:
|
||||
# Value = -3600 (=-1h) → time() - value stays around 1-2h, well below
|
||||
# the 28h threshold across the run.
|
||||
- series: 'backup_loki_timestamp_seconds'
|
||||
values: '-3600x60'
|
||||
- series: 'backup_grafana_timestamp_seconds'
|
||||
values: '-3600x60'
|
||||
- series: 'backup_k8s_resources_timestamp_seconds'
|
||||
values: '-3600x60'
|
||||
- series: 'backup_prometheus_timestamp_seconds'
|
||||
values: '-3600x60'
|
||||
alert_rule_test:
|
||||
- eval_time: 30m
|
||||
alertname: BackupLokiStale
|
||||
exp_alerts: []
|
||||
- eval_time: 30m
|
||||
alertname: BackupGrafanaStale
|
||||
exp_alerts: []
|
||||
- eval_time: 30m
|
||||
alertname: BackupK8sResourcesStale
|
||||
exp_alerts: []
|
||||
- eval_time: 30m
|
||||
alertname: BackupPrometheusStale
|
||||
exp_alerts: []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Check-status alerts fire when the metric is non-zero.
|
||||
# ---------------------------------------------------------------------------
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'backup_loki_check_status'
|
||||
values: '1x30'
|
||||
- series: 'backup_grafana_check_status'
|
||||
values: '2x30'
|
||||
- series: 'backup_k8s_resources_check_status'
|
||||
values: '1x30'
|
||||
- series: 'backup_prometheus_check_status'
|
||||
values: '1x30'
|
||||
alert_rule_test:
|
||||
- eval_time: 15m
|
||||
alertname: BackupLokiCheckFailed
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: loki
|
||||
exp_annotations:
|
||||
summary: "restic check failed on loki repo"
|
||||
description: |
|
||||
`restic check --read-data-subset=5%` returned
|
||||
exit code 1 on the loki repository.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
- eval_time: 15m
|
||||
alertname: BackupGrafanaCheckFailed
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: grafana
|
||||
exp_annotations:
|
||||
summary: "restic check failed on grafana repo"
|
||||
description: |
|
||||
`restic check --read-data-subset=5%` returned
|
||||
exit code 2 on the grafana repository.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
- eval_time: 15m
|
||||
alertname: BackupK8sResourcesCheckFailed
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: k8s-resources
|
||||
exp_annotations:
|
||||
summary: "restic check failed on k8s-resources repo"
|
||||
description: |
|
||||
`restic check --read-data-subset=5%` returned
|
||||
exit code 1 on the k8s-resources
|
||||
repository.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
- eval_time: 15m
|
||||
alertname: BackupPrometheusCheckFailed
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: prometheus
|
||||
exp_annotations:
|
||||
summary: "restic check failed on prometheus repo"
|
||||
description: |
|
||||
`restic check --read-data-subset=5%` returned
|
||||
exit code 1 on the prometheus
|
||||
repository.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Check-status alerts stay silent on 0.
|
||||
# ---------------------------------------------------------------------------
|
||||
- interval: 1m
|
||||
input_series:
|
||||
- series: 'backup_loki_check_status'
|
||||
values: '0x30'
|
||||
- series: 'backup_grafana_check_status'
|
||||
values: '0x30'
|
||||
- series: 'backup_k8s_resources_check_status'
|
||||
values: '0x30'
|
||||
- series: 'backup_prometheus_check_status'
|
||||
values: '0x30'
|
||||
alert_rule_test:
|
||||
- eval_time: 15m
|
||||
alertname: BackupLokiCheckFailed
|
||||
exp_alerts: []
|
||||
- eval_time: 15m
|
||||
alertname: BackupGrafanaCheckFailed
|
||||
exp_alerts: []
|
||||
- eval_time: 15m
|
||||
alertname: BackupK8sResourcesCheckFailed
|
||||
exp_alerts: []
|
||||
- eval_time: 15m
|
||||
alertname: BackupPrometheusCheckFailed
|
||||
exp_alerts: []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Repo-size alert fires when >20 GiB (21474836480 bytes).
|
||||
# ---------------------------------------------------------------------------
|
||||
- interval: 1m
|
||||
input_series:
|
||||
# 25 GiB = 26843545600 bytes.
|
||||
- series: 'restic_repo_size_bytes{repo="loki"}'
|
||||
values: '26843545600x45'
|
||||
# 15 GiB = 16106127360 bytes (below threshold).
|
||||
- series: 'restic_repo_size_bytes{repo="grafana"}'
|
||||
values: '16106127360x45'
|
||||
alert_rule_test:
|
||||
- eval_time: 40m
|
||||
alertname: ResticRepoOversize
|
||||
exp_alerts:
|
||||
- exp_labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: loki
|
||||
exp_annotations:
|
||||
summary: "restic repository loki exceeds 20 GiB"
|
||||
description: |
|
||||
Repository loki is
|
||||
25GiB, above the 20 GiB
|
||||
guard. Baseline is <5 GiB per repo. Investigate
|
||||
retention/prune (see forget flags in the CronJob)
|
||||
and dedup effectiveness.
|
||||
216
apps/monitoring/backup-restic-alerts.yaml
Normal file
216
apps/monitoring/backup-restic-alerts.yaml
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
---
|
||||
# Prometheus alerting rules for the restic-based monitoring backups
|
||||
# (DEV-490 / DEV-482 Option 4). Freshness alerts fire when a
|
||||
# CronJob has not emitted its `backup_<kind>_timestamp_seconds`
|
||||
# metric within 28h (schedules are daily; 28h gives one missed
|
||||
# run + 4h grace before we page).
|
||||
#
|
||||
# Check-status alerts fire on the first non-zero exit from
|
||||
# `restic check --read-data-subset=5%`.
|
||||
#
|
||||
# Repo-size alerts fire when a repository exceeds 20 GiB — the
|
||||
# baseline is expected < 5 GiB per repo. The alert catches
|
||||
# retention/prune bugs and runaway growth. The metric is emitted
|
||||
# by the same CronJob step (see backup-*-restic-cronjob.yaml,
|
||||
# `restic stats --json --mode raw-data`).
|
||||
#
|
||||
# The metrics are written to the node's textfile-collector directory
|
||||
# (`/var/lib/node_exporter/textfile_collector`) and scraped by the
|
||||
# kube-prometheus-stack node-exporter DaemonSet (DEV-494).
|
||||
#
|
||||
# Because a CronJob may run on a different worker across days (loki
|
||||
# backup follows the loki pod; k8s-resources is unpinned), stale
|
||||
# `.prom` files can linger on nodes the job has since left. That
|
||||
# would leave a per-node series with an old timestamp/check-status
|
||||
# indefinitely. The alerts below aggregate across instances so a
|
||||
# single fresh sample from the node where the job currently runs is
|
||||
# enough to keep the freshness alert quiet, and check/size alerts
|
||||
# fire when *any* node reports a bad value (which is the correct
|
||||
# behaviour — a recent failure is still a signal).
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: backup-restic-alerts
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app: backup
|
||||
backend: restic
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
groups:
|
||||
- name: backup-restic.freshness
|
||||
interval: 60s
|
||||
rules:
|
||||
- alert: BackupLokiStale
|
||||
# `max()` collapses per-node samples so a stale `.prom` file
|
||||
# left behind on a node the loki backup no longer runs on
|
||||
# does not fire this alert; the freshest sample wins.
|
||||
expr: time() - max(backup_loki_timestamp_seconds) > 28 * 3600
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: loki
|
||||
annotations:
|
||||
summary: "Loki restic backup is stale (>28h)"
|
||||
description: |
|
||||
`backup-loki-restic` has not written a fresh
|
||||
`backup_loki_timestamp_seconds` sample in more than
|
||||
28 hours. Expected daily at 03:00 UTC.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
- alert: BackupGrafanaStale
|
||||
expr: time() - max(backup_grafana_timestamp_seconds) > 28 * 3600
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: grafana
|
||||
annotations:
|
||||
summary: "Grafana restic backup is stale (>28h)"
|
||||
description: |
|
||||
`backup-grafana-restic` has not written a fresh
|
||||
`backup_grafana_timestamp_seconds` sample in more
|
||||
than 28 hours. Expected daily at 03:15 UTC.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
- alert: BackupK8sResourcesStale
|
||||
expr: time() - max(backup_k8s_resources_timestamp_seconds) > 28 * 3600
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: k8s-resources
|
||||
annotations:
|
||||
summary: "K8s-resources restic backup is stale (>28h)"
|
||||
description: |
|
||||
`backup-k8s-resources` has not written a fresh
|
||||
`backup_k8s_resources_timestamp_seconds` sample in
|
||||
more than 28 hours. Expected daily at 02:00 UTC.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
- alert: BackupPrometheusStale
|
||||
expr: time() - max(backup_prometheus_timestamp_seconds) > 28 * 3600
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: prometheus
|
||||
annotations:
|
||||
summary: "Prometheus restic backup is stale (>28h)"
|
||||
description: |
|
||||
`prometheus-backup` has not written a fresh
|
||||
`backup_prometheus_timestamp_seconds` sample in more
|
||||
than 28 hours. Expected daily at 03:30 UTC.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
- alert: BackupForgejoStale
|
||||
expr: time() - max(backup_forgejo_timestamp_seconds) > 28 * 3600
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: forgejo
|
||||
annotations:
|
||||
summary: "Forgejo restic backup is stale (>28h)"
|
||||
description: |
|
||||
`forgejo-backup` has not written a fresh
|
||||
`backup_forgejo_timestamp_seconds` sample in more
|
||||
than 28 hours. Expected daily at 03:00 UTC.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
- name: backup-restic.integrity
|
||||
interval: 60s
|
||||
rules:
|
||||
- alert: BackupLokiCheckFailed
|
||||
expr: backup_loki_check_status != 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: loki
|
||||
annotations:
|
||||
summary: "restic check failed on loki repo"
|
||||
description: |
|
||||
`restic check --read-data-subset=5%` returned
|
||||
exit code {{ $value }} on the loki repository.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
- alert: BackupGrafanaCheckFailed
|
||||
expr: backup_grafana_check_status != 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: grafana
|
||||
annotations:
|
||||
summary: "restic check failed on grafana repo"
|
||||
description: |
|
||||
`restic check --read-data-subset=5%` returned
|
||||
exit code {{ $value }} on the grafana repository.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
- alert: BackupK8sResourcesCheckFailed
|
||||
expr: backup_k8s_resources_check_status != 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: k8s-resources
|
||||
annotations:
|
||||
summary: "restic check failed on k8s-resources repo"
|
||||
description: |
|
||||
`restic check --read-data-subset=5%` returned
|
||||
exit code {{ $value }} on the k8s-resources
|
||||
repository.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
- alert: BackupPrometheusCheckFailed
|
||||
expr: backup_prometheus_check_status != 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: prometheus
|
||||
annotations:
|
||||
summary: "restic check failed on prometheus repo"
|
||||
description: |
|
||||
`restic check --read-data-subset=5%` returned
|
||||
exit code {{ $value }} on the prometheus
|
||||
repository.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
- alert: BackupForgejoCheckFailed
|
||||
expr: backup_forgejo_check_status != 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
repo: forgejo
|
||||
annotations:
|
||||
summary: "restic check failed on forgejo repo"
|
||||
description: |
|
||||
`restic check --read-data-subset=5%` returned
|
||||
exit code {{ $value }} on the forgejo
|
||||
repository.
|
||||
Runbook: docs/monitoring/restic-restore.md
|
||||
- name: backup-restic.size
|
||||
interval: 60s
|
||||
rules:
|
||||
- alert: ResticRepoOversize
|
||||
# 20 GiB = 20 * 1024^3 = 21474836480 bytes.
|
||||
expr: restic_repo_size_bytes > 21474836480
|
||||
for: 30m
|
||||
labels:
|
||||
severity: warning
|
||||
service: monitoring
|
||||
component: backup
|
||||
annotations:
|
||||
summary: "restic repository {{ $labels.repo }} exceeds 20 GiB"
|
||||
description: |
|
||||
Repository {{ $labels.repo }} is
|
||||
{{ $value | humanize1024 }}B, above the 20 GiB
|
||||
guard. Baseline is <5 GiB per repo. Investigate
|
||||
retention/prune (see forget flags in the CronJob)
|
||||
and dedup effectiveness.
|
||||
52
apps/monitoring/loki-deployment.yaml
Normal file
52
apps/monitoring/loki-deployment.yaml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: loki
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app: loki
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: loki
|
||||
# loki-storage-encrypted is RWO on hcloud-volumes-encrypted, so a rolling
|
||||
# update deadlocks (new pod cannot attach the PVC while the old pod holds
|
||||
# it). Recreate drops the old pod first so the CSI detaches the volume
|
||||
# before the new pod tries to attach it. Same pattern as harbor's RWO fix.
|
||||
strategy:
|
||||
type: Recreate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: loki
|
||||
spec:
|
||||
containers:
|
||||
- name: loki
|
||||
image: grafana/loki:2.9.2
|
||||
args:
|
||||
- -config.file=/etc/loki/loki.yaml
|
||||
ports:
|
||||
- containerPort: 3100
|
||||
name: http
|
||||
- containerPort: 9096
|
||||
name: grpc
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
volumeMounts:
|
||||
- name: loki-config
|
||||
mountPath: /etc/loki
|
||||
- name: loki-storage
|
||||
mountPath: /loki
|
||||
volumes:
|
||||
- name: loki-config
|
||||
configMap:
|
||||
name: loki-config
|
||||
- name: loki-storage
|
||||
persistentVolumeClaim:
|
||||
claimName: loki-storage-encrypted
|
||||
228
apps/monitoring/prometheus-backup-cronjob.yaml
Normal file
228
apps/monitoring/prometheus-backup-cronjob.yaml
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
---
|
||||
# Prometheus data backup via restic to Hetzner Object Storage
|
||||
# (DEV-492, DEV-482 Option 4). Replaces the DEV-465 rclone-sync job so
|
||||
# every monitoring backup ships client-side-encrypted; Hetzner Object
|
||||
# Storage has no SSE-S3/SSE-KMS, so the previous plaintext-at-rest
|
||||
# object layout was the only remaining gap.
|
||||
#
|
||||
# Streams the RWO PVC `prometheus-data-encrypted` (mounted read-only)
|
||||
# into `s3:${S3_ENDPOINT}/${S3_BUCKET}/restic/prometheus`, a client-side
|
||||
# encrypted restic repository (tag=`prometheus`, host=`k3s`).
|
||||
#
|
||||
# Node scheduling matches the previous job: podAffinity co-schedules
|
||||
# with the Prometheus pod (app=prometheus, topology
|
||||
# kubernetes.io/hostname). Hetzner CSI RWO permits additional read-only
|
||||
# mounts on the node that holds the PVC's VolumeAttachment, so this
|
||||
# survives Prometheus being rescheduled to a different worker.
|
||||
#
|
||||
# Prometheus TSDB compaction race
|
||||
# --------------------------------
|
||||
# Prometheus rewrites the on-disk store roughly every 2 h: it creates a
|
||||
# new block dir, then deletes the source dirs. restic walks the source
|
||||
# tree once and may catch a file that disappeared mid-walk; restic
|
||||
# 0.17.3 exits 3 ("at least one source file could not be read") in
|
||||
# that case, and the snapshot excludes only the missing file. The
|
||||
# next daily run picks up the successor block, so the race is not a
|
||||
# data-loss risk — but we must not treat exit 3 as a hard failure, or
|
||||
# the daily job will alert-flap.
|
||||
#
|
||||
# Mitigation:
|
||||
# - exclude `wal/*` (WAL is replayed from a fresh instance on
|
||||
# restart; we accept losing the last ~15 s of ingested samples
|
||||
# rather than snapshotting a moving segment)
|
||||
# - exclude `chunks_head/*` (in-memory head block; ephemeral, would
|
||||
# be rebuilt from WAL which we do not keep)
|
||||
# - exclude Prometheus lock/scratch files (`lock`, `queries.active`,
|
||||
# `*.tmp`, `lost+found/*`)
|
||||
# - treat restic exit code 3 as a soft warning (log, continue);
|
||||
# any other non-zero exit is still fatal
|
||||
# - restore drill re-runs `promtool tsdb analyze` against every
|
||||
# block so a corrupted snapshot is caught end-to-end
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: prometheus-backup
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app: backup
|
||||
type: prometheus
|
||||
backend: restic
|
||||
spec:
|
||||
schedule: "30 3 * * *" # daily 03:30, offset from loki/grafana/k8s-resources
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
jobTemplate:
|
||||
metadata:
|
||||
labels:
|
||||
app: backup
|
||||
type: prometheus
|
||||
backend: restic
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
activeDeadlineSeconds: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: backup
|
||||
type: prometheus
|
||||
backend: restic
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
affinity:
|
||||
podAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchExpressions:
|
||||
- key: app
|
||||
operator: In
|
||||
values:
|
||||
- prometheus
|
||||
topologyKey: kubernetes.io/hostname
|
||||
containers:
|
||||
- name: restic
|
||||
image: harbor.basicstack.de/library/restic:0.19.1
|
||||
env:
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: access-key
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: secret-key
|
||||
- name: RESTIC_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: restic-password
|
||||
- name: S3_ENDPOINT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: endpoint
|
||||
- name: S3_BUCKET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: monitoring-s3-backup
|
||||
key: bucket
|
||||
- name: RESTIC_REPOSITORY
|
||||
value: "s3:$(S3_ENDPOINT)/$(S3_BUCKET)/restic/prometheus"
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
echo "=== backup-prometheus-restic started at $(date -u +%FT%TZ) ==="
|
||||
echo "Repository: ${RESTIC_REPOSITORY}"
|
||||
|
||||
# First-run tolerance: init if the repo isn't there yet.
|
||||
if restic snapshots >/dev/null 2>&1; then
|
||||
echo "Repo exists, skipping init."
|
||||
else
|
||||
echo "Repo missing, initialising..."
|
||||
restic init
|
||||
fi
|
||||
|
||||
# See "Prometheus TSDB compaction race" in the file
|
||||
# header. WAL + chunks_head are excluded on purpose;
|
||||
# exit code 3 (source file vanished mid-walk during a
|
||||
# compaction) is accepted and reported, any other
|
||||
# non-zero exit is fatal.
|
||||
echo "--- restic backup /source (exclude wal/chunks_head + lock files) ---"
|
||||
BACKUP_STATUS=0
|
||||
restic backup /source \
|
||||
--tag prometheus \
|
||||
--host k3s \
|
||||
--exclude 'wal/*' \
|
||||
--exclude 'chunks_head/*' \
|
||||
--exclude 'lock' \
|
||||
--exclude 'queries.active' \
|
||||
--exclude 'lost+found/*' \
|
||||
--exclude '*.tmp' || BACKUP_STATUS=$?
|
||||
echo "restic backup exit: ${BACKUP_STATUS}"
|
||||
if [ "${BACKUP_STATUS}" -eq 0 ]; then
|
||||
echo "backup: all files captured cleanly"
|
||||
elif [ "${BACKUP_STATUS}" -eq 3 ]; then
|
||||
echo "backup: exit 3 (source files vanished mid-walk) — expected under Prometheus compaction, continuing"
|
||||
else
|
||||
echo "backup: FATAL exit ${BACKUP_STATUS} (not compaction-race)"
|
||||
exit ${BACKUP_STATUS}
|
||||
fi
|
||||
|
||||
echo "--- restic forget/prune ---"
|
||||
restic forget --tag prometheus \
|
||||
--keep-daily 7 \
|
||||
--keep-weekly 4 \
|
||||
--keep-monthly 6 \
|
||||
--prune
|
||||
|
||||
echo "--- restic check --read-data-subset=5% ---"
|
||||
CHECK_STATUS=0
|
||||
restic check --read-data-subset=5% || CHECK_STATUS=$?
|
||||
echo "restic check exit: ${CHECK_STATUS}"
|
||||
|
||||
echo "--- restic stats (repo size) ---"
|
||||
REPO_SIZE_BYTES=$(restic stats --json --mode raw-data 2>/dev/null \
|
||||
| grep -oE '"total_size":[0-9]+' \
|
||||
| head -1 \
|
||||
| cut -d: -f2)
|
||||
REPO_SIZE_BYTES=${REPO_SIZE_BYTES:-0}
|
||||
echo "restic repo size: ${REPO_SIZE_BYTES} bytes"
|
||||
|
||||
# Textfile-collector metrics. Same hostPath pattern as
|
||||
# the loki/grafana/k8s-resources siblings (DEV-494) —
|
||||
# written atomically via `.tmp` + rename so a mid-write
|
||||
# read never surfaces a truncated sample.
|
||||
# backup_prometheus_success rolls in both stages: the
|
||||
# backup step (accepting exit 3) and restic check.
|
||||
BACKUP_OK=0
|
||||
if [ "${BACKUP_STATUS}" -eq 0 ] || [ "${BACKUP_STATUS}" -eq 3 ]; then
|
||||
BACKUP_OK=1
|
||||
fi
|
||||
SUCCESS=0
|
||||
if [ "${BACKUP_OK}" -eq 1 ] && [ "${CHECK_STATUS}" -eq 0 ]; then
|
||||
SUCCESS=1
|
||||
fi
|
||||
{
|
||||
echo "backup_prometheus_success ${SUCCESS}"
|
||||
echo "backup_prometheus_timestamp_seconds $(date +%s)"
|
||||
echo "backup_prometheus_check_status ${CHECK_STATUS}"
|
||||
echo "backup_prometheus_backup_status ${BACKUP_STATUS}"
|
||||
echo "restic_repo_size_bytes{repo=\"prometheus\"} ${REPO_SIZE_BYTES}"
|
||||
} > /metrics/backup_prometheus.prom.tmp
|
||||
mv /metrics/backup_prometheus.prom.tmp /metrics/backup_prometheus.prom
|
||||
|
||||
echo "=== backup-prometheus-restic finished at $(date -u +%FT%TZ) ==="
|
||||
exit ${CHECK_STATUS}
|
||||
volumeMounts:
|
||||
- name: prometheus-data
|
||||
mountPath: /source
|
||||
readOnly: true
|
||||
- name: metrics
|
||||
mountPath: /metrics
|
||||
- name: cache
|
||||
mountPath: /root/.cache/restic
|
||||
resources:
|
||||
# Prometheus TSDB is ~8-10 GiB; give restic room to
|
||||
# burst during pack/check but keep steady-state small.
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 1500m
|
||||
memory: 1Gi
|
||||
volumes:
|
||||
- name: prometheus-data
|
||||
persistentVolumeClaim:
|
||||
claimName: prometheus-data-encrypted
|
||||
- name: metrics
|
||||
hostPath:
|
||||
# node-exporter's textfile-collector directory
|
||||
# (DEV-494). See sibling loki cronjob for detail.
|
||||
path: /var/lib/node_exporter/textfile_collector
|
||||
type: DirectoryOrCreate
|
||||
- name: cache
|
||||
emptyDir: {}
|
||||
20
apps/monitoring/prometheus-backup-sealed.yaml
Normal file
20
apps/monitoring/prometheus-backup-sealed.yaml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
apiVersion: bitnami.com/v1alpha1
|
||||
kind: SealedSecret
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: monitoring-s3-backup
|
||||
namespace: monitoring
|
||||
spec:
|
||||
encryptedData:
|
||||
access-key: AgBf1XxCF3/Uv1vVlJHyTSEMONqWBqPfSOax8jJJHy3NOpgGga3SsxDfAbPXe2rBOYSgGzRZcaT8TcNg8Ng1ipv5Dhu7LzmRb6v6fW0NimrHSU/dtzzJSIR7jQ1sf+Qn4CPS73ReDRvUJ7g4o57ClZIVFBk9oPUDJYdjtg9kAM5Me0ZyqVp4Cnyj2TRXYcQPM+RN1r/PXqK4MhlUsmhsbQCPYwocM0liqv0T8L5xR1g2845oMaMx2nmLs1c1R8nCRugK8IeBKf/CCKLYg8yTqWKS2arrfxm+dkl1mrqLdOgnXrqeeo/63lT1sbd06ub86PJr83MXWW0yh1Uc4r7DX7xK/JIGJ4w+jtQa0vRutYANDcdeVlzjVl+ghoxxw/ZizYVQ/mUFCc+ApJD9u709mI39xof77U5yYBRhnZEExRseL756APubmCc0tPfUSNA1GmMhiYaMnyWu6F5Okreto/03AgI3t7lQm9SPRRM7hcHETG/QC8PrZ0NZEN/A2Nm/ZzwDG4HPDaNxH+IV90f5X4wuh6Mh2lffidOix88rkaizQAeVY5gt8tvfSfyfRc5RoOOov+fvUVCqJHnWaD1A2lKScVeCgLv8/0CJTyR0zM6cg2XN8z0HvU/sahfZrG23fVs3E/IFM5D7//CDp+GfcwQOCBZKPLvJtTRh5nA5EbXQusGEaMOTBiiGG0IJKPejKcPRWS1g+owu+G5R6QQjZgNDoP0cPg==
|
||||
bucket: AgCYDCrQalif3+XZYhe4Xxyypg2OMGvzdNyBmHzMaP+ou1g2Q0wIjH6S1+YpPu+JgmaATZVISoSZ+OIl0mNOibiFMr0kFjuMhvKRt9Pq05+6axnqnJ17U2GNsZQtY3Ic93qxkkhJKCOQ87mvAJ5AIVBc3pAqfkD5YYTllgnSKwUwyaWK3iX3hxuKLYDvsQ2WTtcc/gsnlCkm1dwPE4E7DFt4wh+jQJRD314Mt7KSpvPBpz7yVVFFX+xRKCfRxjcwshUSUI5OgagfOQvsivkxlL0oSqA9veX2MJpd0SJH0T1Y0na0evkJitxxe+ECwM4FluK/iwW4cdUu+EpQIdd5otY9C5b8e8PJp8ah1k1hYzXdX+j975MeyXWMUn/NgXfyT2CUef6hmmmImialrSYsHT72bz3xpAnZ/oQ/0qcZpNje2oJeokNgkoiC0E5XRnfwEF9jL3XL+aB6gQ3BD9YRptaxLLxOQNj51Oe82vVsOnUz+utzJO1DtqpF+8I0lvHfgcUhFQytUuLscilfBQO/ru8wh47ZI+LRiS3qCZp2Pqi9+r7Y7jJKcbb2awWp6lgrrqgLMvnbVMuvxBi2hUy0C+EkP8tayRJDzz72utx8FY/v0FWoRDl9BAi8kn11+kbbo8a2OXe+20UkmNzunz5H0ijIxhJWzVh/oLuuLFCS0YJtJNjuqUB0VctAFcR4p5Agua8LA9+73HIBsHBYexzmvEZ6Ng==
|
||||
endpoint: AgCV6YtzikWYMRwHZM1tT8epq6XiSVqhbxMhS7htVjVUfdR4V7/KIcNExxSBul+TU+RCq3S7fppL7CxKbI/IsEdirRMj2PvESXEmzLY/xId7+wJFEdxeXxn59cbV2MfCyysKIwzsVXbmzO66yGaeMKU2oyqJVYu+00L9xS0RbtQRbi0UksMxtw0PhZVMee0aHfu9jynWI3lmPzC/EPRuwxQqBIjYM2v2JCDmYjP0fsBSZSJc+SnAaNmmIf7ypcFwy14qIF7C9tIg02lBx+Sg30oMPjn600AbyF6RmZ+mdclp/Qp+zXOUsbMym0Lu9BjbM7G/z0ExpR5sgE/6+05gJhPF7MuHkrYIMo9LHWWdgzNTnjikSTGEeuFzzRlThPMoUfVi/S7KhG9NtQRnNqEoVCGGW6tt5sygXVE/lWSnmmd//4jXvF9ZJHU+5LrjL7UfVIYNH4y7aYtXpoTgmUe85wWqeMdqr1EiSVueoSwCggGITYoXoKYfERfij8ln/xnX1Kt2xxuyLtpDOczBYZK6LLwTpaXVVe+lO/xBgevWaoEz6si2hyj+glluMQWxPsOnPwUfRi5qqlQxgMZK/9aALYhPLquc16iMG+4FdtoOUfQ0XlLvurHsbCt9lUy3YrsmpDhv42FfamVlcK+gMz7mqYhNhd3fVjxCiUuwvzd01QHcx8ikv48YOnDuRiweRszbPNvlaK2KH6fo7b4l9mmaa4W+MaxZCxG3KSMOafwRmyusEUVNWw==
|
||||
secret-key: AgBItdfzRgcgJvK5HALDnkbGH1DveAgcmS+Q4f16VwX1KY8TzGWpjoaOdsLCwzTWOFME5xjrRyOwwAcdtP65ccWlEZvvoS4kpqv49G1ebblD1wmApnpa8vD63Rd028vUYFF3CnwntX2USxF6t4rD68sZqqzMDyuG9Av93HAKVMNdx9V4AJ45nvfF2WR7ohk37NhQifOdKuelgRV6ahl3/fm/XPRGzf+u7OER8jll9n/ywE0mSkoKBXocbkQ2Xrn+utzplh9BoR9KSPTdnmI8ZXUkDTR9p+7PN4r/HheEdTUG0aaefdEDCbdDlv1EW84l5KzhY4v9g0DW8GQQdyHWi3sMm0hSGFg9NV/Vs1HM9FZZCyjS+mzR4CbuHgkY9mvj+Svn7oS+Fy5d24s1ekmPheakBffXKy2Q2CLMRGNymjUIBQsDwoGC8o4IF+yPyDcw/OvXlr0lruxfRM6IN0oxgRmLAQiqwRzsZb3RPgILEZI9VXKxzCBEuCO7fE2pC05K/mujhUutWmJ5tQ0NFzP1mhNK59cAIVMl7y7Llq5/GZ5ax+voP0c3VandXJeaSRr6O40vyzfCMgpgEgRs9t2WfxCmVYEN2NWpFv07JNICWzJX40PLT9n/euRXzSmJld6UfjzRteaTfmo+OxFczJ6Zl8XdC+6x6wO0Nk0hVYo27s2LH4o0MsM84x9Z3StCCGl77/NnyavpxfDkj3qJQ4BgPtCw26tHTt9DIWYfgeAVqvoRZVlg8YxM3N1k
|
||||
restic-password: AgBuoBFK5Y2Vlf1Vj6Q7y5OwWV2iOLMATe+Kjca8uwHgiYnpka+619ab52P1P+tkxbGyGdnwF+3q0KzvJJaNfuoduwlPuRjiYmaLLoCuQahZmTLNbzwEFst1cbm4oG6vCTBGsK9IMngT6VMfmvz029X+jsGaJHaECHlJjWnMteKN2pwxbzt4/3Wx6wP3//qAk1ah4QLRZUEWvn6O7djTFWJtzM4MRfZPI6EmM9emlQRN9iHwWSUmgBZ5zvaAylRHHUHK1lkNgWJJr7+tC4D8DbZ+c4+FO6XefXDheG6jO2Ial09KTISC0+v86xKFq/aSko5TKczpdXEdYNfyf/aPJTRd4LTcTySUdIpflmjIUnJBdn2Yn4Jox8LKc6ecUJ+IxMXhWlToFNtSFJwx744uv9WsrQ7p/lH7VTZOT+R+0wKTe9vn07h6B78xX6JJWKHMepb9aJB0gB1fn9cx1KHzm4/Y30SFwuOpYpVx19lyl1BRfSry4kmPuDKouSO6D415NJ/lMNYqTvafRho5SzlFfle/n7wAK7OIRdNbgsabO2mfHjHpw6w8Bvo9K7+3QJiwpeesY8OKAubcpxy1RBDw3fp2JQCNNqIer41vUtprsRU+tBnzDboImhOTXpP71AgTa+L68k/iGVJB0YqjFDxjcJB1f1hKGvH+yCV0GPFSeUFOZ/gmxV+Q0Fmoq/ezLrqpjoTbjp+ZbAi6qbVrMN/3t3lheKf47dU5HEOQMyZkXqDrkyuc7kXhmzN/V/ISmW0=
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: monitoring-s3-backup
|
||||
namespace: monitoring
|
||||
type: Opaque
|
||||
38
apps/observability/patches/README.md
Normal file
38
apps/observability/patches/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# observability patches
|
||||
|
||||
Strategic-merge patches applied on top of Helm-managed observability
|
||||
resources. Each file is idempotent (re-applying is a no-op) and is
|
||||
reasserted by hand rather than by a controller, so re-run after any
|
||||
`helm upgrade` of the affected release.
|
||||
|
||||
## `node-exporter-textfile-collector.yaml` (DEV-494)
|
||||
|
||||
Enables the node-exporter textfile collector on the
|
||||
`kube-prometheus-stack-prometheus-node-exporter` DaemonSet by:
|
||||
|
||||
1. adding `--collector.textfile.directory=/host/textfile_collector`
|
||||
to the container args, and
|
||||
2. mounting the host directory `/var/lib/node_exporter/textfile_collector`
|
||||
read-only at `/host/textfile_collector` (`hostPath` type
|
||||
`DirectoryOrCreate`, kubelet creates it on nodes where the
|
||||
directory does not exist yet).
|
||||
|
||||
The four monitoring backup CronJobs in `apps/monitoring/` write their
|
||||
textfile-collector `.prom` files into that same host directory, so the
|
||||
metrics surface in Prometheus via node-exporter's normal scrape.
|
||||
|
||||
Apply / re-apply:
|
||||
|
||||
```bash
|
||||
kubectl -n observability patch daemonset \
|
||||
kube-prometheus-stack-prometheus-node-exporter \
|
||||
--type=strategic \
|
||||
--patch-file=apps/observability/patches/node-exporter-textfile-collector.yaml
|
||||
kubectl -n observability rollout status daemonset \
|
||||
kube-prometheus-stack-prometheus-node-exporter
|
||||
```
|
||||
|
||||
The kube-prometheus-stack chart is not currently tracked in ArgoCD;
|
||||
if it moves under GitOps, fold these values into the chart values as
|
||||
`prometheus-node-exporter.extraArgs` + `.extraHostVolumeMounts`
|
||||
instead of maintaining this patch.
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
---
|
||||
# Strategic-merge patch enabling node-exporter's textfile collector on
|
||||
# the kube-prometheus-stack node-exporter DaemonSet (DEV-494).
|
||||
#
|
||||
# The chart is Helm-managed (release `kube-prometheus-stack` in
|
||||
# namespace `observability`, chart kube-prometheus-stack-86.2.2 /
|
||||
# prometheus-node-exporter-4.55.0) and is NOT currently tracked in
|
||||
# ArgoCD, so a direct DaemonSet patch is the pragmatic wiring path.
|
||||
# The change:
|
||||
#
|
||||
# 1. adds the `--collector.textfile.directory=/host/textfile_collector`
|
||||
# arg to the node-exporter container, and
|
||||
# 2. mounts the host directory `/var/lib/node_exporter/textfile_collector`
|
||||
# read-only at `/host/textfile_collector` (type DirectoryOrCreate so
|
||||
# kubelet creates the dir on nodes where it does not yet exist).
|
||||
#
|
||||
# Apply with:
|
||||
# kubectl -n observability patch daemonset \
|
||||
# kube-prometheus-stack-prometheus-node-exporter \
|
||||
# --type=strategic \
|
||||
# --patch-file=apps/observability/patches/node-exporter-textfile-collector.yaml
|
||||
#
|
||||
# If the Helm release is ever `helm upgrade`d without folding these
|
||||
# values into the chart values, this patch will be reverted — re-apply
|
||||
# it after the upgrade (or move it into a repo-owned values file).
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: node-exporter
|
||||
args:
|
||||
- --path.procfs=/host/proc
|
||||
- --path.sysfs=/host/sys
|
||||
- --path.rootfs=/host/root
|
||||
- --path.udev.data=/host/root/run/udev/data
|
||||
- --web.listen-address=[$(HOST_IP)]:9100
|
||||
- --collector.filesystem.mount-points-exclude=^/(dev|proc|sys|run/containerd/.+|var/lib/docker/.+|var/lib/kubelet/.+)($|/)
|
||||
- --collector.filesystem.fs-types-exclude=^(autofs|binfmt_misc|bpf|cgroup2?|configfs|debugfs|devpts|devtmpfs|fusectl|hugetlbfs|iso9660|mqueue|nsfs|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|selinuxfs|squashfs|sysfs|tracefs|erofs)$
|
||||
- --collector.textfile.directory=/host/textfile_collector
|
||||
volumeMounts:
|
||||
- mountPath: /host/proc
|
||||
name: proc
|
||||
readOnly: true
|
||||
- mountPath: /host/sys
|
||||
name: sys
|
||||
readOnly: true
|
||||
- mountPath: /host/root
|
||||
mountPropagation: HostToContainer
|
||||
name: root
|
||||
readOnly: true
|
||||
- mountPath: /host/textfile_collector
|
||||
name: textfile-collector
|
||||
readOnly: true
|
||||
volumes:
|
||||
- hostPath:
|
||||
path: /proc
|
||||
type: ""
|
||||
name: proc
|
||||
- hostPath:
|
||||
path: /sys
|
||||
type: ""
|
||||
name: sys
|
||||
- hostPath:
|
||||
path: /
|
||||
type: ""
|
||||
name: root
|
||||
- hostPath:
|
||||
path: /var/lib/node_exporter/textfile_collector
|
||||
type: DirectoryOrCreate
|
||||
name: textfile-collector
|
||||
|
|
@ -42,6 +42,8 @@ metadata:
|
|||
namespace: opencloud
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: opencloud
|
||||
|
|
@ -56,7 +58,7 @@ spec:
|
|||
runAsNonRoot: true
|
||||
initContainers:
|
||||
- name: init-dirs
|
||||
image: opencloudeu/opencloud-rolling:7.2.0
|
||||
image: opencloudeu/opencloud-rolling:7.4.0
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
|
@ -71,7 +73,7 @@ spec:
|
|||
runAsNonRoot: true
|
||||
containers:
|
||||
- name: opencloud
|
||||
image: opencloudeu/opencloud-rolling:7.2.0
|
||||
image: opencloudeu/opencloud-rolling:7.4.0
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
|
|
|
|||
240
apps/pangolin/README.md
Normal file
240
apps/pangolin/README.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# Pangolin
|
||||
|
||||
Pangolin is BasicStack's self-hosted reverse proxy and SSO gateway. It sits in front of
|
||||
selected cluster services (e.g. `paperclip.basicstack.de`) and forces every request through
|
||||
Pocket-ID before it reaches the backend. Pangolin does not terminate TLS or handle the
|
||||
raw HTTP request path itself — it publishes a Traefik dynamic config, and the
|
||||
`pangolin-kube-controller` translates that config into Traefik CRDs that our existing
|
||||
Traefik ingress controller enforces.
|
||||
|
||||
Public URL: <https://pangolin.basicstack.de> — App source: <https://github.com/fosrl/pangolin>
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser ──HTTPS──► Traefik (ns=kube-system)
|
||||
│
|
||||
│ IngressRoute + Middleware (badger) + TraefikService
|
||||
│ ▲ managed by pangolin-kube-controller (ns=pangolin)
|
||||
│ │
|
||||
│ │ polls /api/v1/traefik-config every ~15s
|
||||
│ │
|
||||
▼ │
|
||||
Pangolin API (ns=pangolin)
|
||||
├─ port 3000 external dashboard API (session auth, behind Ingress)
|
||||
├─ port 3001 internal traefik-config API (Bearer auth, cluster-internal only)
|
||||
└─ port 3002 UI HTTP (behind Ingress)
|
||||
│
|
||||
├── PostgreSQL (ns=pangolin, pangolin-postgres)
|
||||
└── OIDC provider: Pocket-ID at https://auth.basicstack.de
|
||||
|
||||
Auth flow for a protected resource (e.g. paperclip.basicstack.de):
|
||||
Browser ──► Traefik ──► badger middleware ──callback──► Pangolin API (3001)
|
||||
│
|
||||
▼
|
||||
Pocket-ID / session
|
||||
```
|
||||
|
||||
Two long-running workloads live in the `pangolin` namespace:
|
||||
|
||||
- **Pangolin** — the app itself: dashboard, resource/target CRUD, OIDC handshake,
|
||||
session cookies, and the internal `/api/v1/traefik-config` endpoint that
|
||||
publishes the current desired Traefik state.
|
||||
- **pangolin-kube-controller** — a Kubernetes controller (leader-elected) that
|
||||
polls the Pangolin API, then upserts Traefik `IngressRoute`, `Middleware`,
|
||||
`TraefikService`, etc. via server-side apply into the `pangolin` namespace.
|
||||
|
||||
The controller only writes CRDs into `pangolin`; the backing Service typically
|
||||
lives in the target app's own namespace. Traefik is started with
|
||||
`--providers.kubernetescrd.allowCrossNamespace=true` so those cross-namespace
|
||||
refs resolve.
|
||||
|
||||
## Components (files in this directory)
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `namespace.yaml` | Creates the `pangolin` namespace. |
|
||||
| `pangolin-config.yaml` | Pangolin `config.yml` template (ConfigMap). `__DATABASE_URL__` is rendered at startup by the `render-config` init container from the sealed secret. |
|
||||
| `pangolin-deployment.yaml` | Pangolin app Deployment, Service (ports 3000/3001/3002), Ingress, and the `pangolin-data` PVC. |
|
||||
| `pangolin-secrets-sealed.yaml` | Sealed OIDC client id/secret and local admin password. |
|
||||
| `pangolin-postgres-secrets-sealed.yaml` | Sealed Postgres credentials plus the rendered `DATABASE_URL`. |
|
||||
| `postgres.yaml` | Single-replica Postgres 17.5 + `pangolin-postgres-data` PVC. |
|
||||
| `pangolin-controller-config.yaml` | `pangolin-kube-controller` env: `CONFIG_ENDPOINT`, target namespace, log level. |
|
||||
| `pangolin-controller-deployment.yaml` | Controller Deployment (image `fosrl/pangolin-kube-controller:0.1.0-alpha.1`). |
|
||||
| `pangolin-controller-rbac.yaml` | ServiceAccount + ClusterRole/Binding: CRUD on Traefik CRDs and leases. |
|
||||
| `pangolin-controller-service.yaml` | ClusterIP for controller metrics. |
|
||||
| `pangolin-controller-api-key-sealed.yaml` | Sealed `Authorization` header the controller sends to the internal API. |
|
||||
|
||||
ArgoCD Application: [`apps/app-pangolin.yaml`](../app-pangolin.yaml) — sync policy is
|
||||
`automated: { prune: true, selfHeal: true }`. Any local edit must land in git.
|
||||
|
||||
## SSO integration (Pocket-ID)
|
||||
|
||||
Pangolin authenticates users against Pocket-ID via OIDC. Two independent SSO
|
||||
flows exist and should not be confused:
|
||||
|
||||
1. **Logging into the Pangolin dashboard.** Configured in
|
||||
`pangolin-config.yaml` (`oidc.*`) and via env in `pangolin-deployment.yaml`:
|
||||
- Issuer: `https://auth.basicstack.de`
|
||||
- Callback URL: `https://pangolin.basicstack.de/auth/callback`
|
||||
- Scopes: `openid profile email groups`
|
||||
- Client id/secret: `pangolin-secrets` sealed secret keys
|
||||
`oidc-client-id` / `oidc-client-secret`.
|
||||
2. **Protecting a downstream resource (e.g. paperclip.basicstack.de).**
|
||||
Handled by the badger Traefik middleware that the controller emits.
|
||||
The middleware calls back into Pangolin's internal API (port 3001) to
|
||||
check the session cookie, and if unauthenticated redirects the browser
|
||||
through Pangolin's own OIDC flow. There is nothing to configure per-resource
|
||||
in Kubernetes — the resource's auth policy is set inside the Pangolin UI.
|
||||
|
||||
The Pocket-ID client is provisioned separately (see [pocket-id docs](https://pocket-id.org/docs/client-examples/pangolin)).
|
||||
Do not reconfigure the OIDC client from the Pangolin side once it is working.
|
||||
|
||||
## Ingress protection workflow
|
||||
|
||||
The end-to-end flow for a protected hostname like `paperclip.basicstack.de`:
|
||||
|
||||
1. Operator creates a **Resource** in the Pangolin UI (hostname, backend
|
||||
Service, port, auth policy).
|
||||
2. Pangolin persists it in Postgres and exposes the new desired state at
|
||||
`http://pangolin.pangolin.svc.cluster.local:3001/api/v1/traefik-config`.
|
||||
3. `pangolin-kube-controller` polls that endpoint on a ~15s loop.
|
||||
4. On change, the controller server-side-applies into `ns=pangolin`:
|
||||
- `IngressRoute` for the hostname (with `tls.certResolver: letsencrypt`).
|
||||
- `Middleware/badger` (session check + OIDC redirect).
|
||||
- `TraefikService/1-<resource>-service` pointing at the backend
|
||||
Service in the app's namespace, port from the DB.
|
||||
5. Traefik picks the new CRDs up and starts serving the route.
|
||||
6. First request → redirected to Pocket-ID → session cookie → subsequent
|
||||
requests are proxied to the backend Service.
|
||||
|
||||
## Adding a new protected ingress
|
||||
|
||||
Prereqs the target app must already satisfy:
|
||||
|
||||
- A Kubernetes `Service` in its own namespace, listening on a known port.
|
||||
- A DNS record for the hostname pointing at the cluster ingress IP.
|
||||
- No competing plain `Ingress` in another namespace for the same hostname.
|
||||
If one exists (e.g. a legacy direct ingress), delete it first — Traefik
|
||||
will otherwise route the pre-existing ingress and Pangolin's IngressRoute
|
||||
never wins.
|
||||
|
||||
Steps:
|
||||
|
||||
1. **Log in** to <https://pangolin.basicstack.de> (SSO via Pocket-ID, or
|
||||
local admin from the `pangolin-secrets` sealed secret).
|
||||
2. **Create the Resource.**
|
||||
- Type: HTTP/HTTPS.
|
||||
- Domain: the exact FQDN (e.g. `newapp.basicstack.de`).
|
||||
- Base domain: `basicstack.de`.
|
||||
3. **Target.**
|
||||
- Target type: HTTP.
|
||||
- Backend URL: `http://<service>.<namespace>.svc.cluster.local:<port>`
|
||||
— the port here is written to the `targets.port` column in Pangolin's
|
||||
Postgres and used by the controller when it generates the `TraefikService`.
|
||||
If the Service port later changes, update the target port in the UI (or
|
||||
via SQL: `UPDATE targets SET port=<N> WHERE "targetId"=<id>;`).
|
||||
- Path match: `/`, strip prefix off.
|
||||
4. **TLS.** Enable TLS, force HTTPS redirect. The Pangolin-generated IngressRoute
|
||||
hardcodes `tls.certResolver: letsencrypt`, which is provisioned on our Traefik
|
||||
install (HTTP-01, persistent `/data` PVC). Do not disable that resolver in the
|
||||
Traefik `HelmChartConfig` without also removing every Pangolin-generated route
|
||||
— Traefik will otherwise fail with `nonexistent certificate resolver`.
|
||||
5. **Auth.** Enable authentication, method SSO/OIDC. In Community Edition (what we
|
||||
run) there are no shared policies or group rules — add authorised users
|
||||
individually by email. If you need group-based rules, that's an Enterprise
|
||||
feature.
|
||||
6. **Save** — controller applies CRDs within a poll interval (~15s).
|
||||
7. **Verify.**
|
||||
|
||||
```bash
|
||||
kubectl -n pangolin get ingressroute,middleware,traefikservice \
|
||||
| grep -i <resource-name>
|
||||
kubectl -n pangolin logs -l app=pangolin-controller --tail=50
|
||||
# HTTP smoke test
|
||||
curl -sSI https://<host>/ # expect 302 to Pocket-ID
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **badger middleware returns HTTP 500.** The middleware is trying to call
|
||||
`apiBaseUrl` and either the URL is not an FQDN or Pangolin isn't reachable
|
||||
from `kube-system`. Confirm `pangolin-config.yaml` sets
|
||||
`server.internal_hostname` and `server.badger_override` to
|
||||
`pangolin.pangolin.svc.cluster.local` — the short name `pangolin` does not
|
||||
resolve from Traefik's namespace.
|
||||
- **Controller logs `conflict with "controller" using traefik.io/v1alpha1: .spec…`.**
|
||||
A pre-existing CRD was created with the `Update` verb and the current
|
||||
controller uses server-side `Apply`. Delete the offending
|
||||
`Middleware/TraefikService/IngressRoute` once — the controller recreates it
|
||||
via SSA and the conflict is gone.
|
||||
- **Traefik logs `service port not found: <N>`.** The port stored in Pangolin's
|
||||
`targets` table doesn't match the Kubernetes Service port. Fix in the UI, or:
|
||||
`UPDATE targets SET port=<N> WHERE "targetId"=<id>;` (identifier case matters,
|
||||
double-quote it).
|
||||
- **Traefik logs `nonexistent certificate resolver: letsencrypt`.** The
|
||||
`letsencrypt` resolver isn't loaded in Traefik. Every Pangolin IngressRoute
|
||||
references it — either define the resolver in Traefik (see
|
||||
`infrastructure/networking/traefik-helmchartconfig.yaml`) or override
|
||||
`traefik.cert_resolver` in `pangolin-config.yaml`. Grep the Traefik startup
|
||||
log for `unable to get ACME account`; that appears when the resolver was
|
||||
skipped entirely.
|
||||
- **Route doesn't resolve cross-namespace.** Traefik must run with
|
||||
`--providers.kubernetescrd.allowCrossNamespace=true`. Pangolin's CRDs live in
|
||||
`pangolin`; backend Services usually don't.
|
||||
- **CRDs never appear after saving a Resource.**
|
||||
```bash
|
||||
kubectl -n pangolin get pods -l app=pangolin-controller
|
||||
kubectl -n pangolin logs -l app=pangolin-controller --tail=200
|
||||
kubectl -n pangolin get configmap pangolin-controller-config -o yaml
|
||||
```
|
||||
Common causes: controller CrashLoop, wrong `CONFIG_ENDPOINT`, missing auth
|
||||
header sealed secret, RBAC missing after a controller image upgrade.
|
||||
- **Login loop / redirects to Pocket-ID forever.** Clear cookies for both
|
||||
`pangolin.basicstack.de` and `auth.basicstack.de`, confirm the OIDC callback
|
||||
URL matches the Pocket-ID client, and check Pangolin logs for OIDC errors:
|
||||
`kubectl -n pangolin logs -l app=pangolin`.
|
||||
- **Pangolin pod stuck in `Init:0/2`.** The `wait-for-postgres` init container
|
||||
is looping on `pg_isready`. Check `pangolin-postgres` health and the
|
||||
`pangolin-postgres-secrets` connection details.
|
||||
|
||||
## Critical notes — DO NOT DELETE
|
||||
|
||||
Pangolin holds live user/account/config state. Losing a PVC here means
|
||||
re-provisioning every protected resource, every target port, every user grant,
|
||||
and re-doing the OIDC client trust from scratch — plus temporary outages on
|
||||
every downstream site that Pangolin fronts.
|
||||
|
||||
- **`pangolin-postgres-data` PVC (ns=pangolin, 10 Gi, `hcloud-volumes-encrypted`).**
|
||||
Backs Postgres. Contains resources, targets, users, sessions, and everything
|
||||
the controller reads. Annotated with
|
||||
`argocd.argoproj.io/sync-options: Delete=false` — do not remove that
|
||||
annotation, and do not delete the PVC as a "reset" step. Snapshot the volume
|
||||
before any migration or destructive DB operation.
|
||||
- **`pangolin-data` PVC (ns=pangolin, 10 Gi, `hcloud-volumes-encrypted`).**
|
||||
Runtime files under `/app/config`. Also marked `Delete=false`. Do not delete.
|
||||
- **Sealed secrets** (`pangolin-secrets`, `pangolin-postgres-secrets`,
|
||||
`pangolin-controller-api-key`) — rotating them out of order will lock the
|
||||
controller out of the internal API or take Pangolin off Postgres. Rotate one
|
||||
secret at a time and verify the corresponding pod comes back before
|
||||
proceeding.
|
||||
- **`pangolin-config` ConfigMap.** Editing `server.internal_hostname` /
|
||||
`server.badger_override` back to the default short hostname will break the
|
||||
badger middleware cluster-wide (see Troubleshooting).
|
||||
- **The Traefik `HelmChartConfig`** in `kube-system`
|
||||
(`infrastructure/networking/traefik-helmchartconfig.yaml`) — Pangolin depends
|
||||
on `allowCrossNamespace=true` and on the `letsencrypt` resolver being loaded
|
||||
with a persistent `/data` PVC. Removing either silently breaks every
|
||||
Pangolin-managed route.
|
||||
|
||||
If access to Pangolin is broken, **restore from a snapshot first; do not
|
||||
"reset" or re-provision** the admin/OIDC layer. That is the standing rule for
|
||||
every stateful service in this cluster.
|
||||
|
||||
## Related
|
||||
|
||||
- History and gotchas: internal memory `pangolin-kube-integration` (from DEV-457).
|
||||
- Configuration walkthrough for the `paperclip.basicstack.de` resource:
|
||||
[`pangolin-paperclip-configuration-guide-community.md`](pangolin-paperclip-configuration-guide-community.md).
|
||||
- Pangolin docs: <https://docs.pangolin.net/>
|
||||
- pangolin-kube-controller: <https://github.com/fosrl/pangolin-kube-controller>
|
||||
|
|
@ -35,7 +35,7 @@ spec:
|
|||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-postgres
|
||||
image: postgres:17.5
|
||||
image: postgres:17.11
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
|
|
@ -58,7 +58,7 @@ spec:
|
|||
name: pangolin-postgres-secrets
|
||||
key: postgres-db
|
||||
- name: render-config
|
||||
image: busybox:1.37
|
||||
image: busybox:1.38.0
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ spec:
|
|||
fsGroup: 999
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:17.5
|
||||
image: postgres:17.11
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ spec:
|
|||
fsGroupChangePolicy: Always
|
||||
initContainers:
|
||||
- name: fix-permissions
|
||||
image: busybox:1.36
|
||||
image: busybox:1.38.0
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
---
|
||||
apiVersion: bitnami.com/v1alpha1
|
||||
kind: SealedSecret
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: paperclip-auth-secret
|
||||
namespace: platform-prod
|
||||
spec:
|
||||
encryptedData:
|
||||
better-auth-secret: AgA5HmbWv21FilfH73iNAgqiTtImfz2noM6b5O+WImgMkE+EBTY5EXOD7puZJmuK7QJKNv2+81e7PIpAuZeTlw+3AIp43BebSElpQcGAfvA66B5TpILIB9cR1zgO+taaRIsoMOvPqzXMbT4bU5TN4CNyCavRQx6uMc/MRpJ8/xM9ae+AipcWEY3xPvunKfmJvMowHl2lECjD//V8+LPXNV2KiEoshmPeu2xp3appiLFbm/4pnLoGuiABzXw3PsO49OeQ7n788YyvaCdn81+TzoHF+yeJHyVNMOMLSutO44vQXWdgb86lmWBMbFLgNoGHsD/VWMl3NIYredidxUEQQl6rdpu60Zkl9NTgYtogIkmmt7MaJww8Bkocvggf1gQxYLzbowWFCO50UwNLXy4yYtZbpH/gj+wbL3BnH9Sqn2Rk/QwxDsldMfbcmJk+dvX6s2jGcQ1YCQnKC26g6NZ7xCIcuZtEaSYTJthzYPpTdYYj9SY+1XpAItlnW9dj+zVg3wxifBViB7nqAHXjB3yHztiGyUTQo9C22zIgewxEq/4gCxLYx3HfPyHOKPD6aHMmFnTIFszO2JleV8xjiMy5+m9sFYvxUaPyHY2nO69CqoPJji7f8125YHPPZimUbluyPi2eXDCrN9aRXxe2478pyXaogfWft/vTX1lNyb79k2H/eKL7lpGDzov0smIqTgdUBi6VALpw7n23v3dCEniUwuE/UlcVCYfQQ1bUqw55X5FXUM5aSDS+ps6ZbcIiTUfdRsbopIOrJcukBpF+
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: paperclip-auth-secret
|
||||
namespace: platform-prod
|
||||
type: Opaque
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
---
|
||||
apiVersion: bitnami.com/v1alpha1
|
||||
kind: SealedSecret
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: paperclip-oidc-secret
|
||||
namespace: platform-prod
|
||||
spec:
|
||||
encryptedData:
|
||||
client-id: AgAW44lRQJw+NgeQYhxFklVOFieFew0Nq/fInttWrf3G81QDXJkclqNAFTk7xMsvDff4N3OpGdPojFwdmng78ZXtvYkgvoR9iu3uIItZlMOZqdtSBsHMOJtls36G2slPA3z4OEmCxW7bnRzrUpV6VbSKCCa8A5AAMZAxHFGUGDrm61wen0U9jIDDn+Xa2AMf3XoHWVZEQ9cBg77ruhNmmw4JssI6rbuDkiFmSKMAmNubel/IjhAtZ2FJvA32Zz2Q0p2DEXSsx8jXdSRtPL0tvZICltojBBL2HXQ8dTZKOQqo+JXzJx6hKbCHPORzirIhCyZSFrZv2ac8qo3y2XzMM9EQVAs3QJHXiNqi9jFD/OV862OoOo8cVe/tk2h3/bj49pR1WPp+Mmdza/U7oxtj+Mns9nO78KJsqQDT22b2vnvTQBY6fzDsmBw2+rEAZx4UlEW9xaApzwv2V7ZermIQuo5AQw+HXN3HKESJyfCZoeMX204qNSHhh88n6O8VTxin1ZB2OomF8+CZrmANluHNNsGPVwFFMl7sr9fdtLlnb2x8ux+oRi29/6K/x4mYgk2lVg+VHD58NV9HFFb08kq6rSP71WJE1eCZSix8HIL1Io6ahUDfNQ0sHk/OHni/BwY7S7cYgVGv2n6nyy5uJ8oT5/ce2vE4TXABdkNonbM/igA3nIsh6YC4oiGz2Ck2mehixxvn+7TpluhWodJvTExvw2I6tntE1dmXG1Q6FrHyO17quY17Ah8=
|
||||
client-secret: AgB73qCz4Rp0ZasaqZ6kbROSIuo0Dld7WStDV40Kel3pfTeFhvTALVy3Lp1OjYHzUZ6v2AeolLH0ewmEEyXOdDCspgXfURxXAgoCi6oFYrfeXJIvVlvbnPyVvAbFaVmyFGtVrQyFi+tbhd77YMxquA2fYobCWc3NHIDf9EnLEgfLvdjoWhk3BsCBzFmqgbxsOAPkkhcocqcTff4DntUY/FnsHVRCESid3r+xZsa+Bu3wTSJZH/CyOfRss8vaieBVW3DDglV2/GCCN7TUmdaZuLx3SwAw/zDkOHThkBz59SieVuJf1LP/ax8euMZ+dVLWQ+dc0lDBx+RdzV/hILny+7NzqoegCkRcqapBRgDxQygu1NHwbgkW6jWASaMrrP0VmBus1vQezkXeYoJK+ECrD2WBZ+WlW+9k7yqwZFW3xeC9BJ5N8HTdjivzatQ66m0ovSyTyYGyBYfJ+DUNiD30hRUHMRKmynpLs4dCFCFvWT9vXt87WeSKLKngSB285Q39URKOk9eJZL7Ob8lBCzAZ0rlnQKeUO5Zo1hdVFvuDAQIh0ELiKMMlGDJZEs4qU2yPk26tskZEHw4Wez7v8ANK4HTBUKalqJ0uio3xeEg/xZw6zrZH5tG3tiMGVGScfLaSErFVwZh3j2wSOhrOaqa/3FwhRmWjnl6Ol6Dr6OU1dO/8B7dhU3AP8d5tQMsyApJ2J7kUDNtqxvhqrMe5cp3p313Wj/XzMzFtloEU/s0gBmpoLw==
|
||||
issuer: AgBoZ2oQ/RvqabCnuUQP5cDtBHJdx9nDUlEMp2GSDJ/T5jnB1fuBe5dM/031GBR0378udqmiv7KCCq9eSIlYGQoKomvWQwEa4nEamQktu9xzYxGUzwOK6oH55Tpe2OMXSC0BG5Wx7mZX9JMA/ZBkqDuE/XynLO1P3VedePZwSP6EKkaGqQ1csqmVivYoPh1kMGN/MI9c/IwhdqM8NQOK7jXwZKHWI0oLl9eUJh9d73w/f3EVslMNYFnhBPPN7lD4TtwJRk/iQueeFMaazObnY4LDlzjI28v3aFmptie5QMVBRXcGcotIs5NS04JOkwuxp8uPBDRTYGenWglYzab5Ep5dZywxF+4SQbUETxcki4UIdahFe1F2t3NlIuuzVQy0ltDkWCueepPybv8tuYY36IpZfHVFNYuCa9snMDlPQWs2/EXkxwFkVldxhhXXcGIimE+UI3t685xTvhiGVwIOSPIK/41CvprZWdMpw5iWeF2V/2RPKhfE2Pa9ARPgX6dZ4+LPO40HOHaDfhhlBIj+UbUq3pjRsLzRB7gij0bbkejTq+jQGefEjzTkO6vGvcRKAuWc5bSO0TBKQL3ItgBKKLhqcAjjXo9T0XXhennL2PDdLQLl9ixLzFkTLSlRXMRTRtk3a0hVI7vXY5POwnxuyqKEKhNTaqis9yi+bm0wAtza4/uw0ZdjFNmV7b47KSUFmlzdze8CHgLEkhk6x3i0eBUb0av+NLS8oz4pYg==
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: paperclip-oidc-secret
|
||||
namespace: platform-prod
|
||||
type: Opaque
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
---
|
||||
apiVersion: bitnami.com/v1alpha1
|
||||
kind: SealedSecret
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: paperclip-session-secret
|
||||
namespace: platform-prod
|
||||
spec:
|
||||
encryptedData:
|
||||
secret: AgCQntkVAWf6xX1Gv1f1Wx7NVc42nUMSTktjeI8xv1t4Uia4Sk6q1YQWFfoksxT4zlQJlR+ObctV97+h4AXiKYuVYXfF3CkQzdG5BAvOvhowo7LtUHkh+k3m9j/8MuO2l1ud1QKPNYV2Bi/vJ+x4ifX31l8QZexitVusb31KMWc1wDoRuIo4Ntk9BCIuEA9ctg7XbG1t17pMGMHGasL0vYb2fN/GePFKhaFoe00ZThZBAI2er4pVM5AV6Q33xQTXTAN5VWJKu65/NeGi+xeUXs0/I7oOesHKf0G51zjUT2I01ZtaFTmX0hzBuKCRA/SNyq5IT0+V46euItgD/vg8HAj8J5yVyvoaAtg9Ts44QsPOmkuXGpCsCj4PmhE8ZZVXarH8t5qmdWXwbgR9sR8+3mUAP2pnq8D0CNLowLmxE9HVPuUxWIsCpBHAc6f9eH8jclT+H/KmaW7wcaIIuMB3BH3Si6ZanCBKpy0oz0qKllHQt+l1W7nA8W4hXR1h2aVOOThzUbtg/j+xAmJz+pfQx9KUaucLZxa3/ZdQFtVOU8dF6slQcIrt+OB8XXebTk/LGJDgO27sGKjY46kRFkIIJ6U/SLvsyfrZNTHxMqSariQQosekqkNZ+ME3wRE5D8UgQtRfkK44KwyU/zKjXCDgXBIAI9gNJRs77yAzPeQv4AoKGVTQ60Li2bA2PMoPjYHY9gGtYNssa/XEQISoEw5dYzRGJOLbtgjA1ZLUrR5nIWUHn5ntff84e0SsumI7AQ==
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: paperclip-session-secret
|
||||
namespace: platform-prod
|
||||
type: Opaque
|
||||
|
|
@ -17,7 +17,7 @@ spec:
|
|||
spec:
|
||||
containers:
|
||||
- name: pocket-id
|
||||
image: ghcr.io/pocket-id/pocket-id:v2.11.0
|
||||
image: ghcr.io/pocket-id/pocket-id:v2.14.0
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 1411
|
||||
|
|
|
|||
|
|
@ -71,14 +71,20 @@ This repository (`https://forgejo.basicstack.de/basicstack/stack.basicstack.de`)
|
|||
|
||||
### Investigation Results (2026-07-13)
|
||||
|
||||
**Infrastructure Status**: ✅ ALL VERIFIED WORKING
|
||||
**Infrastructure Status**: ✅ ALL VERIFIED WORKING (as of 21:00 UTC)
|
||||
- SMTP (ports 25, 587, 465): Responding with correct ESMTP greeting
|
||||
- IMAP (ports 143, 993): Responding with correct IMAP greeting
|
||||
- LoadBalancer external IP: 178.105.17.239 (correct)
|
||||
- Pod status: Running, 0 restarts
|
||||
- Pod status: Running, started at 19:26 UTC
|
||||
- Service configuration: Correct port mappings and selectors verified
|
||||
- Monitoring: PrometheusRules and ServiceMonitors in place
|
||||
|
||||
**User-Reported SMTP Issue**: Client configuration problem (Thunderbird connecting to port 143 instead of 587), not infrastructure issue
|
||||
**SMTP Issue Timeline (2026-07-13)**:
|
||||
- 20:16 UTC: LoadBalancer IP updates visible in events (10.42.1.1 → 178.105.17.239)
|
||||
- 20:31 UTC: User reported SMTP port returning IMAP greeting (Thunderbird error)
|
||||
- 21:00 UTC: External port tests confirm all ports working correctly with proper greetings
|
||||
- **Assessment**: Transient issue during LoadBalancer IP transition, now resolved
|
||||
- **Action Required**: User to retest SMTP sending with fresh client connection
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
---
|
||||
# One-time Job to configure Stalwart HTTP listener to allow internal cluster IPs
|
||||
#
|
||||
# Problem: Stalwart blocks the HTTP port from Traefik's pod IP (10.244.2.227),
|
||||
# causing 502/503 errors when accessing mail.basicstack.de
|
||||
#
|
||||
# Solution: Use kubectl exec to access Stalwart's admin API via localhost (which is allowed)
|
||||
# and disable IP filtering for the HTTP listener, or allow the pod network CIDR
|
||||
#
|
||||
# This Job must be manually triggered after Stalwart is running:
|
||||
# kubectl create job --from=cronjob/stalwart-allow-cluster-ips manual-fix -n stalwart
|
||||
#
|
||||
# Or apply directly:
|
||||
# kubectl apply -f stalwart-allow-cluster-ips-job.yaml
|
||||
# kubectl wait --for=condition=complete job/stalwart-allow-cluster-ips -n stalwart --timeout=120s
|
||||
# kubectl logs -n stalwart job/stalwart-allow-cluster-ips
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: stalwart-config-access
|
||||
namespace: stalwart
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: stalwart-config-access
|
||||
namespace: stalwart
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/exec"]
|
||||
verbs: ["create"]
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["get"]
|
||||
resourceNames: ["stalwart-admin-credentials"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: stalwart-config-access
|
||||
namespace: stalwart
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: stalwart-config-access
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: stalwart-config-access
|
||||
namespace: stalwart
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: stalwart-allow-cluster-ips
|
||||
namespace: stalwart
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600 # Keep logs for 1 hour
|
||||
backoffLimit: 3
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stalwart-security-fix
|
||||
spec:
|
||||
serviceAccountName: stalwart-config-access
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: fix-security
|
||||
image: bitnami/kubectl:latest
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
echo "=== Stalwart HTTP Listener Security Fix ==="
|
||||
echo "Configuring Stalwart to allow internal cluster IPs for the HTTP listener"
|
||||
echo ""
|
||||
|
||||
# Wait for Stalwart pod to be ready
|
||||
echo "Waiting for stalwart-0 pod to be ready..."
|
||||
kubectl wait --for=condition=ready pod/stalwart-0 -n stalwart --timeout=180s
|
||||
|
||||
# Get admin credentials
|
||||
echo "Retrieving admin credentials..."
|
||||
ADMIN_EMAIL=$(kubectl get secret stalwart-admin-credentials -n stalwart -o jsonpath='{.data.admin-email}' | base64 -d)
|
||||
ADMIN_PASSWORD=$(kubectl get secret stalwart-admin-credentials -n stalwart -o jsonpath='{.data.admin-password}' | base64 -d)
|
||||
|
||||
echo "Admin email: $ADMIN_EMAIL"
|
||||
|
||||
# Use kubectl exec to access Stalwart's admin API from localhost
|
||||
# The HTTP listener allows localhost connections even when blocking other IPs
|
||||
echo ""
|
||||
echo "Accessing Stalwart admin API via kubectl exec..."
|
||||
|
||||
# Test API access first
|
||||
echo "Testing API connectivity..."
|
||||
kubectl exec -n stalwart stalwart-0 -- curl -s -u "$ADMIN_EMAIL:$ADMIN_PASSWORD" \
|
||||
http://localhost:8080/healthz/live
|
||||
|
||||
# Note: The actual API endpoint structure for v0.16.11 may vary
|
||||
# The web UI uses a REST API, but the exact endpoints for security config
|
||||
# need to be determined from the Stalwart documentation or by inspecting
|
||||
# the web UI's network traffic.
|
||||
|
||||
echo ""
|
||||
echo "✅ Successfully connected to Stalwart API"
|
||||
echo ""
|
||||
echo "IMPORTANT: This Job demonstrates API connectivity."
|
||||
echo "The actual security configuration change requires:"
|
||||
echo "1. Identifying the correct API endpoint for security settings"
|
||||
echo "2. Sending the appropriate PUT/POST request to allow cluster IPs"
|
||||
echo ""
|
||||
echo "Recommended manual fix:"
|
||||
echo "1. Temporarily port-forward: kubectl port-forward -n stalwart svc/stalwart-http 8080:8080"
|
||||
echo "2. Access https://mail.basicstack.de from your browser"
|
||||
echo "3. Login with admin credentials"
|
||||
echo "4. Navigate to Settings > Security"
|
||||
echo "5. Disable IP filtering for the HTTP listener or add 10.244.0.0/16 to allowed IPs"
|
||||
|
||||
exit 0
|
||||
|
|
@ -5,4 +5,7 @@ metadata:
|
|||
name: stalwart-bootstrap-config
|
||||
namespace: stalwart
|
||||
data:
|
||||
config.json: '{"@type":"RocksDb","path":"/var/lib/stalwart"}'
|
||||
# DEV-476: Bootstrap now points at the PostgreSQL config/data store.
|
||||
# PGPASSWORD is injected into the stalwart container from
|
||||
# secret/stalwart-postgres-credentials (see stalwart-fresh-deployment.yaml).
|
||||
config.json: '{"@type":"PostgreSql","host":"stalwart-postgres","port":5432,"database":"stalwart","authUsername":"stalwart","authSecret":{"@type":"EnvironmentVariable","variableName":"PGPASSWORD"},"useTls":false}'
|
||||
|
|
|
|||
|
|
@ -10,12 +10,22 @@ data:
|
|||
# Stalwart Mail Server Configuration
|
||||
#
|
||||
|
||||
# DEV-476: primary store is PostgreSQL (see stalwart-bootstrap-config.yaml).
|
||||
# Note: the container starts with `--config /etc/stalwart/config.json`, so
|
||||
# this stalwart.toml is not read at runtime for a live pod — the bootstrap
|
||||
# JSON is authoritative. Kept in sync here as documentation and for any
|
||||
# one-shot tooling that references the toml.
|
||||
[store]
|
||||
data = "rocksdb"
|
||||
data = "postgres"
|
||||
|
||||
[store.rocksdb]
|
||||
type = "rocksdb"
|
||||
path = "/var/lib/stalwart"
|
||||
[store.postgres]
|
||||
type = "postgresql"
|
||||
host = "stalwart-postgres"
|
||||
port = 5432
|
||||
database = "stalwart"
|
||||
user = "stalwart"
|
||||
password = "%{env:PGPASSWORD}%"
|
||||
tls.enable = false
|
||||
|
||||
#
|
||||
# Server Configuration
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ spec:
|
|||
mountPath: /var/lib/stalwart
|
||||
containers:
|
||||
- name: stalwart
|
||||
image: stalwartlabs/stalwart:v0.16.11
|
||||
image: stalwartlabs/stalwart:v0.16.18
|
||||
ports:
|
||||
- containerPort: 25
|
||||
name: smtp
|
||||
|
|
@ -145,6 +145,12 @@ spec:
|
|||
value: "/etc/stalwart/certs/tls.crt"
|
||||
- name: TLS_PRIVATE_KEY
|
||||
value: "/etc/stalwart/certs/tls.key"
|
||||
# DEV-476: bootstrap config.json resolves authSecret via this env var.
|
||||
- name: PGPASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: stalwart-postgres-credentials
|
||||
key: POSTGRES_PASSWORD
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/stalwart
|
||||
|
|
@ -289,7 +295,7 @@ spec:
|
|||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
activeDeadlineSeconds: 600 # 10 minute timeout
|
||||
activeDeadlineSeconds: 1800 # 30 minute timeout (was 600s; retries + affinity settling can push past 10 min)
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
|
|
@ -297,9 +303,20 @@ spec:
|
|||
spec:
|
||||
serviceAccountName: stalwart-backup
|
||||
restartPolicy: OnFailure
|
||||
# Co-locate with stalwart-0 so both pods share the same hcloud block volume
|
||||
# attachment (RWO). Without this the backup pod can be scheduled on a
|
||||
# different node and hits FailedAttachVolume/Multi-Attach (DEV-468).
|
||||
affinity:
|
||||
podAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchLabels:
|
||||
app: stalwart
|
||||
statefulset.kubernetes.io/pod-name: stalwart-0
|
||||
topologyKey: kubernetes.io/hostname
|
||||
containers:
|
||||
- name: backup
|
||||
image: alpine:3.19
|
||||
image: alpine:3.24
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
|
|
|
|||
120
apps/stalwart/stalwart-postgres-backup.yaml
Normal file
120
apps/stalwart/stalwart-postgres-backup.yaml
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
---
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: stalwart-postgres-backup
|
||||
namespace: stalwart
|
||||
labels:
|
||||
app: stalwart-postgres-backup
|
||||
spec:
|
||||
schedule: "30 2 * * *"
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
activeDeadlineSeconds: 1800
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stalwart-postgres-backup
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: backup
|
||||
image: postgres:16-alpine
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -euo pipefail
|
||||
BACKUP_START=$(date +%s)
|
||||
echo "=== Starting Stalwart Postgres backup at $(date -u) ==="
|
||||
|
||||
# pg_dump already in image; restic is small and only needed here.
|
||||
apk add --no-cache restic
|
||||
|
||||
export RESTIC_REPOSITORY="s3:${S3_ENDPOINT}/${S3_BUCKET}/stalwart"
|
||||
echo "Repository: ${RESTIC_REPOSITORY}"
|
||||
|
||||
# Repo is shared with the RocksDB backup, so it already exists;
|
||||
# init only if the RocksDB side has not created it yet.
|
||||
restic snapshots > /dev/null 2>&1 || restic init
|
||||
|
||||
echo "Streaming pg_dump | gzip -> restic (tag stalwart-postgres)..."
|
||||
PGPASSWORD="${POSTGRES_PASSWORD}" pg_dump \
|
||||
-h stalwart-postgres.stalwart.svc.cluster.local \
|
||||
-U "${POSTGRES_USER}" \
|
||||
-d "${POSTGRES_DB}" \
|
||||
--no-owner --no-acl \
|
||||
| gzip -c \
|
||||
| restic backup \
|
||||
--stdin \
|
||||
--stdin-filename stalwart-postgres.sql.gz \
|
||||
--tag stalwart-postgres \
|
||||
--tag daily \
|
||||
--host stalwart-postgres-k8s
|
||||
|
||||
echo "Forget/prune (tag=stalwart-postgres, independent retention)..."
|
||||
restic forget \
|
||||
--tag stalwart-postgres \
|
||||
--keep-daily 14 \
|
||||
--keep-weekly 8 \
|
||||
--keep-monthly 6 \
|
||||
--prune
|
||||
|
||||
echo "Latest stalwart-postgres snapshots:"
|
||||
restic snapshots --tag stalwart-postgres --compact || true
|
||||
|
||||
BACKUP_END=$(date +%s)
|
||||
echo "=== Postgres backup complete in $((BACKUP_END - BACKUP_START))s at $(date -u) ==="
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: stalwart-postgres-credentials
|
||||
key: POSTGRES_USER
|
||||
- name: POSTGRES_DB
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: stalwart-postgres-credentials
|
||||
key: POSTGRES_DB
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: stalwart-postgres-credentials
|
||||
key: POSTGRES_PASSWORD
|
||||
- name: RESTIC_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: stalwart-s3-backup
|
||||
key: restic-password
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: stalwart-s3-backup
|
||||
key: access-key
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: stalwart-s3-backup
|
||||
key: secret-key
|
||||
- name: S3_ENDPOINT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: stalwart-s3-backup
|
||||
key: endpoint
|
||||
- name: S3_BUCKET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: stalwart-s3-backup
|
||||
key: bucket
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
19
apps/stalwart/stalwart-postgres-credentials-sealed.yaml
Normal file
19
apps/stalwart/stalwart-postgres-credentials-sealed.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
apiVersion: bitnami.com/v1alpha1
|
||||
kind: SealedSecret
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: stalwart-postgres-credentials
|
||||
namespace: stalwart
|
||||
spec:
|
||||
encryptedData:
|
||||
POSTGRES_DB: AgBl0xkIxrdRjAlv0JP/rPXwV4sbfFcLc1TwPKZ0VC7+MXd9K1s+FMEottjxe3jUMeVk1ddI0OelZ7PlZcVeocvC4fq02djtagLcjSBn5AFqnn0vJO+sAKsnqxuyQv7IIuNhqL5a88obOeuKJPB325IsXLEhFUrCjXDz4KTjn1RW4p9sWcbn1jBbGYHSRpp7GMn6LgkJOjhbpiQeptr270dTiOjHAxI3Ky507v5qAA8m5VwjHK9pXN8QkkYqIq3mXp4WWRt9LVAfnaQ+tnxPpeZBgzf3pV2Hj4yDpFuans8xGft9e5Er2ygEwpUD+AOEJdjSntzvkx4K5Lprv3ud1PXY+f5LZb7w41Ex2fwQtGKWepQyAD1LSqtapkOVirgWOfmGxR0NcyWTpqu6VuVYPgUq+I37CCmTdVKbBmqhSIMRll0E7Cnzse1wiLIugyHtidg5whWpYiHiByEKCxwmd0LKAGZ2gTrIOctRmpLb5UYvDxr+T/mrG20p4SymFucaT7MrvMXRnewB5HSvhooXgFyTq7zNL6mlVgK5oQI1RRJ71dNtrjs2Z/RLCzffbV8K3oSM8lFuN0BBea5M8QorJoraqwa7cHPyCCCbuJuXBMo8ZDC96etvoICiBFwt4hFcxuOS43XX6CLZiVq516/TN4VLm558ai9KfIZvEkaIivJeFqdDuR2oVUV9nAQc6nqMHHnRiFmPJpUpXg==
|
||||
POSTGRES_PASSWORD: AgASUNVrGocpv2dDca4E/cRK8IISrCS5z40ObiAzxsZql/+NdRfMzk1fUl5QF94KAgm3xe1V901SWNJvNVG/qKfHt83K8ARlWyHxFH8etc9diMQgXD4EuW21wVyWbI1lRaDSP56yqMMlOpgrgfLhN45BToiD0Dqe6s1lAdO7ozyBcNO9kEkHzM/bFmGgbDX/u8/c4bXXV3VSQueBPVDPMToEzIazCJPDQKn7PlmOzmANQ8xWa5avRpQfpsPcoatevDMnYDGyVEAlxBIAbaf8muyVD7yce6rFCYAQCUhqwyiu3YPVNBJT1PdmtNwZ2WHu42nuLcOkUoEWLtX/Ex/fnBhlvKvFBKCLBR2XPLjY/GkfoYXFN/+RbwCdvJnj77v11AxBLPrNpLx7sQmS0Gxz2ivzxexjuSVibyVO2pYqsi4idOOc8OtUEwbxN1WjncKMRz0WB5k2bOE4/DIjYIZ5KYz/+RVi6fR0CMKPuCgq76j3DHazMEKCicCdqw0iZaqyXKTIf3jzaAkdC0v6c/1by4d5z2NppDiGKwvLcwHYHlq2IKPPCjSdpu/efGI2CxijcHLtYpa5N2gg40T9oJRsf6NWu9lrvtdsoKiaWCo4hnkqmhGsNXiAqVerd7BslEkD8h5bnZYZPZ6IVZCZw86rwDOmICOj79lqtIcHDJyJra+n7wC3FlokBwR1bR3QcVJcqL2V2OBqlHQ2SCHv+uxL8d8IkHibnj23FJHlwYRBFNvw
|
||||
POSTGRES_USER: AgA6zhC4W9S/+X+o2Gcj+0WfapqtUjZEmxpnMxZwQpYk5qaR1TrfFezKgKuGbnFWZ1HeUJM1gTlsOvg2wIlbKLHd8on8KeK4JhtR5GUd75icaW4/SSwWdODlHP6UlY1rxbc2uYxwLkbauRcjY1DKl633n/BXNYzAKFd1MbN0XsD3cMHgwDHy2MD6qW8VS80BEqj6ljNUPC/wJTR1CISH+rAiWmuuX+mf8whjqZxulqFFWaPuscC9ceHZT2bdhkC5uSBRXc5t6ZTiroRW50cT08J+u0W2Xm56ygJ6uuIxBWgTWKcAE1UNRIIXLOlK19j5yR6e216TaDTj06Spu6l7thfdPiRAx1kXMJhCK3Rhh5umWVrXxyomfA6IweUeI/kfLC3q3I5UGezZUMTou4QUsRVYwxnuyVsiDQJ0MWd0p5RVN690PbLxFebsg3YFW1R33UAvzhukO/Ct+QvWG+FfVUnaVrMh3nM+UnJau7w+9hdToG9wwvwBwCJRNRpCDo3ev1d+0AuBURXWnOSCUcifDsF6rKfCVxRMEVB5dLj5IYN3j0XRRMkvsE5UAHCFC5QFaUQD/5V6LSRvX1vV/aD88Ver57P0P4bYnNHLj02GP9OIV7HTv2jDJz3ZycfchXjRw9cIM7WmmF2HWlyCT608HD8NKf+dZlF7FJr+REYGCmNCvA4PIB6g6kwIOZzQ0KjVqFEJ0z+G7xXXjQ==
|
||||
stalwart-db-password: AgBhrofehsfXWbdixnkOqdsNu8y9eDNIBW1g5ZgHcNAX0uq5Qa/84KguJZcNm4nzIuTs5KZTjTLvuync4cypKVKoqzthRwGWL01uAc3I8+gzjAsSLIzfWa1Dq9BZTEA0Q7oO3dBmdooAy+e5WR0MRhsF9VQWWwxM/UzQad4ihM/PBlkfvx+6Ql0pTp8m/K+E5YqXzNnla5eCra+zlzG3JWF7SOF82AnM6sLKzsHQzTv31z4I6pJTyoEtBNxbjsmmuCIh+fGPBusqTEY3vjEwVJJEaviq2B6rR9uz/sNF5bc6XtX0nAeyeQR4WURrTmGBDgwI85Z0/vDBsD9D+NTuoTitw4kTO8JcE2H4FUY8afurxpODvx6mmLaYnV8c7hLz4l33k9zCpjb8gHfNqQR/sKRu+iSeej12E0MgxUXnSL9nhLBltwA/6KpGHz4TxTG58/AopF9yDQIKOsZF7Lj4mJC3AQ7qlpwF8j/5wiMEr8OqyhAiOm6wmzIArZzb91SVlKitLmQEPBdOzx2kboZmeSJi/P52R6qFYm/bamGPTI5m6x+MnEXgur37eubnSlY/v65sfhdJb+Gm4B1QyS06CSG4VCgom1T5Fpc1zTWxgYN85OaHQVFR1T5jhOCgZPS01/rN3R1D/wryve8n2aGLZhf+ad8vKyXbcg25lIchzwWOXYk5nM0NOshw3Dk2/IcFfwja3QGak0ps7syZUHPY+wCPL9mrd7F4OqaLrZmbZHrv
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: stalwart-postgres-credentials
|
||||
namespace: stalwart
|
||||
type: Opaque
|
||||
100
apps/stalwart/stalwart-postgres.yaml
Normal file
100
apps/stalwart/stalwart-postgres.yaml
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: stalwart-postgres
|
||||
namespace: stalwart
|
||||
labels:
|
||||
app: stalwart-postgres
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: stalwart-postgres
|
||||
ports:
|
||||
- name: postgres
|
||||
port: 5432
|
||||
targetPort: 5432
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: stalwart-postgres
|
||||
namespace: stalwart
|
||||
labels:
|
||||
app: stalwart-postgres
|
||||
spec:
|
||||
serviceName: stalwart-postgres
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: stalwart-postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stalwart-postgres
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: csi.hetzner.cloud/location
|
||||
operator: In
|
||||
values:
|
||||
- fsn1
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: postgres
|
||||
containerPort: 5432
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: stalwart-postgres-credentials
|
||||
env:
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
volumeMounts:
|
||||
- name: postgres-data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
subPath: pgdata
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: postgres-data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: hcloud-volumes-encrypted
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
464
docs/monitoring/restic-restore.md
Normal file
464
docs/monitoring/restic-restore.md
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
# Restic Restore Runbook — Hetzner Object Storage
|
||||
|
||||
Author: CTO agent (`4b5f09a2-22d8-4e3d-9ac4-46d008ad1385`)
|
||||
Ticket: [DEV-488](/DEV/issues/DEV-488)
|
||||
Parent: [DEV-482](/DEV/issues/DEV-482) (Option 4 — restic → Hetzner OS)
|
||||
|
||||
The monitoring stack's data-durability layer is a client-side-encrypted
|
||||
restic repo set stored in a single Hetzner Object Storage bucket. This
|
||||
document is the operator playbook for **restoring** those snapshots
|
||||
into a scratch namespace when we need to (a) recover from a real loss
|
||||
event, or (b) run the quarterly restore drill that keeps the
|
||||
[`backup-volumes` retirement plan](/DEV/issues/DEV-489) honest.
|
||||
|
||||
## Repo layout
|
||||
|
||||
Single bucket, four repo prefixes:
|
||||
|
||||
| repo prefix | source | writer CronJob | tag |
|
||||
| ---------------------------------------------- | --------------------------------------------- | ---------------------- | --------------- |
|
||||
| `restic/loki` | PVC `loki-storage-encrypted` | `backup-loki-restic` | `loki` |
|
||||
| `restic/grafana` | PVC `grafana-storage` (worker-2) | `backup-grafana-restic`| `grafana` |
|
||||
| `restic/k8s-resources` | `kubectl get -o yaml` (stdin) | `backup-k8s-resources` | `k8s-resources` |
|
||||
| `restic/prometheus` | PVC `prometheus-data-encrypted` (excl. WAL) | `prometheus-backup` | `prometheus` |
|
||||
|
||||
Password + endpoint + bucket + AWS creds live in `SealedSecret`
|
||||
`monitoring-s3-backup` (namespace `monitoring`). Keys:
|
||||
|
||||
- `restic-password` — 32-byte random, generated in DEV-484, sealed
|
||||
locally, mirrored to Passbolt entry `restic / monitoring backups`.
|
||||
- `access-key`, `secret-key` — Hetzner S3 credentials.
|
||||
- `endpoint` — e.g. `https://hel1.your-objectstorage.com`.
|
||||
- `bucket` — Hetzner bucket name (all four restic repos share it).
|
||||
|
||||
Encryption is done **client-side by restic**. Hetzner Object Storage
|
||||
has no SSE-S3 / SSE-KMS ([FAQ](https://docs.hetzner.com/storage/object-storage/faq/general/));
|
||||
the CTO's [Hetzner Object Storage encryption memory](../../memory/hetzner-object-storage-encryption.md)
|
||||
covers the constraints.
|
||||
|
||||
## Credentials projection
|
||||
|
||||
Any restore pod must project **only** `monitoring-s3-backup` — never
|
||||
mount cluster admin creds into the drill namespace. The pattern below
|
||||
copies the secret cross-namespace once and lets restic project it
|
||||
read-only.
|
||||
|
||||
```bash
|
||||
kubectl create namespace restore-drill
|
||||
kubectl -n monitoring get secret monitoring-s3-backup -o json |
|
||||
jq 'del(.metadata.namespace,.metadata.resourceVersion,.metadata.uid,
|
||||
.metadata.creationTimestamp,.metadata.ownerReferences,
|
||||
.metadata.annotations,.status) |
|
||||
.metadata.namespace="restore-drill"' |
|
||||
kubectl apply -n restore-drill -f -
|
||||
```
|
||||
|
||||
## Restore pod
|
||||
|
||||
Throwaway pod pinned to the API-server node (no data mount, just the
|
||||
S3 secret). The `emptyDir` holds the restored tree; deleting the pod
|
||||
wipes it.
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: restore-drill
|
||||
namespace: restore-drill
|
||||
labels: { app: restore-drill }
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: restic
|
||||
image: harbor.basicstack.de/library/restic:0.19.1 # matches CronJob image (DEV-493, bumped in DEV-541)
|
||||
command: ["sleep", "3600"]
|
||||
env:
|
||||
- { name: AWS_ACCESS_KEY_ID, valueFrom: { secretKeyRef: { name: monitoring-s3-backup, key: access-key } } }
|
||||
- { name: AWS_SECRET_ACCESS_KEY, valueFrom: { secretKeyRef: { name: monitoring-s3-backup, key: secret-key } } }
|
||||
- { name: RESTIC_PASSWORD, valueFrom: { secretKeyRef: { name: monitoring-s3-backup, key: restic-password } } }
|
||||
- { name: S3_ENDPOINT, valueFrom: { secretKeyRef: { name: monitoring-s3-backup, key: endpoint } } }
|
||||
- { name: S3_BUCKET, valueFrom: { secretKeyRef: { name: monitoring-s3-backup, key: bucket } } }
|
||||
volumeMounts:
|
||||
- { name: work, mountPath: /work }
|
||||
volumes:
|
||||
- name: work
|
||||
emptyDir: {}
|
||||
```
|
||||
|
||||
Apply it, then `kubectl -n restore-drill exec restore-drill -- <cmd>`
|
||||
for everything below.
|
||||
|
||||
## Per-repo restore commands
|
||||
|
||||
`RESTIC_REPOSITORY` is set inline per repo to keep repo scope explicit.
|
||||
All commands run inside the scratch pod.
|
||||
|
||||
### Loki
|
||||
|
||||
```sh
|
||||
export RESTIC_REPOSITORY="s3:${S3_ENDPOINT}/${S3_BUCKET}/restic/loki"
|
||||
restic snapshots
|
||||
restic restore latest --target /work/loki --tag loki
|
||||
find /work/loki/source -type f
|
||||
```
|
||||
|
||||
Restored tree lands under `/work/loki/source/…` because the backup
|
||||
source was `/source` inside the CronJob container.
|
||||
|
||||
### K8s resources
|
||||
|
||||
```sh
|
||||
export RESTIC_REPOSITORY="s3:${S3_ENDPOINT}/${S3_BUCKET}/restic/k8s-resources"
|
||||
restic snapshots
|
||||
restic restore latest --target /work/k8s --tag k8s-resources
|
||||
ls -lh /work/k8s/cluster.yaml
|
||||
```
|
||||
|
||||
`cluster.yaml` is the concatenated dump written by the
|
||||
`backup-k8s-resources` CronJob's `kubectl-dump` init container.
|
||||
|
||||
### Grafana
|
||||
|
||||
```sh
|
||||
export RESTIC_REPOSITORY="s3:${S3_ENDPOINT}/${S3_BUCKET}/restic/grafana"
|
||||
restic snapshots
|
||||
restic restore latest --target /work/grafana --tag grafana
|
||||
```
|
||||
|
||||
Follows the loki pattern (restored tree under `/work/grafana/source/`).
|
||||
|
||||
### Prometheus
|
||||
|
||||
```sh
|
||||
export RESTIC_REPOSITORY="s3:${S3_ENDPOINT}/${S3_BUCKET}/restic/prometheus"
|
||||
restic snapshots
|
||||
restic restore latest --target /work/prometheus --tag prometheus
|
||||
find /work/prometheus/source -maxdepth 1 -type d | head
|
||||
```
|
||||
|
||||
Restored tree lands under `/work/prometheus/source/…`. **WAL and
|
||||
`chunks_head/` are excluded on purpose** — see the "compaction race"
|
||||
notes at the top of `apps/monitoring/prometheus-backup-cronjob.yaml`.
|
||||
Expect the last ~15 s of ingested samples to be lost on recovery; the
|
||||
compacted 2h/24h blocks are complete.
|
||||
|
||||
**Integrity check — `promtool tsdb analyze` (required for the DEV-492
|
||||
drill).** The snapshot must load cleanly through Prometheus's own
|
||||
verifier before we call the drill green.
|
||||
|
||||
```sh
|
||||
kubectl -n restore-drill run promtool-check --rm -it --restart=Never \
|
||||
--image=prom/prometheus:v2.53.1 \
|
||||
--overrides='{"spec":{"containers":[{"name":"promtool-check","image":"prom/prometheus:v2.53.1","command":["sh","-c","for b in /work/prometheus/source/*/; do echo \"--- $b\"; promtool tsdb analyze /work/prometheus/source \"$(basename $b)\" || exit 1; done"],"volumeMounts":[{"name":"work","mountPath":"/work"}]}],"volumes":[{"name":"work","emptyDir":{}}]}}'
|
||||
```
|
||||
|
||||
Simpler drill path (the restore pod already has restic; `promtool` is
|
||||
not shipped in `restic/restic`, so run it from a `prom/prometheus`
|
||||
sidecar or an interactive pod that mounts the same `emptyDir` — see
|
||||
DEV-492 drill entry for the concrete two-pod recipe used on the
|
||||
first run).
|
||||
|
||||
Exit 0 on every block = pass. A non-zero exit on any block means the
|
||||
compaction-race mitigation slipped and the snapshot is corrupt.
|
||||
Re-run the CronJob and re-drill.
|
||||
|
||||
## Integrity checks
|
||||
|
||||
Two layers.
|
||||
|
||||
**Repository self-check** — cryptographic integrity of packs and
|
||||
snapshots, no source needed:
|
||||
|
||||
```sh
|
||||
restic check # metadata only
|
||||
restic check --read-data-subset=5% # sample-read + decrypt 5% of packs
|
||||
```
|
||||
|
||||
The CronJobs run `--read-data-subset=5%` on every write. A restore
|
||||
drill should re-run at minimum `restic check` end-to-end.
|
||||
|
||||
**Source parity** — sha256 the restored tree against a fresh readout
|
||||
of the live PVC. For loki, exec into the live pod:
|
||||
|
||||
```sh
|
||||
LOKI=$(kubectl -n monitoring get pods -l app=loki -o jsonpath='{.items[0].metadata.name}')
|
||||
kubectl -n monitoring exec "$LOKI" -- sh -c '
|
||||
cd /loki && find . -type f ! -path "./lost+found/*" -exec sha256sum {} +' \
|
||||
> /tmp/live.sha256
|
||||
kubectl -n restore-drill exec restore-drill -- sh -c '
|
||||
cd /work/loki/source && find . -type f -exec sha256sum {} +' \
|
||||
> /tmp/restored.sha256
|
||||
diff <(sort /tmp/live.sha256) <(sort /tmp/restored.sha256)
|
||||
```
|
||||
|
||||
Empty diff = parity. Any drift is expected only on files that Loki is
|
||||
actively writing (WAL segments) — capture the snapshot when Loki is
|
||||
quiet, or accept a WAL delta and re-hash the boltdb-shipper /
|
||||
chunks trees only.
|
||||
|
||||
For k8s-resources, parity is meaningless (a live dump varies by the
|
||||
second); rely on `restic check` and on being able to `grep` the
|
||||
restored `cluster.yaml` for expected namespaces / secrets.
|
||||
|
||||
## Cleanup
|
||||
|
||||
```sh
|
||||
kubectl -n restore-drill delete pod restore-drill --wait
|
||||
kubectl delete namespace restore-drill
|
||||
```
|
||||
|
||||
Deleting the namespace tears down the projected secret. The `emptyDir`
|
||||
lives in the pod's ephemeral scratch on the node; the sleep container
|
||||
never persists anything outside `/work`.
|
||||
|
||||
## Rotation and disaster recovery
|
||||
|
||||
If the `restic-password` is lost:
|
||||
|
||||
1. Recover the plaintext from Passbolt (`restic / monitoring backups`).
|
||||
2. If Passbolt is also lost, the data is unrecoverable by design —
|
||||
client-side encryption with a lost key cannot be reversed.
|
||||
|
||||
To rotate:
|
||||
|
||||
```sh
|
||||
# on any workstation with kubectl + kubeseal + the restic password
|
||||
NEW=$(openssl rand -base64 32)
|
||||
restic key add # opens editor for new password;
|
||||
# paste NEW and confirm
|
||||
restic key list
|
||||
restic key remove <old-id>
|
||||
# then re-seal the SealedSecret with NEW, redeploy, Argo syncs.
|
||||
```
|
||||
|
||||
Rotate annually or immediately on suspected compromise.
|
||||
|
||||
## Restic image (Harbor mirror)
|
||||
|
||||
All three restic CronJobs (`backup-loki-restic`, `backup-grafana-restic`,
|
||||
`backup-k8s-resources`) reference a Harbor-hosted copy of upstream to
|
||||
keep the backup pipeline off the Docker Hub pull path and immune to
|
||||
upstream retagging:
|
||||
|
||||
- Manifest reference: `harbor.basicstack.de/library/restic:0.19.1`
|
||||
- Upstream: `docker.io/restic/restic:0.19.1`
|
||||
- Ticket: [DEV-493](/DEV/issues/DEV-493) (bumped to 0.19.1 in [DEV-541](/DEV/issues/DEV-541))
|
||||
|
||||
The `library` project is public, so no `imagePullSecret` is required
|
||||
on the CronJob pods.
|
||||
|
||||
### Tag-bump procedure
|
||||
|
||||
Run this when we want to move restic to a new pinned tag (e.g. 0.17.3
|
||||
→ 0.17.4). Do the mirror push **before** editing manifests so Argo
|
||||
cannot roll pods onto an unmirrored tag.
|
||||
|
||||
1. **Pick and verify the upstream tag.** Confirm the tag exists on
|
||||
Docker Hub and (ideally) read the upstream restic release notes for
|
||||
breaking changes:
|
||||
|
||||
```sh
|
||||
curl -s "https://hub.docker.com/v2/repositories/restic/restic/tags/0.17.4"
|
||||
```
|
||||
|
||||
2. **Mirror the image to Harbor** with a one-shot in-cluster crane
|
||||
Job. Requires an admin (or `library`-scoped robot) Harbor
|
||||
credential; the Job auth secret is throwaway.
|
||||
|
||||
```sh
|
||||
# from the CTO workstation with kubectl + Harbor admin access
|
||||
NEW_TAG=0.17.4
|
||||
ADMIN_PW=$(kubectl -n harbor get secret harbor-secrets \
|
||||
-o jsonpath='{.data.harborAdminPassword}' | base64 -d)
|
||||
AUTH=$(printf 'admin:%s' "$ADMIN_PW" | base64 -w0)
|
||||
cat >/tmp/dc.json <<JSON
|
||||
{"auths":{"harbor.basicstack.de":{"auth":"$AUTH"}}}
|
||||
JSON
|
||||
kubectl -n harbor create secret generic crane-mirror-cfg \
|
||||
--from-file=config.json=/tmp/dc.json \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
rm /tmp/dc.json
|
||||
|
||||
cat <<YAML | kubectl apply -f -
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: crane-mirror-restic-${NEW_TAG//./-}
|
||||
namespace: harbor
|
||||
spec:
|
||||
backoffLimit: 1
|
||||
ttlSecondsAfterFinished: 600
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: crane
|
||||
image: gcr.io/go-containerregistry/crane:v0.21.9
|
||||
env: [{name: DOCKER_CONFIG, value: /docker}]
|
||||
args:
|
||||
- copy
|
||||
- docker.io/restic/restic:${NEW_TAG}
|
||||
- harbor.basicstack.de/library/restic:${NEW_TAG}
|
||||
volumeMounts:
|
||||
- {name: docker-config, mountPath: /docker, readOnly: true}
|
||||
volumes:
|
||||
- name: docker-config
|
||||
secret: {secretName: crane-mirror-cfg}
|
||||
YAML
|
||||
|
||||
kubectl -n harbor wait --for=condition=complete \
|
||||
job/crane-mirror-restic-${NEW_TAG//./-} --timeout=5m
|
||||
kubectl -n harbor logs job/crane-mirror-restic-${NEW_TAG//./-} | tail -20
|
||||
```
|
||||
|
||||
3. **Sanity-check the artifact** — the Harbor digest must match the
|
||||
digest crane just pushed, and an anonymous pull must resolve:
|
||||
|
||||
```sh
|
||||
ADMIN_PW=$(kubectl -n harbor get secret harbor-secrets \
|
||||
-o jsonpath='{.data.harborAdminPassword}' | base64 -d)
|
||||
curl -sk -u "admin:$ADMIN_PW" \
|
||||
"https://harbor.basicstack.de/api/v2.0/projects/library/repositories/restic/artifacts" \
|
||||
| jq -r '.[] | "digest=\(.digest) tags=\((.tags//[])|map(.name)|join(","))"'
|
||||
```
|
||||
|
||||
4. **Clean up the mirror secret and Job** (the Job also TTLs itself
|
||||
in 10 min):
|
||||
|
||||
```sh
|
||||
kubectl -n harbor delete secret crane-mirror-cfg
|
||||
kubectl -n harbor delete job crane-mirror-restic-${NEW_TAG//./-} \
|
||||
--ignore-not-found
|
||||
```
|
||||
|
||||
5. **Bump the manifest.** Update the three files under `apps/monitoring/`
|
||||
in the `basicstack-repo`:
|
||||
|
||||
- `backup-loki-restic-cronjob.yaml`
|
||||
- `backup-grafana-restic-cronjob.yaml`
|
||||
- `backup-k8s-resources-cronjob.yaml`
|
||||
|
||||
Each references `harbor.basicstack.de/library/restic:<tag>` under
|
||||
the `restic` container. Bump `<tag>` in all three, commit, push.
|
||||
|
||||
6. **Let Argo sync** — the `monitoring` Application picks up the new
|
||||
manifests. Watch for successful reconciliation:
|
||||
|
||||
```sh
|
||||
kubectl -n argocd get application monitoring \
|
||||
-o jsonpath='{.status.sync.status}{" "}{.status.health.status}{"\n"}'
|
||||
```
|
||||
|
||||
7. **Trigger one CronJob run to prove the Harbor pull is green.**
|
||||
Create a manual Job from any of the CronJobs (loki is fine) and
|
||||
inspect its logs:
|
||||
|
||||
```sh
|
||||
kubectl -n monitoring create job --from=cronjob/backup-loki-restic \
|
||||
smoke-restic-$(date +%Y%m%d-%H%M%S)
|
||||
```
|
||||
|
||||
The Pod should pull from `harbor.basicstack.de/library/restic:<tag>`
|
||||
(`kubectl describe pod ...` → Events → `Pulling image …`) and
|
||||
finish with `restic backup /source` output ending in a snapshot id.
|
||||
|
||||
8. **Update this doc's "restore pod" template** if the new tag is
|
||||
incompatible with the existing restore pod command — the drill pod
|
||||
image must match the CronJob image, otherwise the restore-drill
|
||||
won't round-trip.
|
||||
|
||||
**Rollback:** if the new tag misbehaves, edit the three manifests back
|
||||
to the last-known-good tag, commit, and let Argo re-sync. The old
|
||||
tag's manifest remains in Harbor until it's explicitly deleted, so
|
||||
rollback is a manifest change only.
|
||||
|
||||
## Drill log
|
||||
|
||||
Every drill appends to this section. Include:
|
||||
|
||||
- date (UTC), operator, snapshot IDs restored, parity result,
|
||||
restic check result, cleanup confirmation.
|
||||
|
||||
### 2026-08-16 — First drill (DEV-488)
|
||||
|
||||
- Operator: CTO agent, run [DEV-488](/DEV/issues/DEV-488).
|
||||
- Cluster: k3s at `178.105.17.239` (Hetzner).
|
||||
- Snapshots:
|
||||
- `restic/loki`: `97092888` (2026-08-16 15:44:21Z), parent `98c6fee6`
|
||||
(2026-08-16 15:26:10Z). Both tag=`loki`, host=`k3s`, paths=`/source`.
|
||||
- `restic/k8s-resources`: `9b0155cf` (2026-08-16 15:40:28Z),
|
||||
tag=`k8s-resources`, paths=`/cluster.yaml`.
|
||||
- Restore output:
|
||||
- loki: `Restored 14 files/dirs (292 B) in 0:00` — matches live PVC
|
||||
which is nearly empty (fresh Loki, no ingested chunks yet).
|
||||
- k8s-resources: `Restored 1 files/dirs (11.167 MiB) in 0:00` —
|
||||
`cluster.yaml` = 11 709 295 bytes, sha256
|
||||
`5aa93f7326b5cbd2408cb763395bba259de6cc0f99b0c8e1bca4421edc7931c0`.
|
||||
- Parity — loki restored tree vs. live loki-storage-encrypted PVC:
|
||||
|
||||
| file | restored sha256 | live sha256 | match |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------ | ----------- | ----- |
|
||||
| `boltdb-shipper-active/uploader/name` | `51215284bd61bf79d48ccd1aec59445bfa28dec7b2cdce4d9476bc0b61342813` | identical | yes |
|
||||
| `chunks/loki_cluster_seed.json` | `8d214a355d0057586e1c142d5f5d2658ccec39bd66e193b37dc12c21d6d7edd5` | identical | yes |
|
||||
| `wal/checkpoint.019215/00000000` | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | identical | yes |
|
||||
| `wal/00019216` | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | identical | yes |
|
||||
|
||||
- k8s-resources sanity: `cluster.yaml` opens with a valid v1
|
||||
Namespace list including `argocd`, `basicstack-web`, `monitoring`
|
||||
etc. No parse errors.
|
||||
- `restic check --read-data-subset=5%` was run by the writing
|
||||
CronJob on both repos; drill relied on that. Re-running end-to-end
|
||||
`restic check` is a follow-up for the quarterly drill.
|
||||
- Cleanup: pod + `restore-drill` namespace deleted; scratch tree
|
||||
discarded with the emptyDir.
|
||||
|
||||
**Outcome: PASS** for `restic/loki` and `restic/k8s-resources`.
|
||||
|
||||
Next drill target: 2026-11-16 (quarterly). Include `restic/grafana`
|
||||
once its first CronJob run has produced ≥ 1 snapshot.
|
||||
|
||||
### 2026-08-16 — Prometheus migration drill (DEV-492)
|
||||
|
||||
Migrated `prometheus-backup` from `rclone sync` (plaintext at rest) to
|
||||
restic (client-side encrypted). Same-day full-cycle drill against the
|
||||
freshly-populated `restic/prometheus` repo.
|
||||
|
||||
- Operator: CTO agent, run [DEV-492](/DEV/issues/DEV-492).
|
||||
- Repo: `s3:${S3_ENDPOINT}/${S3_BUCKET}/restic/prometheus`, initialised
|
||||
by first CronJob run (repo id `f0eda506fd`).
|
||||
- Backup runs (manual, from the deployed CronJob):
|
||||
- `71420465` (2026-08-16 16:16:35Z) — first run, cold repo,
|
||||
`restic backup` walk 5:10, 85 files 42 dirs, 8.017 GiB added
|
||||
(2.441 GiB stored after compression + dedup), exit 0. `restic
|
||||
check --read-data-subset=5%`: no errors.
|
||||
- `825f983a` (2026-08-16 16:23:56Z) — second run, warm repo, 17 s
|
||||
total (no new blocks — dedup 100 %), `restic check` clean.
|
||||
- Drill: `harbor.basicstack.de/library/restic:0.17.3` (restore) +
|
||||
`prom/prometheus:v2.53.1` (promtool) sidecars sharing an `emptyDir`
|
||||
in namespace `restore-drill-dev492`.
|
||||
- `restic restore latest --tag prometheus --target /work`:
|
||||
`Restored 127 files/dirs (8.069 GiB) in 0:34`.
|
||||
- Excluded paths verified absent on restored tree: `wal/`,
|
||||
`chunks_head/`, `lost+found/` restored as empty dirs; `lock` and
|
||||
`queries.active` not present at all.
|
||||
- `promtool tsdb list /work/source` — 19 blocks parsed, all with
|
||||
valid ULID + duration + samples/chunks/series counts.
|
||||
- `promtool tsdb analyze /work/source <block>` — ran against every
|
||||
block dir; 0 failed. Summary lines validated non-zero `Total
|
||||
Series` and reasonable `Duration` (2h / 18h / 54h buckets, matching
|
||||
Prometheus's compaction schedule).
|
||||
- End-to-end `restic check`: `no errors were found` (metadata pass;
|
||||
on the freshly-initialised repo `--read-data-subset=5%` was
|
||||
already exercised by the writing CronJob).
|
||||
- Cleanup: `restore-drill-prom` pod + `restore-drill-dev492` namespace
|
||||
deleted; scratch tree discarded with the `emptyDir`. Manual
|
||||
`prom-restic-manual-{01,02}` jobs deleted from `monitoring`.
|
||||
|
||||
**Outcome: PASS** for `restic/prometheus`. All Definition-of-Done
|
||||
items on [DEV-492](/DEV/issues/DEV-492) satisfied — backup encrypted
|
||||
at rest, `promtool tsdb analyze` clean, docs updated, restic password
|
||||
handling documented (shared `monitoring-s3-backup` SealedSecret,
|
||||
rotation procedure covers all four repos).
|
||||
|
||||
Next drill target for `restic/prometheus`: 2026-11-16 alongside the
|
||||
other three repos.
|
||||
14
infrastructure/CP1_UPDATE_PROCEDURE.md
Normal file
14
infrastructure/CP1_UPDATE_PROCEDURE.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# k3s-cp-1 OS Update Procedure — SUPERSEDED
|
||||
|
||||
**Superseded by:** [`CP_UPDATE_PROCEDURE.md`](CP_UPDATE_PROCEDURE.md) (DEV-515, 2026-08-23).
|
||||
|
||||
**Why:** this document was written when cp-1 was the **sole** control-plane node and the datastore was embedded SQLite via kine. After [DEV-510](/DEV/issues/DEV-510) the cluster runs HA embedded etcd across `k3s-cp-1`, `k3s-cp-2`, `k3s-cp-3`, and all three CPs are `NoSchedule`-tainted (they host no StatefulSets or single-replica Deployments anymore). The single-CP kine cascade risk, the batched stateful eviction dance, and the external-`/livez` polling pattern no longer describe the current cluster.
|
||||
|
||||
The generalized procedure covers cp-1 and cp-2 and cp-3. Use it.
|
||||
|
||||
**Scripts:**
|
||||
|
||||
- `infrastructure/scripts/os-update/update-cp-node.sh <node>` — the HA-aware entry point.
|
||||
- `infrastructure/scripts/os-update/update-cp-1.sh` — thin wrapper (calls `update-cp-node.sh k3s-cp-1 "$@"`). Kept for backwards compatibility with any existing runbook that names it explicitly.
|
||||
|
||||
**History note:** the original phase design (add swap → preflight → batched stateful eviction → drain → apt → reboot → finalize) is preserved in git history at commit `19af691` and `989033d` for future reference. The new procedure retains Phases A/B/D/E/F essentially unchanged and drops Phase C (batched stateful eviction) as obsolete.
|
||||
298
infrastructure/CP_UPDATE_PROCEDURE.md
Normal file
298
infrastructure/CP_UPDATE_PROCEDURE.md
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
# HA k3s Control-Plane OS Update Procedure
|
||||
|
||||
**Purpose:** apply Ubuntu OS updates (kernel, security, apt) to any control-plane node in the HA k3s cluster (`k3s-cp-1`, `k3s-cp-2`, `k3s-cp-3`) without losing etcd quorum and without a longer api-server outage than a normal reboot.
|
||||
|
||||
**Audience:** the CTO agent, or an operator with root SSH to the cluster. Execution is board-approval-gated — see `## Approval gates` below.
|
||||
|
||||
**Supersedes:** the earlier single-CP `CP1_UPDATE_PROCEDURE.md` (kept as a redirect stub). The cp-1-only procedure was written when cp-1 was the sole api-server and the datastore was embedded SQLite via kine. After [DEV-510](/DEV/issues/DEV-510) (2026-08-22), the cluster runs HA embedded etcd across cp-1/cp-2/cp-3 and all three CPs are `NoSchedule`-tainted, so:
|
||||
|
||||
- **The kine cascade is no longer the driving risk.** The datastore is etcd 3.6.12 with 3-node quorum; a single CP drain no longer leaves a lone SQLite writer. The kubectl-latency guardrail stays as a sanity check but is now a much softer signal.
|
||||
- **CPs host no StatefulSet or single-replica Deployment workloads.** The pre-drain stateful eviction dance from the old cp-1 doc no longer applies — CPs only host DaemonSets + `metrics-server` (currently on cp-2) + occasional CronJob completions.
|
||||
- **The api-server is not lost on reboot.** During a CP reboot the other two apiservers keep serving. `/livez` is polled from the *other* apiservers, not from an external machine forced to poll the one being rebooted.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope**
|
||||
|
||||
- Add a durable ≥ 2 GiB swapfile on any CP that has zero swap (idempotent one-off — `--add-swap`).
|
||||
- k3s etcd snapshot as the restore point.
|
||||
- `kubectl cordon` + `kubectl drain` on the target CP.
|
||||
- `apt-get update/upgrade/dist-upgrade/autoremove` on the target CP.
|
||||
- Controlled reboot with etcd-quorum-aware liveness monitoring.
|
||||
- Uncordon + cluster health verify.
|
||||
|
||||
**Out of scope — do NOT do here**
|
||||
|
||||
- Any change to `/etc/rancher/k3s/*`, `/etc/systemd/system/k3s*.service*`, or the k3s binary version. k3s upgrades go through system-upgrade-controller (see `K3S_OPERATIONS.md`).
|
||||
- Any change to manifests under `apps/`, `infrastructure/`, or applied via ArgoCD.
|
||||
- Deleting PVs / PVCs. Any pod that gets rescheduled off the target CP stays on its new node.
|
||||
- Fixing application-level problems.
|
||||
- Rebuilding the node — if a CP does not return after reboot, escalate; the rebuild path is `ADD_WORKER_NODE.md` plus board approval, not this document.
|
||||
|
||||
---
|
||||
|
||||
## Approval gates
|
||||
|
||||
This procedure has **two** independent gates. Neither happens without explicit board approval on the corresponding Paperclip issue:
|
||||
|
||||
1. **Add swap (Phase A).** Non-invasive, non-state-mutating, kubelet already runs with `failSwapOn=false`. Requires board approval per stateful-service safety rules because it modifies a CP node.
|
||||
2. **Full OS update (Phases B–E).** Requires board approval because it drains + reboots one of the etcd members. Do NOT execute without an explicit `request_board_approval` acceptance on the execution ticket.
|
||||
|
||||
Both gates are independent — swap can (and should) be added first, in a quiet window, before the full update is scheduled.
|
||||
|
||||
---
|
||||
|
||||
## CP ordering rule (multiple CPs in one cycle)
|
||||
|
||||
- **One at a time. Never two CPs cordoned or draining at once.** Two of three CPs down = etcd quorum loss = api-server unavailability for the whole cluster.
|
||||
- **Leader last.** Query the current etcd leader before starting; update the two followers first (in any order), then the leader. Rationale: draining a follower is a no-op for the raft leader; draining the leader forces a re-election. Doing followers first minimises leader flapping.
|
||||
- **Health gate between CPs.** After each CP finishes Phase F (finalize) and cluster health is green, wait at least `POST_UNCORDON_WAIT_SECONDS` (default 180 s) and re-check etcd endpoint status before touching the next CP. This gives etcd time to fully re-sync the just-rebooted member.
|
||||
- **Halt on any yellow.** Any of the following aborts the cycle at the current CP (do NOT proceed to the next CP): etcd reports a member as not `started`, `kubectl get nodes` > 5 s, any node not `Ready`, any workload deployment/sts under desired replicas.
|
||||
|
||||
Query the leader:
|
||||
|
||||
```bash
|
||||
ssh root@$CP1_HOST bash -c '
|
||||
ETCDCTL_API=3 etcdctl \
|
||||
--endpoints=https://127.0.0.1:2379 \
|
||||
--cacert=/var/lib/rancher/k3s/server/tls/etcd/server-ca.crt \
|
||||
--cert=/var/lib/rancher/k3s/server/tls/etcd/server-client.crt \
|
||||
--key=/var/lib/rancher/k3s/server/tls/etcd/server-client.key \
|
||||
endpoint status --cluster -w table'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current CP workload (snapshot 2026-08-23)
|
||||
|
||||
Re-derive live before any execution — this table is a design reference only.
|
||||
|
||||
| Node | Kernel | Swap | StatefulSets | Single-replica Deployments | DaemonSets |
|
||||
|------|--------|------|--------------|-----------------------------|------------|
|
||||
| k3s-cp-1 | 6.8.0-137 | **0 B** | 0 | 0 | node-exporter, promtail, hcloud-csi-node, svclb-* |
|
||||
| k3s-cp-2 | 6.8.0-137 | **0 B** | 0 | metrics-server (evictable) | node-exporter, promtail, hcloud-csi-node, svclb-* |
|
||||
| k3s-cp-3 | 6.8.0-137 | **0 B** | 0 | 0 | node-exporter, promtail, hcloud-csi-node, svclb-* |
|
||||
|
||||
etcd datastore live snapshot: 38 MiB per member, all three `started`, cp-3 is current leader.
|
||||
|
||||
Because CPs are `NoSchedule`-tainted, no workload will end up on them via normal scheduling — the pre-drain stateful redistribution loop from the old cp-1 procedure is obsolete.
|
||||
|
||||
---
|
||||
|
||||
## Phase A — Add swap (one-off, idempotent)
|
||||
|
||||
**Goal:** eliminate the "3.7 GiB, no swap" underlying constraint on any CP before ever draining it. All three CPs currently have zero swap; add swap once per CP.
|
||||
|
||||
**Preconditions:**
|
||||
|
||||
- kubelet in this k3s already runs with `failSwapOn=false` (confirmed on cp-1 via `/api/v1/nodes/k3s-cp-1/proxy/configz`) — enabling swap does NOT break the kubelet.
|
||||
- Target CP's `/` has ≥ `2 * SWAP_SIZE_MB` free.
|
||||
- Board approval on the swap-add ticket.
|
||||
|
||||
**Sizing:** default **4 GiB** swap. Rationale — cp-1 baseline (k3s + hosted daemons) sits at ~2 GiB used; 4 GiB swap gives us headroom for the drain-eviction transient without inflating disk usage past ~10 % of `/`. Minimum acceptable per guardrail: 2 GiB.
|
||||
|
||||
**Location:** `/swapfile` (root filesystem). Not a separate partition — reversible, no LVM changes.
|
||||
|
||||
**Steps (encoded in `update-cp-node.sh <node> --add-swap`):** identical to the old cp-1 procedure — `fallocate` → `mkswap` → `swapon` → `/etc/fstab` → `vm.swappiness=10` in `/etc/sysctl.d/99-k3s-swap.conf`. Idempotent: skips if a swapfile of the target size is already active. Full script source in `scripts/os-update/update-cp-node.sh`.
|
||||
|
||||
**Phase A also installs `etcd-client`** on the target CP if `etcdctl` is missing. This is a hard requirement for the etcd-quorum probes used in Phase B/E when the CP is later itself the update target and one of the *other* CPs must query etcd cluster status. cp-1 already has `etcdctl` (installed pre-HA); cp-2 and cp-3 pick it up here.
|
||||
|
||||
**Rollback for Phase A:** `swapoff /swapfile && rm /swapfile` and remove the fstab line. Safe at any time — swap is a soft resource.
|
||||
|
||||
**Verification after Phase A:**
|
||||
|
||||
- `free -h` shows `Swap: 4.0Gi` used ≈ 0.
|
||||
- `swapon --show` shows `/swapfile 4G`.
|
||||
- `sysctl vm.swappiness` returns `10`.
|
||||
- kubelet still Ready (`kubectl get node <node>`).
|
||||
- No new `MemoryPressure` condition.
|
||||
|
||||
---
|
||||
|
||||
## Phase B — Preflight
|
||||
|
||||
Runs from the operator machine (or from any healthy CP). Every command's stdout+stderr goes to `/tmp/os-update-<node>-<UTC-timestamp>.log` on the operator machine. Attach that log to the Paperclip execution ticket at the end.
|
||||
|
||||
Run `update-cp-node.sh <node> --preflight`. Checks:
|
||||
|
||||
1. **Cluster is currently healthy:** `cluster-health.sh` returns 0.
|
||||
2. **Target node is a CP:** `kubectl get node <node>` carries the `node-role.kubernetes.io/control-plane` label.
|
||||
3. **All 3 CPs are healthy etcd members:** `etcdctl endpoint status --cluster` shows every member `started`, no errors.
|
||||
4. **Only one CP is being updated this cycle:** no other CP is currently cordoned.
|
||||
5. **Target CP has swap on:** abort if `free -h` shows `Swap: 0B`. Run `--add-swap` first.
|
||||
6. **kubectl latency probe:** `time kubectl get nodes` returns in ≤ 5 s.
|
||||
7. **etcd snapshot** (restore point): `k3s etcd-snapshot save --name pre-cp-os-update-<node>-<UTC-timestamp>` executed on the target CP.
|
||||
8. **Records the current etcd leader** — if the target *is* the leader, prints a warning ("prefer updating a follower first"). Does NOT auto-swap the target; the operator/agent makes the call per the CP ordering rule.
|
||||
|
||||
If any preflight check fails: STOP. Do not proceed.
|
||||
|
||||
---
|
||||
|
||||
## Phase C — Cordon + drain the target CP
|
||||
|
||||
CPs are `NoSchedule`-tainted, so cordon is mostly a belt-and-suspenders measure. Drain evicts the small tail of not-DaemonSet workloads (`metrics-server` currently lives on cp-2).
|
||||
|
||||
```bash
|
||||
kubectl cordon <node>
|
||||
kubectl drain <node> \
|
||||
--ignore-daemonsets \
|
||||
--delete-emptydir-data \
|
||||
--timeout="${DRAIN_TIMEOUT_SECONDS:-600}s"
|
||||
```
|
||||
|
||||
If drain reports a PDB block: do NOT `--force`. Uncordon the node, mark the run `blocked` on the PDB, and escalate. This is a workload PDB bug — fix separately.
|
||||
|
||||
**Kine-cascade guardrail (softened for HA etcd):** during drain, `time kubectl get nodes` should still return in ≤ 5 s. Because we now have 3-member etcd (not lone SQLite kine), a single-CP drain does not create the write-amplification hazard from [DEV-495](/DEV/issues/DEV-495). But `> 5 s` still indicates something is wrong (etcd slow disk, leader flapping) — halt and investigate.
|
||||
|
||||
---
|
||||
|
||||
## Phase D — apt on the target CP
|
||||
|
||||
Identical to the worker-node apt block (`update-node.sh` step 3). `update-cp-node.sh <node> --apt` runs this via `ssh root@<node> bash -s`:
|
||||
|
||||
```bash
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
APT_OPTS='-y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold'
|
||||
uname -r > /root/pre-apt-kernel
|
||||
dpkg-query -W -f='${Package}\t${Version}\n' > /root/pre-apt-packages.tsv
|
||||
|
||||
if dpkg --audit | grep -qE .; then dpkg --configure -a || true; fi
|
||||
apt-get update
|
||||
apt-get $APT_OPTS upgrade || { apt-get $APT_OPTS -f install; apt-get $APT_OPTS upgrade; }
|
||||
apt-get $APT_OPTS dist-upgrade
|
||||
apt-get $APT_OPTS autoremove --purge
|
||||
apt-get clean
|
||||
[ -f /var/run/reboot-required ] && echo REBOOT_REQUIRED=yes || echo REBOOT_REQUIRED=no
|
||||
```
|
||||
|
||||
`pre-apt-kernel` and `pre-apt-packages.tsv` are the local rollback references — see Rollback below.
|
||||
|
||||
---
|
||||
|
||||
## Phase E — Reboot handling (etcd-quorum-aware)
|
||||
|
||||
**Duration expectation:** 90–180 s of *this member's* api-server unavailability. The other two apiservers keep serving; the operator machine's kubectl continues to work via one of them.
|
||||
|
||||
**Escalation trigger:** rebooted apiserver not back on `/livez` after **10 minutes** → escalate. First check the Hetzner console via `hcloud server describe <node>` for boot state; if kernel-panic / initramfs, use grub previous-kernel path (see Rollback). Do NOT rebuild the node.
|
||||
|
||||
**Steps (executed by `update-cp-node.sh <node> --reboot`):**
|
||||
|
||||
```bash
|
||||
# 1. Reboot the target CP (ssh will hang up mid-command — expected).
|
||||
ssh $SSH_OPTS root@$TARGET_HOST 'systemctl reboot' || true
|
||||
sleep 15
|
||||
|
||||
# 2. Poll the TARGET's /livez from the operator machine. It is served on port 6443.
|
||||
# Insecure (`-k`) because the server cert is self-signed by k3s.
|
||||
deadline=$(( $(date +%s) + ${REBOOT_MAX_WAIT_SECONDS:-600} ))
|
||||
while [ $(date +%s) -lt $deadline ]; do
|
||||
code=$(curl -sk -o /dev/null -w '%{http_code}' https://$TARGET_HOST:6443/livez 2>/dev/null || echo 000)
|
||||
[ "$code" = "200" ] && break
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# 3. Verify etcd membership from a peer CP — the target should be back as `started`.
|
||||
ssh $SSH_OPTS root@$PEER_HOST '
|
||||
ETCDCTL_API=3 etcdctl \
|
||||
--endpoints=https://127.0.0.1:2379 \
|
||||
--cacert=/var/lib/rancher/k3s/server/tls/etcd/server-ca.crt \
|
||||
--cert=/var/lib/rancher/k3s/server/tls/etcd/server-client.crt \
|
||||
--key=/var/lib/rancher/k3s/server/tls/etcd/server-client.key \
|
||||
endpoint status --cluster -w table'
|
||||
|
||||
# 4. Wait for kubelet Ready on the target from the api-server view.
|
||||
deadline=$(( $(date +%s) + 300 ))
|
||||
while [ $(date +%s) -lt $deadline ]; do
|
||||
ready=$(kubectl get node "$TARGET_NODE" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo Unknown)
|
||||
[ "$ready" = "True" ] && break
|
||||
sleep 5
|
||||
done
|
||||
```
|
||||
|
||||
**What to do if the api-server takes 3–10 minutes:** it may be normal for a large etcd on a slow disk. Wait it out. Do NOT restart k3s to "help" — an etcd member catching up on raft log is stateful and interrupting it can slow the whole cluster.
|
||||
|
||||
**What to do if it takes > 10 minutes:** escalate; run the Hetzner-console diagnosis; if the console shows a bootable Ubuntu but the k3s service is failing, that's the boundary — this procedure stops here. Follow `K3S_OPERATIONS.md` for the k3s recovery path.
|
||||
|
||||
---
|
||||
|
||||
## Phase F — Uncordon + verify + finalize
|
||||
|
||||
```bash
|
||||
kubectl uncordon <node>
|
||||
sleep "${POST_UNCORDON_WAIT_SECONDS:-180}"
|
||||
RETRY_ON_TRANSIENT=1 infrastructure/scripts/os-update/cluster-health.sh
|
||||
ssh root@<node> 'zgrep -h "Commandline\|Install\|Upgrade\|Remove" /var/log/apt/history.log* 2>/dev/null | tail -60'
|
||||
```
|
||||
|
||||
Between CPs (when multiple CPs will be updated in the same cycle), also verify:
|
||||
|
||||
```bash
|
||||
ssh root@<peer-cp> '
|
||||
ETCDCTL_API=3 etcdctl \
|
||||
--endpoints=https://127.0.0.1:2379 \
|
||||
--cacert=/var/lib/rancher/k3s/server/tls/etcd/server-ca.crt \
|
||||
--cert=/var/lib/rancher/k3s/server/tls/etcd/server-client.crt \
|
||||
--key=/var/lib/rancher/k3s/server/tls/etcd/server-client.key \
|
||||
endpoint status --cluster -w table'
|
||||
```
|
||||
|
||||
All three members must be `started`, no errors, DB sizes within 10 % of each other.
|
||||
|
||||
Attach the `/tmp/os-update-<node>-<ts>.log` to the execution ticket. Mark the ticket `done` on green health, or `blocked` naming the specific residual issue.
|
||||
|
||||
---
|
||||
|
||||
## Rollback / recovery
|
||||
|
||||
### If apt broke a package
|
||||
- On the target CP, `dpkg --audit` to find half-configured packages.
|
||||
- `dpkg --configure -a`, then `apt-get -f install`.
|
||||
- If a specific package broke and you know the previous version from `/root/pre-apt-packages.tsv`, `apt-get install <pkg>=<old-version>`.
|
||||
|
||||
### If the new kernel does not boot
|
||||
- Hetzner cloud console: `hcloud server request-console <node>` → get VNC URL, watch boot.
|
||||
- If grub is up, select the previous-kernel entry. Ubuntu keeps ≥ 1 old kernel installed by default.
|
||||
- Once booted on the old kernel, `apt-get remove` the broken kernel and pin the working one:
|
||||
```bash
|
||||
apt-mark hold linux-image-<broken-version> linux-headers-<broken-version>
|
||||
```
|
||||
- Escalate on the ticket regardless — a kernel rollback is a follow-up investigation, not a "done" outcome.
|
||||
|
||||
### If the node does not return at all
|
||||
- Do NOT `hcloud server delete`. Do NOT re-provision.
|
||||
- Escalate to the board with the Hetzner console output and `/tmp/os-update-<node>-*.log`.
|
||||
- The `--cluster-reset --cluster-reset-restore-path=<snapshot>` recovery path exists (see `K3S_OPERATIONS.md`) but requires board approval per stateful-service safety rules. The pre-flight snapshot from Phase B is the restore point.
|
||||
- **HA-etcd advantage:** if one CP is permanently lost but the other two are healthy, you have quorum. Do not rush a `cluster-reset`. Follow the "member remove + re-add" path in the k3s docs first.
|
||||
|
||||
### If etcd quorum degrades mid-cycle
|
||||
- Two of three CPs down = write outage until quorum returns. Do NOT attempt to update the third.
|
||||
- Uncordon all CPs, restore the just-updated one first, and confirm all three members `started` before doing anything else.
|
||||
|
||||
---
|
||||
|
||||
## Automation entry points
|
||||
|
||||
- `infrastructure/scripts/os-update/update-cp-node.sh <node> [phase-flag]` — the generalized HA-aware flow.
|
||||
- `--add-swap` — Phase A only (idempotent, safe standalone).
|
||||
- `--dry-run` — walk all phases printing what would be done, no state change.
|
||||
- `--preflight` — Phase B only.
|
||||
- `--drain` — Phase C only (cordon + drain).
|
||||
- `--apt` — Phase D apt commands only.
|
||||
- `--reboot` — Phase E only.
|
||||
- `--finalize` — Phase F only.
|
||||
- `--run` — all phases with a confirmation between each unless `ASSUME_YES=1`.
|
||||
|
||||
- `infrastructure/scripts/os-update/update-cp-1.sh` — retained as a thin wrapper that calls `update-cp-node.sh k3s-cp-1 "$@"`. Historical callers keep working.
|
||||
|
||||
The script follows the same log-dir contract as `update-node.sh` (`/tmp/os-update-<node>-<UTC-timestamp>.log`).
|
||||
|
||||
---
|
||||
|
||||
## Change history
|
||||
|
||||
| Date | Change | By |
|
||||
|------|--------|-----|
|
||||
| 2026-08-16 | Initial cp-1-only procedure (DEV-496) | CTO agent |
|
||||
| 2026-08-23 | Generalized to HA (cp-1/cp-2/cp-3); dropped Phase C batched stateful eviction (CPs are NoSchedule-tainted now); softened kine cascade guardrail (embedded etcd 3-member quorum); added CP ordering rule (leader last, one at a time); introduced `update-cp-node.sh` (DEV-515) | CTO agent |
|
||||
343
infrastructure/OS_UPDATE_PROCEDURE.md
Normal file
343
infrastructure/OS_UPDATE_PROCEDURE.md
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
# Weekly Rolling Ubuntu OS Update Procedure
|
||||
|
||||
**Purpose:** Keep every k3s node's Ubuntu OS patched (kernel, security, package updates) with a **rolling, zero-downtime** update — one node at a time, drain → update → reboot → verify → uncordon → cluster health check → next node.
|
||||
|
||||
**Audience:** Agents (currently CTO, optionally a dedicated ClusterOps agent). Also usable manually by an operator.
|
||||
|
||||
**Scope — what this procedure does:**
|
||||
- Runs `apt-get update`, `apt-get -y upgrade`, `apt-get -y dist-upgrade`, `apt-get -y autoremove` on each cluster node.
|
||||
- Reboots the node if a reboot is required (kernel/libc updates).
|
||||
- Drains and cordons each node before touching it, uncordons after verification.
|
||||
- Verifies the node and cluster are healthy before moving on.
|
||||
|
||||
**Scope — what this procedure MUST NOT do:**
|
||||
- **Never change the Kubernetes / k3s setup.** Do not touch `/etc/systemd/system/k3s*.service*`, `/etc/rancher/k3s/*`, k3s binary version, k3s config, kube-system manifests, network policies, or any manifest under `infrastructure/`, `apps/`, or applied via ArgoCD.
|
||||
- **Do not upgrade k3s** here. k3s upgrades are handled separately by system-upgrade-controller (see `K3S_OPERATIONS.md` and `k3s-upgrade/`).
|
||||
- **Do not delete PersistentVolumes, PVCs, or workload manifests.**
|
||||
- Do not "fix" application-level problems on a node — that's out of scope. Only fix problems in the OS/apt/reboot layer of the current node being updated. Anything else → stop, report, escalate.
|
||||
|
||||
---
|
||||
|
||||
## Cluster topology (context)
|
||||
|
||||
| Role | Node | Private IP | Public IP | Datacenter | Notes |
|
||||
|------|------|-----------|-----------|------------|-------|
|
||||
| control-plane | k3s-cp-1 | 10.42.1.1 | 178.105.17.239 | fsn1 | EXCLUDED from `os-update.sh` — see [`CP_UPDATE_PROCEDURE.md`](CP_UPDATE_PROCEDURE.md) |
|
||||
| control-plane | k3s-cp-2 | — | 188.245.85.199 | fsn1 | EXCLUDED — see [`CP_UPDATE_PROCEDURE.md`](CP_UPDATE_PROCEDURE.md) |
|
||||
| control-plane | k3s-cp-3 | — | 49.13.92.162 | fsn1 | EXCLUDED — see [`CP_UPDATE_PROCEDURE.md`](CP_UPDATE_PROCEDURE.md) |
|
||||
| worker | k3s-worker-1 | 10.42.1.2 | (via CP) | fsn1 | |
|
||||
| worker | k3s-worker-2 | 10.42.1.3 | (via CP) | fsn1 | |
|
||||
| worker | k3s-worker-3 | 10.42.1.5 | 167.233.121.121 | fsn1 | |
|
||||
| worker | k3s-worker-4 | 10.42.1.6 | 128.140.3.80 | nbg1 | |
|
||||
| worker | k3s-worker-5 | 10.42.1.7 | 167.233.192.86 | fsn1 | |
|
||||
| runner | k3s-update-runner | 167.233.79.65 | 167.233.79.65 | fsn1 | k3s-upgrade helper, still an updatable node |
|
||||
|
||||
Always re-derive the live list before running — nodes may have been added/removed:
|
||||
|
||||
```bash
|
||||
ssh root@178.105.17.239 'kubectl get nodes -o wide'
|
||||
```
|
||||
|
||||
**Order rule:** update workers only. Within workers, update in this order to protect stateful workloads:
|
||||
1. runner + workers that host no PVs (safest — lowest disruption)
|
||||
2. remaining workers
|
||||
3. **Stalwart-hosting fsn1 workers last among workers** — Stalwart has hard fsn1 affinity, so draining a fsn1 worker while another fsn1 worker is also unavailable can leave Stalwart Pending. Never have two fsn1 workers cordoned/down at the same time.
|
||||
4. **ALL control-plane nodes excluded** — `os-update.sh` selects nodes without the `node-role.kubernetes.io/control-plane` label, so `k3s-cp-1`, `k3s-cp-2`, and `k3s-cp-3` are all skipped automatically. CPs have their own procedure ([`CP_UPDATE_PROCEDURE.md`](CP_UPDATE_PROCEDURE.md) — HA-aware, one CP at a time, leader last).
|
||||
|
||||
**Concurrency:** exactly one node at a time. Never in parallel.
|
||||
|
||||
---
|
||||
|
||||
## Kine thundering-herd guardrails (added after 2026-08-16 incident, see DEV-495)
|
||||
|
||||
**Historical context (single-CP kine era, 2026-08-16).** When the cluster ran a single CP with embedded SQLite via kine, draining a worker with many StatefulSets could trigger a self-amplifying eviction-and-slow-SQL cascade: kine backs up on writes → apiserver hangs → node-lease renewals fail → taint-eviction kicks in on more nodes → more writes → kine falls further behind → OOM risk on cp-1.
|
||||
|
||||
**Current state (HA-etcd era, since DEV-510 on 2026-08-22).** The datastore is now embedded etcd 3.6.12 with 3-node quorum across cp-1/cp-2/cp-3. A single-worker drain no longer creates the lone-SQLite writer risk, and etcd tolerates one member being slow or briefly unreachable. Guardrails (1) and (3) below are retained as sanity checks but the failure mode they were named for is much less likely to trigger. Guardrail (2) is largely done (add swap on cp-1/cp-2/cp-3 tracked in the CP update procedure).
|
||||
|
||||
All four rules below MUST be observed on every DEV-478 fire. The reference implementation (`os-update.sh`) does not yet enforce (1)/(3) automatically; the operator must actively watch.
|
||||
|
||||
1. **Pre-plan drain order for StatefulSets.** Before draining any worker, `kubectl get pods -n <ns> -o wide` against every namespace with StatefulSets and count how many will be evicted from the target node. If a single drain would evict **more than 3 StatefulSets at once**, redistribute first: cordon+delete individual StatefulSet pods one namespace at a time and wait for each reschedule to settle before draining the whole node.
|
||||
2. **All CPs must have swap before their update cycle.** cp-1/cp-2/cp-3 currently all have 0 swap. Add at least 2 GiB (default 4 GiB) of swap on each CP before its OS-update run — see Phase A in [`CP_UPDATE_PROCEDURE.md`](CP_UPDATE_PROCEDURE.md). This is a one-off setup task per CP; once done it is a durable capability.
|
||||
3. **Halt on kine slowness.** During any drain, keep a `time kubectl get nodes` running from cp-1. If it exceeds **5 s** in real time, halt the cycle immediately (uncordon the current node, do not proceed), verify cluster health, and escalate. The 5 s threshold is the leading indicator that kine has fallen behind and the taint-eviction cascade is about to start.
|
||||
4. **CP updates are a separate design task.** With HA etcd across three CPs the risk is now etcd quorum loss (two CPs down simultaneously), not the sole-apiserver reboot. CP updates MUST be planned and board-approved per node; they are NOT covered by the standard `os-update.sh` cycle. See [`CP_UPDATE_PROCEDURE.md`](CP_UPDATE_PROCEDURE.md) and `scripts/os-update/update-cp-node.sh` (DEV-515). The old cp-1-only doc `CP1_UPDATE_PROCEDURE.md` is a redirect stub.
|
||||
|
||||
---
|
||||
|
||||
## Access
|
||||
|
||||
Prereqs are the same as `CLUSTER_ACCESS.md`:
|
||||
- SSH key for `root` on every node (jump via control plane for private-IP workers).
|
||||
- `kubectl` available (either from the operator machine, or by SSH-ing to the control plane and using it there).
|
||||
- `hcloud` CLI configured (only needed for firewall-related recovery — not for normal runs).
|
||||
|
||||
Environment variables the scripts expect:
|
||||
- `CONTROL_PLANE_HOST` — default `178.105.17.239`
|
||||
- `CONTROL_PLANE_PRIVATE` — default `10.42.1.1`
|
||||
- Drain timeout: `DRAIN_TIMEOUT_SECONDS` — default `600`
|
||||
- Reboot wait: `REBOOT_MAX_WAIT_SECONDS` — default `600`
|
||||
- Post-uncordon settle: `POST_UNCORDON_WAIT_SECONDS` — default `180`
|
||||
|
||||
---
|
||||
|
||||
## Preflight (run once per cycle, before touching any node)
|
||||
|
||||
1. **Cluster is currently healthy.** If any of the checks below fail, STOP and open an issue — do not start OS updates on an already-degraded cluster.
|
||||
```bash
|
||||
ssh root@$CONTROL_PLANE_HOST bash -s <<'EOF'
|
||||
set -e
|
||||
kubectl get nodes
|
||||
echo "--- Not-ready nodes:"
|
||||
kubectl get nodes -o json | jq -r '.items[] | select(.status.conditions[] | select(.type=="Ready" and .status!="True")) | .metadata.name'
|
||||
echo "--- Pods not Running/Completed:"
|
||||
kubectl get pods -A --field-selector=status.phase!=Running,status.phase!=Succeeded | grep -v 'STATUS' || echo " (none)"
|
||||
echo "--- Non-Ready pods (Running but not Ready):"
|
||||
kubectl get pods -A -o json | jq -r '.items[] | select(.status.phase=="Running") | select([.status.conditions[]?|select(.type=="Ready")|.status]|contains(["False"])) | "\(.metadata.namespace)/\(.metadata.name)"'
|
||||
EOF
|
||||
```
|
||||
Only proceed if: all nodes `Ready`, no non-Running/Succeeded pods, no Running-but-not-Ready pods (small transient counts are OK — retry once and continue if it clears).
|
||||
|
||||
2. **Snapshot k3s datastore** (control plane only — this is a checkpoint you can restore etcd from if the control-plane reboot goes badly):
|
||||
```bash
|
||||
ssh root@$CONTROL_PLANE_HOST 'k3s etcd-snapshot save --name pre-os-update-$(date +%Y%m%d)'
|
||||
ssh root@$CONTROL_PLANE_HOST 'k3s etcd-snapshot list | tail -5'
|
||||
```
|
||||
|
||||
3. **List the nodes to update** and derive the ordered plan. Persist to `/tmp/os-update-plan.txt` on the control plane for auditability. See `scripts/os-update/os-update.sh` for the reference implementation of the ordering rule.
|
||||
|
||||
---
|
||||
|
||||
## Per-node procedure
|
||||
|
||||
Repeat for each node in the ordered plan. The reference implementation is `infrastructure/scripts/os-update/update-node.sh <node-name>`; the manual steps below are what that script does.
|
||||
|
||||
Throughout: every command's stdout+stderr goes to a per-run log at `/tmp/os-update-<node>-<timestamp>.log` on the control plane. Attach the log to the issue at the end.
|
||||
|
||||
### 1. Pre-check the node
|
||||
|
||||
```bash
|
||||
NODE=<node-name>
|
||||
kubectl get node "$NODE"
|
||||
kubectl describe node "$NODE" | grep -A2 Conditions
|
||||
```
|
||||
|
||||
Confirm: `Ready=True`, not already cordoned, no `DiskPressure`/`MemoryPressure`/`PIDPressure`.
|
||||
|
||||
### 2. Cordon and drain
|
||||
|
||||
```bash
|
||||
kubectl cordon "$NODE"
|
||||
kubectl drain "$NODE" \
|
||||
--ignore-daemonsets \
|
||||
--delete-emptydir-data \
|
||||
--disable-eviction=false \
|
||||
--timeout="${DRAIN_TIMEOUT_SECONDS:-600}s"
|
||||
```
|
||||
|
||||
If drain fails on a PodDisruptionBudget:
|
||||
- Do NOT force-delete pods (breaks HA guarantees).
|
||||
- Log the blocking PDB, uncordon the node, mark the node as `SKIPPED_PDB` in the plan, and continue with the next node. Escalate the PDB conflict on the issue at the end.
|
||||
|
||||
If drain fails on a lone pod without a controller:
|
||||
- Do NOT `--force`. Same as above — uncordon, mark `SKIPPED_ORPHAN_POD`, continue.
|
||||
|
||||
### 3. Update the OS
|
||||
|
||||
On the node itself:
|
||||
|
||||
```bash
|
||||
ssh -o StrictHostKeyChecking=accept-new root@<node-ssh-target> bash -s <<'REMOTE'
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
# Refresh package lists
|
||||
apt-get update
|
||||
# Configure apt to keep existing config files silently (no interactive prompts)
|
||||
APT_OPTS='-y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold'
|
||||
apt-get $APT_OPTS upgrade
|
||||
apt-get $APT_OPTS dist-upgrade
|
||||
apt-get $APT_OPTS autoremove --purge
|
||||
apt-get clean
|
||||
# Report reboot need
|
||||
if [ -f /var/run/reboot-required ]; then
|
||||
echo "REBOOT_REQUIRED=yes"
|
||||
cat /var/run/reboot-required.pkgs 2>/dev/null || true
|
||||
else
|
||||
echo "REBOOT_REQUIRED=no"
|
||||
fi
|
||||
REMOTE
|
||||
```
|
||||
|
||||
For private-IP workers, target them from the control plane (`ssh -J root@$CONTROL_PLANE_HOST root@10.42.1.X`) or run the whole block after SSH-ing to the control plane first.
|
||||
|
||||
Common OS-only fixes the agent MAY perform on the node if apt fails:
|
||||
- `dpkg --configure -a` after an interrupted install
|
||||
- `apt-get -f install` to resolve broken deps
|
||||
- Free disk with `journalctl --vacuum-time=3d` or `apt-get clean` if `/` is full
|
||||
- Restart a system service that is stuck (`systemctl restart <unit>`) — but NOT `k3s`, `k3s-agent`, `containerd`, `flanneld`, or any container runtime
|
||||
|
||||
**Never**: change k3s config, delete PVs, uninstall packages the OS didn't schedule, edit `/etc/rancher/`, or reinstall k3s. If a fix would touch any of those, stop and escalate.
|
||||
|
||||
### 4. Reboot if required
|
||||
|
||||
If `REBOOT_REQUIRED=yes`:
|
||||
|
||||
```bash
|
||||
ssh root@<node-ssh-target> 'systemctl reboot' || true
|
||||
# Wait until SSH responds again (max REBOOT_MAX_WAIT_SECONDS)
|
||||
deadline=$(( $(date +%s) + ${REBOOT_MAX_WAIT_SECONDS:-600} ))
|
||||
while [ $(date +%s) -lt $deadline ]; do
|
||||
sleep 10
|
||||
if ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new root@<node-ssh-target> 'uptime' 2>/dev/null; then
|
||||
echo "Node back up"; break
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
If the node doesn't come back within the timeout: escalate immediately. Do NOT rebuild the node or touch k3s — a rebuild requires the ADD_WORKER_NODE procedure and is a separate approved action.
|
||||
|
||||
### 5. Wait for k3s-agent / k3s to be ready again
|
||||
|
||||
```bash
|
||||
# Wait for kubelet Ready condition (control plane view)
|
||||
deadline=$(( $(date +%s) + 300 ))
|
||||
while [ $(date +%s) -lt $deadline ]; do
|
||||
READY=$(kubectl get node "$NODE" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}')
|
||||
[ "$READY" = "True" ] && break
|
||||
sleep 5
|
||||
done
|
||||
kubectl get node "$NODE"
|
||||
```
|
||||
|
||||
If Ready never returns True: escalate. Do NOT change k3s config.
|
||||
|
||||
### 5b. Reconcile Docker on labeled nodes (before uncordon)
|
||||
|
||||
For nodes carrying the `basicstack.de/docker=true` label (currently the workers that run the Forgejo runner), ensure `docker.io` is present + running before scheduling resumes. Without this the runner pod comes back `ContainerCreating` because `hostPath` requires the docker socket to exist (see [DEV-498](/DEV/issues/DEV-498) and [DEV-499](/DEV/issues/DEV-499)).
|
||||
|
||||
```bash
|
||||
scripts/os-update/ensure-node-docker.sh "$NODE"
|
||||
```
|
||||
|
||||
This step is a no-op on nodes without the label. `update-node.sh` runs it automatically between step 5 and step 6; the script is also safe to run ad-hoc after any manual OS operation. As a defensive extra measure, the apt phase (step 3) now runs `apt-mark manual docker.io` on any node where it is installed, so `apt-get autoremove --purge` cannot silently strip it.
|
||||
|
||||
### 6. Uncordon
|
||||
|
||||
```bash
|
||||
kubectl uncordon "$NODE"
|
||||
```
|
||||
|
||||
### 7. Post-node health verification
|
||||
|
||||
Wait for pods to reschedule and settle, then verify:
|
||||
|
||||
```bash
|
||||
sleep "${POST_UNCORDON_WAIT_SECONDS:-180}"
|
||||
|
||||
# All nodes Ready?
|
||||
kubectl get nodes
|
||||
kubectl get nodes -o json | jq -r '.items[] | select(.status.conditions[] | select(.type=="Ready" and .status!="True")) | .metadata.name' | grep . && { echo "NOT-READY NODES"; exit 1; } || true
|
||||
|
||||
# Any pod not Running/Succeeded?
|
||||
BAD=$(kubectl get pods -A --field-selector=status.phase!=Running,status.phase!=Succeeded --no-headers 2>/dev/null | wc -l)
|
||||
[ "$BAD" -gt 0 ] && { echo "BAD PODS: $BAD"; kubectl get pods -A --field-selector=status.phase!=Running,status.phase!=Succeeded; exit 1; } || true
|
||||
|
||||
# Any Deployment/StatefulSet under desired replicas?
|
||||
kubectl get deploy -A -o json | jq -r '.items[] | select((.status.readyReplicas // 0) < (.spec.replicas // 1)) | "deploy \(.metadata.namespace)/\(.metadata.name) \(.status.readyReplicas // 0)/\(.spec.replicas)"'
|
||||
kubectl get sts -A -o json | jq -r '.items[] | select((.status.readyReplicas // 0) < (.spec.replicas // 1)) | "sts \(.metadata.namespace)/\(.metadata.name) \(.status.readyReplicas // 0)/\(.spec.replicas)"'
|
||||
```
|
||||
|
||||
Only proceed to the next node when ALL of the above are green. If not:
|
||||
- Give it another 3 minutes and re-check (workloads with large images may still be pulling).
|
||||
- If still not green: STOP the cycle. Uncordon everything, leave the cluster in a stable state, and open a follow-up issue with the failing workloads. Do NOT proceed to update more nodes.
|
||||
|
||||
### 8. Control plane special handling
|
||||
|
||||
All three CPs (`k3s-cp-1`, `k3s-cp-2`, `k3s-cp-3`) are **NOT** updated by the weekly `os-update.sh` cycle — they have their own dedicated procedure and script: [`CP_UPDATE_PROCEDURE.md`](CP_UPDATE_PROCEDURE.md) and `scripts/os-update/update-cp-node.sh`. Reasons:
|
||||
|
||||
- CPs are `NoSchedule`-tainted and host no StatefulSet workloads today, but they are the etcd quorum. Two of three CPs cordoned/rebooting at once = write outage. One CP at a time, leader last.
|
||||
- All three CPs currently have zero swap. The CP procedure adds a durable 4 GiB swapfile per CP before draining.
|
||||
- The CP procedure polls the target's `/livez` from the operator machine and cross-checks `etcdctl endpoint status --cluster` from a peer CP to confirm the just-rebooted member rejoined.
|
||||
|
||||
`os-update.sh` excludes any node carrying the `node-role.kubernetes.io/control-plane` label (see `--dry-run` output) and prints a pointer to the CP procedure.
|
||||
|
||||
---
|
||||
|
||||
## Finalization
|
||||
|
||||
After all nodes are done:
|
||||
|
||||
1. Final cluster health snapshot (same preflight commands as at the start).
|
||||
2. Print apt history summary per node so the audit trail has "what changed":
|
||||
```bash
|
||||
for host in <all-nodes>; do
|
||||
echo "=== $host ==="
|
||||
ssh root@$host 'zgrep -h "Commandline\|Install\|Upgrade\|Remove" /var/log/apt/history.log* 2>/dev/null | tail -60'
|
||||
done
|
||||
```
|
||||
3. Optional: prune old etcd snapshots to keep disk in check:
|
||||
```bash
|
||||
ssh root@$CONTROL_PLANE_HOST 'k3s etcd-snapshot list'
|
||||
# delete anything older than 14 days if desired (manual review)
|
||||
```
|
||||
4. Attach the per-run logs to the Paperclip issue that triggered this run.
|
||||
5. Update the issue with:
|
||||
- Nodes updated (list)
|
||||
- Nodes skipped (list + reason)
|
||||
- Any package that required manual intervention
|
||||
- Reboots performed
|
||||
- Final `kubectl get nodes` output
|
||||
|
||||
If everything is clean → mark the issue `done`.
|
||||
If a node was skipped or errored → mark `blocked` with the unblock action, or open a child issue for the specific failure.
|
||||
|
||||
---
|
||||
|
||||
## Recovery / what to do when it goes wrong
|
||||
|
||||
**Node stuck cordoned after failure:** `kubectl uncordon <node>` — always leave the cluster back in its normal scheduling state.
|
||||
|
||||
**Node fails to boot after reboot:**
|
||||
- Check Hetzner console for boot errors (kernel panic, initramfs).
|
||||
- Try `hcloud server reboot <name>` from `hcloud` CLI.
|
||||
- If the node cannot recover: escalate. Do NOT delete or rebuild the Hetzner server without board approval; that is the ADD_WORKER_NODE flow.
|
||||
|
||||
**k3s-agent won't start after reboot:**
|
||||
- Check `journalctl -u k3s-agent -n 100`.
|
||||
- Do NOT edit the k3s-agent unit file. Do NOT re-run the k3s installer.
|
||||
- Escalate. This is out of scope for the OS-update procedure.
|
||||
|
||||
**Pods CrashLoopBackOff after node came back:**
|
||||
- Not an OS-update problem to fix — the node is healthy, apt succeeded. Leave the node uncordoned, stop the cycle, and open an application-level issue.
|
||||
|
||||
**PDB blocked drain:**
|
||||
- Never `--force` the drain. Leave the node uncordoned, skip it, note in the report which PDB blocked and which workload owns it.
|
||||
|
||||
**Datastore snapshot restore (last resort — CP only):**
|
||||
- Only if the control plane is broken beyond repair. See `K3S_OPERATIONS.md` for the `--cluster-reset --cluster-reset-restore-path=<snapshot>` procedure. Requires board approval — do NOT execute unattended.
|
||||
|
||||
---
|
||||
|
||||
## Automation entry points
|
||||
|
||||
- `infrastructure/scripts/os-update/os-update.sh` — full cycle runner (preflight → per-node loop → finalization). Idempotent, resumable via `--start-from <node>`. Use `--dry-run` to print the plan without touching anything. Explicitly skips `k3s-cp-1`.
|
||||
- `infrastructure/scripts/os-update/update-node.sh <node>` — single-node update (all 7 per-node steps). Callable standalone for retry. Not for `k3s-cp-1`.
|
||||
- `infrastructure/scripts/os-update/update-cp-node.sh <node>` — HA-aware CP update flow (add swap, preflight, drain, apt, reboot, finalize). Accepts `k3s-cp-1`, `k3s-cp-2`, or `k3s-cp-3`. See [`CP_UPDATE_PROCEDURE.md`](CP_UPDATE_PROCEDURE.md).
|
||||
- `infrastructure/scripts/os-update/update-cp-1.sh` — thin wrapper (calls `update-cp-node.sh k3s-cp-1 "$@"`). Kept for backwards compat.
|
||||
- `infrastructure/scripts/os-update/cluster-health.sh` — the preflight/post-node health check as a standalone command; exits non-zero on any failure.
|
||||
|
||||
Read the script sources for the exact behavior before running them. They mirror this procedure step for step.
|
||||
|
||||
---
|
||||
|
||||
## Weekly schedule
|
||||
|
||||
A Paperclip routine (see `infrastructure/OS_UPDATE_ROUTINE.md`) fires this procedure once per week. The routine creates a task whose description points here. The assigned agent reads this document and executes the automation.
|
||||
|
||||
---
|
||||
|
||||
## Change history
|
||||
|
||||
| Date | Change | By |
|
||||
|------|--------|-----|
|
||||
| 2026-08-09 | Initial procedure + automation scripts | CTO agent (DEV-462) |
|
||||
| 2026-08-16 | Added kine thundering-herd guardrails after DEV-495 incident (worker-3 drain caused CP kine cascade + near-OOM on cp-1). Four new rules: pre-plan StatefulSet drain order, swap on cp-1, halt on kubectl >5 s, cp-1 update as separate design task. | CTO agent (DEV-478/DEV-495) |
|
||||
| 2026-08-23 | Downgraded the kine cascade section for the HA-etcd era (DEV-510 migrated cp-1 from kine SQLite to embedded etcd, cp-2/cp-3 joined as full members). Pointed §8 and the "Automation entry points" list at the new HA-aware `CP_UPDATE_PROCEDURE.md` / `update-cp-node.sh`. Old `CP1_UPDATE_PROCEDURE.md` and `update-cp-1.sh` are now redirect stubs. | CTO agent (DEV-515) |
|
||||
47
infrastructure/OS_UPDATE_ROUTINE.md
Normal file
47
infrastructure/OS_UPDATE_ROUTINE.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Weekly OS Update — Paperclip Routine
|
||||
|
||||
**Owner:** CTO agent (until a dedicated ClusterOps agent is spun up)
|
||||
**Schedule:** every **Sunday 03:00 Europe/Berlin** (low-traffic window, before Monday operations)
|
||||
**Companion procedure:** [`OS_UPDATE_PROCEDURE.md`](OS_UPDATE_PROCEDURE.md)
|
||||
|
||||
## What the routine does
|
||||
|
||||
Each fire creates a Paperclip task whose description points to `OS_UPDATE_PROCEDURE.md`. The assigned agent:
|
||||
|
||||
1. Checks out the task.
|
||||
2. Reads the procedure doc.
|
||||
3. Runs `infrastructure/scripts/os-update/os-update.sh` end to end.
|
||||
4. Attaches per-run logs (`/tmp/os-update-<stamp>/`) to the task.
|
||||
5. Closes the task `done` on success, or `blocked` with a named unblock action on skip/error.
|
||||
|
||||
## Routine configuration (Paperclip)
|
||||
|
||||
- `title`: "Weekly rolling OS update — k3s cluster nodes"
|
||||
- `assigneeAgentId`: CTO (agent id `4b5f09a2-22d8-4e3d-9ac4-46d008ad1385`)
|
||||
- `projectId`: `d24057b8-02ca-41cd-b7a0-6151298c6e2c` (BasicStack Phase-2)
|
||||
- `goalId`: `b4dfe4a8-5c37-4f62-aad9-fa340dcd34a4` (self-hosted Kubernetes at Hetzner)
|
||||
- `priority`: `medium`
|
||||
- `concurrencyPolicy`: `coalesce_if_active` (a previous run still open? merge into it, don't stack)
|
||||
- `catchUpPolicy`: `skip_missed` (no back-fires if Paperclip was down)
|
||||
- Trigger: `schedule` — cron `0 3 * * 0`, timezone `Europe/Berlin`
|
||||
|
||||
The task description created by each fire is a link to the procedure doc in Forgejo, so the doc is the single source of truth even if the routine metadata drifts.
|
||||
|
||||
## Reassigning to a dedicated agent
|
||||
|
||||
If we later hire a ClusterOps agent, only two things change:
|
||||
|
||||
1. `PATCH /api/routines/{routineId}` — update `assigneeAgentId`.
|
||||
2. Nothing in `OS_UPDATE_PROCEDURE.md` or the scripts changes; the procedure is agent-neutral.
|
||||
|
||||
## Pausing / disabling
|
||||
|
||||
To pause without losing config:
|
||||
|
||||
```bash
|
||||
curl -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||
-d '{"status":"paused"}' \
|
||||
"$PAPERCLIP_API_URL/routines/{routineId}"
|
||||
```
|
||||
|
||||
Resume with `{"status":"active"}`.
|
||||
|
|
@ -2,6 +2,14 @@
|
|||
|
||||
This directory contains cluster-wide infrastructure configurations that support all applications.
|
||||
|
||||
## Operational procedures (agent-facing)
|
||||
|
||||
- **[OS_UPDATE_PROCEDURE.md](OS_UPDATE_PROCEDURE.md)** — weekly rolling Ubuntu OS update for all cluster nodes (drain → apt → reboot → verify → uncordon → cluster health → next). Never touches k3s config.
|
||||
- **[OS_UPDATE_ROUTINE.md](OS_UPDATE_ROUTINE.md)** — the Paperclip routine that fires the above weekly.
|
||||
- **[K3S_OPERATIONS.md](K3S_OPERATIONS.md)** — k3s version upgrades (separate concern from OS updates).
|
||||
- **[ADD_WORKER_NODE.md](ADD_WORKER_NODE.md)** — adding a worker node.
|
||||
- **[CLUSTER_ACCESS.md](CLUSTER_ACCESS.md)** — SSH / kubectl access.
|
||||
|
||||
## Structure
|
||||
|
||||
### `networking/`
|
||||
|
|
|
|||
51
infrastructure/scripts/os-update/README.md
Normal file
51
infrastructure/scripts/os-update/README.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# OS Update Automation
|
||||
|
||||
Scripts that implement the weekly rolling Ubuntu OS-update procedure.
|
||||
|
||||
**Authoritative doc:** [`../../OS_UPDATE_PROCEDURE.md`](../../OS_UPDATE_PROCEDURE.md) — read it first. The scripts here mirror that procedure step-for-step.
|
||||
|
||||
## Files
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `cluster-health.sh` | Non-zero if any node is not Ready, any pod is not Running/Ready, or any Deployment/StatefulSet is below its desired replica count. Used at preflight and after each node. |
|
||||
| `update-node.sh <node>` | Drain, apt-update, reboot-if-required, wait for Ready, ensure Docker on labeled nodes, uncordon, post-node health check. Retriable per-node. |
|
||||
| `ensure-node-docker.sh <node>` | Idempotent: on nodes labeled `basicstack.de/docker=true`, install docker.io if missing, `apt-mark manual`, enable+start the docker service, wait for `/var/run/docker.sock`, re-apply the label. No-op on nodes without the label. Called from `update-node.sh` between "kubelet Ready" and "uncordon"; also runnable ad-hoc for recovery. |
|
||||
| `os-update.sh` | Full cycle runner: preflight → etcd snapshot → ordered per-node loop → finalization + apt history digest. |
|
||||
|
||||
## Order of operations (encoded in `os-update.sh`)
|
||||
|
||||
1. Workers with no stateful affinity concerns first.
|
||||
2. `fsn1` workers (potential Stalwart hosts) last among workers — see [`stalwart-datacenter-affinity`](../../K3S_OPERATIONS.md) note.
|
||||
3. `k3s-cp-1` last (single control plane).
|
||||
4. One node at a time. Never in parallel.
|
||||
|
||||
## Guardrails the scripts enforce
|
||||
|
||||
- Preflight cluster health failure → refuse to start.
|
||||
- Drain with a PDB conflict → uncordon and mark the node `SKIPPED_DRAIN_FAILED`, never `--force`.
|
||||
- Node reboot fails to come back within timeout → hard stop, escalate. Do NOT rebuild the node or touch k3s config.
|
||||
- kubelet doesn't return `Ready` → hard stop, escalate. Do NOT touch k3s config.
|
||||
- Post-node cluster health check red → hard stop, do not proceed to the next node.
|
||||
- Absolute rule: **no k3s config, no manifests, no PVs, no service files touched** — apt/dpkg only.
|
||||
|
||||
## Typical invocations
|
||||
|
||||
```bash
|
||||
# Dry-run: print the ordered plan, touch nothing.
|
||||
./os-update.sh --dry-run
|
||||
|
||||
# Full cycle (weekly, triggered by the Paperclip routine).
|
||||
./os-update.sh
|
||||
|
||||
# Retry a single node (after fixing a manual issue).
|
||||
./update-node.sh k3s-worker-3
|
||||
|
||||
# Restart a partial cycle from a specific node onward.
|
||||
./os-update.sh --start-from k3s-worker-4
|
||||
|
||||
# Ad-hoc: reconcile Docker on a single node (e.g. after emergency ops).
|
||||
./ensure-node-docker.sh k3s-worker-3
|
||||
```
|
||||
|
||||
Logs land in `/tmp/os-update-<UTC-timestamp>/` on the machine that ran the cycle.
|
||||
130
infrastructure/scripts/os-update/cluster-health.sh
Executable file
130
infrastructure/scripts/os-update/cluster-health.sh
Executable file
|
|
@ -0,0 +1,130 @@
|
|||
#!/bin/bash
|
||||
# cluster-health.sh — verify k3s cluster is fully healthy.
|
||||
# Exits 0 on green, 1 on any failure. Used as preflight and post-node check by os-update.sh.
|
||||
#
|
||||
# Runs kubectl commands against whatever the current KUBECONFIG resolves to;
|
||||
# invoke via `ssh root@$CONTROL_PLANE_HOST bash -s < cluster-health.sh` to
|
||||
# check the cluster from an operator machine without local kubeconfig.
|
||||
#
|
||||
# Environment overrides:
|
||||
# RETRY_ON_TRANSIENT=1 — one retry after 30s for non-Ready-but-Running pods
|
||||
# VERBOSE=1 — dump full failing rows on non-zero exit
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
fail=0
|
||||
|
||||
echo "=== nodes ==="
|
||||
kubectl get nodes -o wide
|
||||
|
||||
not_ready=$(kubectl get nodes -o json | jq -r '
|
||||
.items[]
|
||||
| select(.status.conditions[] | select(.type=="Ready" and .status!="True"))
|
||||
| .metadata.name
|
||||
' || true)
|
||||
if [ -n "$not_ready" ]; then
|
||||
echo "FAIL: nodes not Ready: $not_ready"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== node pressure conditions ==="
|
||||
pressure=$(kubectl get nodes -o json | jq -r '
|
||||
.items[]
|
||||
| . as $n
|
||||
| .status.conditions[]
|
||||
| select(.type=="DiskPressure" or .type=="MemoryPressure" or .type=="PIDPressure")
|
||||
| select(.status=="True")
|
||||
| "\($n.metadata.name) \(.type)=True"
|
||||
' || true)
|
||||
if [ -n "$pressure" ]; then
|
||||
echo "FAIL: node pressure: $pressure"
|
||||
fail=1
|
||||
else
|
||||
echo " (none)"
|
||||
fi
|
||||
|
||||
check_bad_pods() {
|
||||
kubectl get pods -A --field-selector=status.phase!=Running,status.phase!=Succeeded --no-headers 2>/dev/null || true
|
||||
}
|
||||
check_notready_pods() {
|
||||
kubectl get pods -A -o json | jq -r '
|
||||
.items[]
|
||||
| select(.status.phase=="Running")
|
||||
| select([.status.conditions[]?|select(.type=="Ready")|.status] | contains(["False"]))
|
||||
| "\(.metadata.namespace)/\(.metadata.name)"
|
||||
' 2>/dev/null || true
|
||||
}
|
||||
|
||||
echo
|
||||
echo "=== pods not Running/Succeeded ==="
|
||||
bad_pods=$(check_bad_pods)
|
||||
if [ -n "$bad_pods" ]; then
|
||||
if [ "${RETRY_ON_TRANSIENT:-0}" = "1" ]; then
|
||||
echo " transient? re-checking in 30s..."
|
||||
sleep 30
|
||||
bad_pods=$(check_bad_pods)
|
||||
fi
|
||||
fi
|
||||
if [ -n "$bad_pods" ]; then
|
||||
echo "FAIL: pods not Running/Succeeded:"
|
||||
echo "$bad_pods"
|
||||
fail=1
|
||||
else
|
||||
echo " (none)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== pods Running but not Ready ==="
|
||||
notready=$(check_notready_pods)
|
||||
if [ -n "$notready" ] && [ "${RETRY_ON_TRANSIENT:-0}" = "1" ]; then
|
||||
echo " transient? re-checking in 30s..."
|
||||
sleep 30
|
||||
notready=$(check_notready_pods)
|
||||
fi
|
||||
if [ -n "$notready" ]; then
|
||||
echo "FAIL: pods Running-but-not-Ready:"
|
||||
echo "$notready"
|
||||
fail=1
|
||||
else
|
||||
echo " (none)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== deployments below desired replicas ==="
|
||||
deploy_bad=$(kubectl get deploy -A -o json | jq -r '
|
||||
.items[]
|
||||
| select((.status.readyReplicas // 0) < (.spec.replicas // 1))
|
||||
| "\(.metadata.namespace)/\(.metadata.name) \(.status.readyReplicas // 0)/\(.spec.replicas)"
|
||||
' || true)
|
||||
if [ -n "$deploy_bad" ]; then
|
||||
echo "FAIL: deployments not fully ready:"
|
||||
echo "$deploy_bad"
|
||||
fail=1
|
||||
else
|
||||
echo " (none)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== statefulsets below desired replicas ==="
|
||||
sts_bad=$(kubectl get sts -A -o json | jq -r '
|
||||
.items[]
|
||||
| select((.status.readyReplicas // 0) < (.spec.replicas // 1))
|
||||
| "\(.metadata.namespace)/\(.metadata.name) \(.status.readyReplicas // 0)/\(.spec.replicas)"
|
||||
' || true)
|
||||
if [ -n "$sts_bad" ]; then
|
||||
echo "FAIL: statefulsets not fully ready:"
|
||||
echo "$sts_bad"
|
||||
fail=1
|
||||
else
|
||||
echo " (none)"
|
||||
fi
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo
|
||||
echo "CLUSTER-HEALTH: FAIL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "CLUSTER-HEALTH: OK"
|
||||
112
infrastructure/scripts/os-update/ensure-node-docker.sh
Executable file
112
infrastructure/scripts/os-update/ensure-node-docker.sh
Executable file
|
|
@ -0,0 +1,112 @@
|
|||
#!/bin/bash
|
||||
# ensure-node-docker.sh — ensure docker.io is installed, enabled, and running on
|
||||
# a node that carries (or should carry) the `basicstack.de/docker=true` label.
|
||||
#
|
||||
# Runs from the operator machine (or the control plane); needs kubectl and
|
||||
# ssh access to root@<node-ssh-target>.
|
||||
#
|
||||
# Behavior:
|
||||
# - If the node has label `basicstack.de/docker=true`, ensure docker.io is
|
||||
# installed, marked `apt-mark manual`, systemd `docker` is enabled+active,
|
||||
# and /var/run/docker.sock exists.
|
||||
# - Re-apply the label (idempotent) so that returning nodes always end the
|
||||
# step in a known state.
|
||||
# - If the node does NOT carry the label, this is a no-op — do NOT install
|
||||
# docker on nodes that were not designated to run the Forgejo runner.
|
||||
#
|
||||
# Usage:
|
||||
# ensure-node-docker.sh <node-name>
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — node is either not designated (no label) or Docker is confirmed healthy
|
||||
# 1 — hard failure: label present but docker could not be brought up
|
||||
#
|
||||
# Motivated by DEV-499: the weekly rolling OS update was purging docker.io from
|
||||
# workers, which broke the Forgejo runner (DEV-498). Making Docker part of the
|
||||
# post-reboot reconciliation removes the manual "apt-get install docker.io &&
|
||||
# systemctl enable --now docker && kubectl label node" step.
|
||||
set -euo pipefail
|
||||
|
||||
NODE="${1:-}"
|
||||
if [ -z "$NODE" ]; then
|
||||
echo "usage: $0 <node-name>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
CONTROL_PLANE_HOST="${CONTROL_PLANE_HOST:-178.105.17.239}"
|
||||
SSH_OPTS="${SSH_OPTS:--o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new}"
|
||||
DOCKER_LABEL_KEY="basicstack.de/docker"
|
||||
|
||||
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] ensure-docker/$NODE: $*"; }
|
||||
die() { log "FATAL: $*"; exit 1; }
|
||||
|
||||
# node -> ssh target. Keep in sync with update-node.sh::node_ssh_target.
|
||||
node_ssh_target() {
|
||||
case "$1" in
|
||||
k3s-cp-1) echo "root@178.105.17.239" ;;
|
||||
k3s-worker-1) echo "-J root@$CONTROL_PLANE_HOST root@10.42.1.2" ;;
|
||||
k3s-worker-2) echo "-J root@$CONTROL_PLANE_HOST root@10.42.1.3" ;;
|
||||
k3s-worker-3) echo "root@167.233.121.121" ;;
|
||||
k3s-worker-4) echo "root@128.140.3.80" ;;
|
||||
k3s-worker-5) echo "root@167.233.192.86" ;;
|
||||
k3s-update-runner) echo "root@167.233.79.65" ;;
|
||||
*) die "unknown node $1 — update node_ssh_target() in $0" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# --- 1. is this node designated to run Docker? --------------------------------
|
||||
kubectl get node "$NODE" >/dev/null || die "node $NODE not found in cluster"
|
||||
LABEL_VAL=$(kubectl get node "$NODE" \
|
||||
-o jsonpath="{.metadata.labels.${DOCKER_LABEL_KEY//./\\.}}" 2>/dev/null || echo "")
|
||||
|
||||
if [ "$LABEL_VAL" != "true" ]; then
|
||||
log "no ${DOCKER_LABEL_KEY}=true label — skipping Docker reconciliation"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SSH_TARGET=$(node_ssh_target "$NODE")
|
||||
log "label ${DOCKER_LABEL_KEY}=true present — reconciling docker.io via $SSH_TARGET"
|
||||
|
||||
# --- 2. ensure docker.io on the node -----------------------------------------
|
||||
REMOTE=$(cat <<'REMOTE'
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
APT_OPTS='-y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold'
|
||||
|
||||
# Recover from any half-finished dpkg state before touching apt.
|
||||
dpkg --configure -a >/dev/null 2>&1 || true
|
||||
|
||||
if ! dpkg -s docker.io >/dev/null 2>&1; then
|
||||
echo "docker.io not installed — installing"
|
||||
apt-get update
|
||||
apt-get $APT_OPTS install docker.io
|
||||
else
|
||||
echo "docker.io already installed"
|
||||
fi
|
||||
|
||||
# Keep docker.io out of the reach of apt-get autoremove --purge, which is what
|
||||
# the weekly OS-update cycle runs. Idempotent.
|
||||
apt-mark manual docker.io >/dev/null
|
||||
|
||||
systemctl enable --now docker
|
||||
|
||||
# Wait for the docker socket to appear so the forgejo-runner pod can bind it.
|
||||
for _ in $(seq 1 30); do
|
||||
if [ -S /var/run/docker.sock ]; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
if [ ! -S /var/run/docker.sock ]; then
|
||||
echo "docker.sock missing after enable" >&2
|
||||
systemctl status docker --no-pager | tail -20 >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "docker OK: $(docker version --format '{{.Server.Version}}')"
|
||||
REMOTE
|
||||
)
|
||||
|
||||
ssh $SSH_OPTS $SSH_TARGET "bash -s" <<< "$REMOTE" | sed 's/^/ /'
|
||||
|
||||
# --- 3. re-apply the label (idempotent, in case someone stripped it) ---------
|
||||
kubectl label node "$NODE" "${DOCKER_LABEL_KEY}=true" --overwrite >/dev/null
|
||||
log "docker + label reconciled OK"
|
||||
196
infrastructure/scripts/os-update/os-update.sh
Executable file
196
infrastructure/scripts/os-update/os-update.sh
Executable file
|
|
@ -0,0 +1,196 @@
|
|||
#!/bin/bash
|
||||
# os-update.sh — full weekly rolling OS-update cycle.
|
||||
#
|
||||
# Behavior:
|
||||
# 1. preflight cluster health (fail-closed)
|
||||
# 2. take an etcd snapshot
|
||||
# 3. compute node order (workers first, control plane last;
|
||||
# Stalwart-hosting fsn1 workers moved to end of workers group)
|
||||
# 4. call update-node.sh for each node, halting on any failure
|
||||
# 5. finalization: health snapshot + apt history digest
|
||||
#
|
||||
# Usage:
|
||||
# os-update.sh [--dry-run] [--start-from <node>] [--only <node>]
|
||||
#
|
||||
# Environment:
|
||||
# CONTROL_PLANE_HOST (default 178.105.17.239)
|
||||
# All env vars honored by update-node.sh are honored here as well.
|
||||
#
|
||||
# Read OS_UPDATE_PROCEDURE.md alongside this script; the script mirrors it
|
||||
# step-for-step and the doc is the authoritative reference.
|
||||
set -euo pipefail
|
||||
|
||||
CONTROL_PLANE_HOST="${CONTROL_PLANE_HOST:-178.105.17.239}"
|
||||
DRY_RUN=0
|
||||
START_FROM=""
|
||||
ONLY=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--start-from) START_FROM="$2"; shift 2 ;;
|
||||
--only) ONLY="$2"; shift 2 ;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
LOG_DIR="/tmp/os-update-${STAMP}"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_DIR/main.log"; }
|
||||
die() { log "FATAL: $*"; exit 1; }
|
||||
|
||||
log "=== os-update.sh cycle $STAMP ==="
|
||||
log "log dir: $LOG_DIR"
|
||||
|
||||
# --- 0. sanity checks ---------------------------------------------------------
|
||||
command -v kubectl >/dev/null || die "kubectl not on PATH"
|
||||
command -v jq >/dev/null || die "jq not on PATH (needed for health checks)"
|
||||
|
||||
# --- 1. preflight -------------------------------------------------------------
|
||||
log "[preflight] cluster health"
|
||||
if ! RETRY_ON_TRANSIENT=1 "$SCRIPT_DIR/cluster-health.sh" | tee "$LOG_DIR/preflight.log"; then
|
||||
die "cluster is not healthy at preflight — refuse to start OS updates"
|
||||
fi
|
||||
|
||||
# --- 2. etcd snapshot ---------------------------------------------------------
|
||||
if [ "$DRY_RUN" -eq 0 ]; then
|
||||
log "[preflight] taking k3s etcd snapshot"
|
||||
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new root@"$CONTROL_PLANE_HOST" \
|
||||
"k3s etcd-snapshot save --name pre-os-update-$(date +%Y%m%d)" | tee "$LOG_DIR/etcd-snapshot.log" || \
|
||||
log "WARN: etcd snapshot failed — continuing but this is a risk on CP reboot"
|
||||
else
|
||||
log "[preflight] DRY-RUN — skipping etcd snapshot"
|
||||
fi
|
||||
|
||||
# --- 3. node ordering ---------------------------------------------------------
|
||||
# Ordering rule:
|
||||
# - workers only
|
||||
# - within workers: nodes NOT hosting Stalwart first, Stalwart-hosting fsn1 nodes last
|
||||
# - ALL control-plane nodes are EXCLUDED and never updated by this script. Rationale:
|
||||
# * kine/etcd write-path is sensitive to concurrent drains (see kine thundering-herd
|
||||
# guardrails in OS_UPDATE_PROCEDURE.md, added after DEV-495).
|
||||
# * rebooting a CP removes one apiserver — needs external liveness monitoring.
|
||||
# * CP nodes host their own StatefulSet workloads that need batched eviction.
|
||||
# Since DEV-510 (2026-08-22) the cluster runs HA control plane (cp-1/cp-2/cp-3). The
|
||||
# exclusion here is role-based so ALL current and future CPs are covered automatically.
|
||||
# To update a CP, use `scripts/os-update/update-cp-1.sh` (currently cp-1-only; will be
|
||||
# generalized to any CP as part of the HA-aware CP OS-update procedure follow-up).
|
||||
STALWART_NODE=$(kubectl -n stalwart get pod -l app=stalwart -o jsonpath='{.items[*].spec.nodeName}' 2>/dev/null | tr ' ' '\n' | sort -u || true)
|
||||
# If the pod's not currently up (e.g. Pending) we still want to protect fsn1 workers.
|
||||
|
||||
CP_NODES=$(kubectl get nodes -l node-role.kubernetes.io/control-plane -o jsonpath='{.items[*].metadata.name}' 2>/dev/null | tr ' ' '\n' || true)
|
||||
ALL_NODES=$(kubectl get nodes -o jsonpath='{.items[*].metadata.name}' | tr ' ' '\n')
|
||||
|
||||
is_cp() {
|
||||
local n="$1"
|
||||
for c in $CP_NODES; do [ "$c" = "$n" ] && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
workers=()
|
||||
stalwart_workers=()
|
||||
for n in $ALL_NODES; do
|
||||
is_cp "$n" && continue
|
||||
if [ -n "$STALWART_NODE" ] && [ "$n" = "$STALWART_NODE" ]; then
|
||||
stalwart_workers+=("$n")
|
||||
continue
|
||||
fi
|
||||
# Any fsn1 worker is a potential Stalwart host — schedule after non-Stalwart nodes.
|
||||
loc=$(kubectl get node "$n" -o jsonpath='{.metadata.labels.csi\.hetzner\.cloud/location}' 2>/dev/null || echo "")
|
||||
if [ "$loc" = "fsn1" ]; then
|
||||
stalwart_workers+=("$n")
|
||||
else
|
||||
workers+=("$n")
|
||||
fi
|
||||
done
|
||||
|
||||
ORDER=("${workers[@]}" "${stalwart_workers[@]}")
|
||||
# NOTE: CP nodes intentionally excluded. To update a CP, see CP1_UPDATE_PROCEDURE.md.
|
||||
if [ -n "$CP_NODES" ]; then
|
||||
log "[plan] EXCLUDING control-plane nodes: $(echo $CP_NODES | tr '\n' ' ')— use scripts/os-update/update-cp-1.sh (see CP1_UPDATE_PROCEDURE.md)"
|
||||
fi
|
||||
|
||||
if [ -n "$ONLY" ]; then
|
||||
ORDER=("$ONLY")
|
||||
elif [ -n "$START_FROM" ]; then
|
||||
new=()
|
||||
skip=1
|
||||
for n in "${ORDER[@]}"; do
|
||||
[ "$n" = "$START_FROM" ] && skip=0
|
||||
[ $skip -eq 0 ] && new+=("$n")
|
||||
done
|
||||
ORDER=("${new[@]}")
|
||||
fi
|
||||
|
||||
log "[plan] ordered nodes (${#ORDER[@]}): ${ORDER[*]}"
|
||||
printf '%s\n' "${ORDER[@]}" > "$LOG_DIR/plan.txt"
|
||||
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
log "DRY-RUN — plan written, no node touched. Exiting."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 4. per-node loop ---------------------------------------------------------
|
||||
updated=()
|
||||
skipped=()
|
||||
for n in "${ORDER[@]}"; do
|
||||
log "===================================================================="
|
||||
log "==> updating $n"
|
||||
log "===================================================================="
|
||||
NODE_LOG="$LOG_DIR/${n}.log"
|
||||
set +e
|
||||
"$SCRIPT_DIR/update-node.sh" "$n" 2>&1 | tee "$NODE_LOG"
|
||||
rc=${PIPESTATUS[0]}
|
||||
set -e
|
||||
case $rc in
|
||||
0) updated+=("$n") ;;
|
||||
3) skipped+=("$n:drain-blocked") ;;
|
||||
*) die "update-node.sh failed for $n (rc=$rc). Cycle halted. See $NODE_LOG" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- 5. finalization ----------------------------------------------------------
|
||||
log "===================================================================="
|
||||
log "==> finalization"
|
||||
log "===================================================================="
|
||||
|
||||
log "[final] cluster health"
|
||||
"$SCRIPT_DIR/cluster-health.sh" | tee "$LOG_DIR/final-health.log" || \
|
||||
die "final cluster health failed after cycle. Do NOT declare success."
|
||||
|
||||
log "[final] apt history digest"
|
||||
{
|
||||
for n in "${updated[@]}"; do
|
||||
echo "=== $n ==="
|
||||
# resolve ssh target via a mini-eval of node_ssh_target-equivalent
|
||||
case "$n" in
|
||||
k3s-cp-1) t="root@178.105.17.239" ;;
|
||||
k3s-worker-1) t="-J root@$CONTROL_PLANE_HOST root@10.42.1.2" ;;
|
||||
k3s-worker-2) t="-J root@$CONTROL_PLANE_HOST root@10.42.1.3" ;;
|
||||
k3s-worker-3) t="root@167.233.121.121" ;;
|
||||
k3s-worker-4) t="root@128.140.3.80" ;;
|
||||
k3s-worker-5) t="root@167.233.192.86" ;;
|
||||
k3s-update-runner) t="root@167.233.79.65" ;;
|
||||
*) echo " (unknown ssh target)"; continue ;;
|
||||
esac
|
||||
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new $t \
|
||||
'zgrep -h "Commandline\|Install\|Upgrade\|Remove" /var/log/apt/history.log* 2>/dev/null | tail -40' 2>/dev/null \
|
||||
|| echo " (could not read apt history)"
|
||||
done
|
||||
} | tee "$LOG_DIR/apt-history.log"
|
||||
|
||||
log "=== summary ==="
|
||||
log "updated (${#updated[@]}): ${updated[*]:-none}"
|
||||
log "skipped (${#skipped[@]}): ${skipped[*]:-none}"
|
||||
log "logs: $LOG_DIR"
|
||||
|
||||
if [ ${#skipped[@]} -gt 0 ]; then
|
||||
log "cycle finished with skipped nodes — return code 4 so the caller can escalate"
|
||||
exit 4
|
||||
fi
|
||||
|
||||
log "cycle complete."
|
||||
13
infrastructure/scripts/os-update/update-cp-1.sh
Executable file
13
infrastructure/scripts/os-update/update-cp-1.sh
Executable file
|
|
@ -0,0 +1,13 @@
|
|||
#!/bin/bash
|
||||
# update-cp-1.sh -- SUPERSEDED thin wrapper.
|
||||
#
|
||||
# Since DEV-515 (2026-08-23) the HA-aware entry point is update-cp-node.sh.
|
||||
# This wrapper forwards to `update-cp-node.sh k3s-cp-1 "$@"` so historical
|
||||
# callers keep working (routines, runbook references, board-approval docs).
|
||||
#
|
||||
# See ../CP_UPDATE_PROCEDURE.md for the current procedure.
|
||||
# The old cp-1-only design lives in git history at commit 19af691.
|
||||
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec "$SCRIPT_DIR/update-cp-node.sh" k3s-cp-1 "$@"
|
||||
595
infrastructure/scripts/os-update/update-cp-node.sh
Executable file
595
infrastructure/scripts/os-update/update-cp-node.sh
Executable file
|
|
@ -0,0 +1,595 @@
|
|||
#!/bin/bash
|
||||
# update-cp-node.sh — controlled OS update for any HA k3s control-plane node.
|
||||
#
|
||||
# See ../CP_UPDATE_PROCEDURE.md for the full design rationale.
|
||||
#
|
||||
# Usage:
|
||||
# update-cp-node.sh <node> --dry-run # print what would be done, touch nothing
|
||||
# update-cp-node.sh <node> --add-swap # Phase A only (idempotent, safe standalone)
|
||||
# update-cp-node.sh <node> --preflight # Phase B only
|
||||
# update-cp-node.sh <node> --drain # Phase C only (cordon + drain)
|
||||
# update-cp-node.sh <node> --apt # Phase D only (requires <node> already fully drained)
|
||||
# update-cp-node.sh <node> --reboot # Phase E only (requires --apt reported REBOOT_REQUIRED=yes)
|
||||
# update-cp-node.sh <node> --finalize # Phase F only (uncordon + verify)
|
||||
# update-cp-node.sh <node> --run # all phases with confirmation between each (or ASSUME_YES=1)
|
||||
#
|
||||
# <node> must be one of: k3s-cp-1, k3s-cp-2, k3s-cp-3.
|
||||
#
|
||||
# Environment overrides:
|
||||
# SWAP_SIZE_MB default 4096 (>=2048 required)
|
||||
# SWAP_PATH default /swapfile
|
||||
# DRAIN_TIMEOUT_SECONDS default 600
|
||||
# REBOOT_MAX_WAIT_SECONDS default 600
|
||||
# POST_UNCORDON_WAIT_SECONDS default 180
|
||||
# MIN_TARGET_MEM_MIB default 200 (target-CP MemAvailable floor mid-drain)
|
||||
# MAX_KUBECTL_SECONDS default 5 (kine-latency guardrail; softened for HA etcd)
|
||||
# SSH_OPTS default "-o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
|
||||
# ASSUME_YES=1 skip interactive confirmations in --run
|
||||
# DRY_RUN=1 do not execute state-mutating commands; log-only
|
||||
#
|
||||
# Log directory contract (matches update-node.sh):
|
||||
# Every command's stdout+stderr is tee'd to /tmp/os-update-<node>-<UTC-ts>.log on
|
||||
# the operator machine. Attach it to the execution ticket at the end.
|
||||
#
|
||||
# NEVER touches k3s config, k3s services, containerd, or any manifest. Only
|
||||
# fixes it will attempt: apt/dpkg recovery on the target CP (Phase D). Any other
|
||||
# problem -> escalate and STOP.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CP topology — public/routable IPs used to reach each CP via ssh + /livez.
|
||||
# Kept out-of-cluster on purpose: during a full-cluster incident the operator
|
||||
# machine must be able to reach each CP without going through k3s.
|
||||
# --------------------------------------------------------------------------- #
|
||||
declare -A CP_HOST=(
|
||||
[k3s-cp-1]="178.105.17.239"
|
||||
[k3s-cp-2]="188.245.85.199"
|
||||
[k3s-cp-3]="49.13.92.162"
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Arg parse — expect <node> as $1
|
||||
# --------------------------------------------------------------------------- #
|
||||
usage() { grep -E '^# ' "$0" | sed 's/^# \{0,1\}//'; exit 2; }
|
||||
[ $# -ge 2 ] || usage
|
||||
|
||||
NODE="$1"; shift
|
||||
TARGET_HOST="${CP_HOST[$NODE]:-}"
|
||||
if [ -z "$TARGET_HOST" ]; then
|
||||
echo "unknown CP node: $NODE (allowed: ${!CP_HOST[*]})" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Config
|
||||
# --------------------------------------------------------------------------- #
|
||||
SWAP_SIZE_MB="${SWAP_SIZE_MB:-4096}"
|
||||
SWAP_PATH="${SWAP_PATH:-/swapfile}"
|
||||
DRAIN_TIMEOUT_SECONDS="${DRAIN_TIMEOUT_SECONDS:-600}"
|
||||
REBOOT_MAX_WAIT_SECONDS="${REBOOT_MAX_WAIT_SECONDS:-600}"
|
||||
POST_UNCORDON_WAIT_SECONDS="${POST_UNCORDON_WAIT_SECONDS:-180}"
|
||||
MIN_TARGET_MEM_MIB="${MIN_TARGET_MEM_MIB:-200}"
|
||||
MAX_KUBECTL_SECONDS="${MAX_KUBECTL_SECONDS:-5}"
|
||||
SSH_OPTS="${SSH_OPTS:--o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new}"
|
||||
DRY_RUN="${DRY_RUN:-0}"
|
||||
ASSUME_YES="${ASSUME_YES:-0}"
|
||||
|
||||
TS="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
LOG_LOCAL="/tmp/os-update-${NODE}-${TS}.log"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
HEALTH_SCRIPT="$SCRIPT_DIR/cluster-health.sh"
|
||||
|
||||
# Pick a peer CP for etcd status probes. Requirements:
|
||||
# - not the target
|
||||
# - etcdctl available (via non-login ssh PATH)
|
||||
# We probe each candidate at parse time. Order preference: cp-1 first (it has
|
||||
# etcdctl installed since the pre-HA era), then cp-2, then cp-3. The Phase A
|
||||
# `--add-swap` step also installs `etcd-client` via apt so cp-2/cp-3 pick up
|
||||
# etcdctl on their first pass.
|
||||
peer_host_for() {
|
||||
local target="$1"
|
||||
local order=(k3s-cp-1 k3s-cp-2 k3s-cp-3)
|
||||
for n in "${order[@]}"; do
|
||||
[ "$n" = "$target" ] && continue
|
||||
local h="${CP_HOST[$n]}"
|
||||
if ssh $SSH_OPTS -o BatchMode=yes "root@$h" 'command -v etcdctl >/dev/null 2>&1' 2>/dev/null; then
|
||||
echo "$h"; return 0
|
||||
fi
|
||||
done
|
||||
# In DRY_RUN we don't need a real etcdctl-capable peer.
|
||||
if [ "${DRY_RUN:-0}" = "1" ]; then
|
||||
for n in "${order[@]}"; do
|
||||
[ "$n" != "$target" ] && { echo "${CP_HOST[$n]}"; return 0; }
|
||||
done
|
||||
fi
|
||||
# No non-target CP has etcdctl. Return empty so Phase B/E can fail
|
||||
# explicitly with a targeted "install etcd-client on cp-X first" message.
|
||||
echo ""
|
||||
return 0
|
||||
}
|
||||
PEER_HOST="$(peer_host_for "$NODE")"
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Logging + safe-run helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG_LOCAL"; }
|
||||
die() { log "FATAL: $*"; exit 1; }
|
||||
warn() { log "WARN: $*"; }
|
||||
|
||||
run() {
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
log "DRY-RUN would exec: $*"
|
||||
return 0
|
||||
fi
|
||||
log "exec: $*"
|
||||
"$@" 2>&1 | tee -a "$LOG_LOCAL"
|
||||
}
|
||||
|
||||
ssh_run() {
|
||||
local target="${1:-root@$TARGET_HOST}"; shift || true
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
log "DRY-RUN would ssh $target: $*"
|
||||
return 0
|
||||
fi
|
||||
log "ssh $target: $*"
|
||||
ssh $SSH_OPTS "$target" "$@" 2>&1 | tee -a "$LOG_LOCAL"
|
||||
}
|
||||
|
||||
# ssh_run_stdin: pipe a heredoc through bash -s on the target; used for multi-line remote blocks.
|
||||
ssh_run_stdin() {
|
||||
local target="root@${TARGET_HOST}"
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
log "DRY-RUN would ssh $target with stdin script:"
|
||||
sed 's/^/ | /' | tee -a "$LOG_LOCAL"
|
||||
return 0
|
||||
fi
|
||||
log "ssh $target (heredoc)"
|
||||
ssh $SSH_OPTS "$target" "bash -s" 2>&1 | tee -a "$LOG_LOCAL"
|
||||
}
|
||||
|
||||
confirm() {
|
||||
local prompt="$1"
|
||||
if [ "$ASSUME_YES" = "1" ]; then
|
||||
log "confirm SKIPPED (ASSUME_YES=1): $prompt"
|
||||
return 0
|
||||
fi
|
||||
echo -n " >>> $prompt Continue? [y/N] "
|
||||
read -r a
|
||||
case "$a" in y|Y|yes|YES) return 0 ;; *) die "aborted by operator" ;; esac
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# etcd helpers — run etcdctl on the target OR on a peer CP.
|
||||
# --------------------------------------------------------------------------- #
|
||||
ETCDCTL_ENV='ETCDCTL_API=3 etcdctl \
|
||||
--endpoints=https://127.0.0.1:2379 \
|
||||
--cacert=/var/lib/rancher/k3s/server/tls/etcd/server-ca.crt \
|
||||
--cert=/var/lib/rancher/k3s/server/tls/etcd/server-client.crt \
|
||||
--key=/var/lib/rancher/k3s/server/tls/etcd/server-client.key'
|
||||
|
||||
etcd_status_via() {
|
||||
# $1 = host (IP)
|
||||
local host="$1"
|
||||
ssh $SSH_OPTS "root@$host" "$ETCDCTL_ENV endpoint status --cluster -w table" 2>&1
|
||||
}
|
||||
|
||||
etcd_leader_name() {
|
||||
# returns the etcd member NAME (e.g. k3s-cp-3-9d305472) whose row shows IS LEADER=true.
|
||||
# Uses simple-format output (no -w table) for reliable parsing.
|
||||
local host="$1"
|
||||
ssh $SSH_OPTS "root@$host" "$ETCDCTL_ENV endpoint status --cluster -w simple" 2>/dev/null \
|
||||
| awk -F, '$5 ~ /true/ {print $1}'
|
||||
}
|
||||
|
||||
target_is_leader() {
|
||||
# returns 0 if the target IP is the current etcd leader, 1 otherwise.
|
||||
local host="$1" # peer to query from
|
||||
local target="$2" # target IP to compare
|
||||
local leader_ep
|
||||
leader_ep=$(ssh $SSH_OPTS "root@$host" "$ETCDCTL_ENV endpoint status --cluster -w simple" 2>/dev/null \
|
||||
| awk -F, '$5 ~ /true/ {print $1}')
|
||||
# leader_ep looks like https://49.13.92.162:2379
|
||||
echo "$leader_ep" | grep -q "://${target}:" && return 0 || return 1
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Phase A — Add swap on the target CP (idempotent)
|
||||
# --------------------------------------------------------------------------- #
|
||||
phase_add_swap() {
|
||||
log "=== Phase A: add swap on $NODE ($SWAP_SIZE_MB MiB at $SWAP_PATH) ==="
|
||||
[ "$SWAP_SIZE_MB" -ge 2048 ] || die "SWAP_SIZE_MB=$SWAP_SIZE_MB below 2048 MiB guardrail"
|
||||
|
||||
cat <<REMOTE | ssh_run_stdin
|
||||
set -euo pipefail
|
||||
SWAP_PATH="$SWAP_PATH"
|
||||
SIZE_MB="$SWAP_SIZE_MB"
|
||||
|
||||
# Skip if a swapfile at this path is already active.
|
||||
if swapon --show=NAME 2>/dev/null | grep -qx "\$SWAP_PATH"; then
|
||||
echo "swap already on at \$SWAP_PATH -- skipping"
|
||||
free -h
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Root filesystem free space check -- abort if less than 2*swap free.
|
||||
avail_mb=\$(df -m --output=avail / | tail -1 | tr -d ' ')
|
||||
need_mb=\$(( SIZE_MB * 2 ))
|
||||
if [ "\$avail_mb" -lt "\$need_mb" ]; then
|
||||
echo "ERROR: only \${avail_mb} MiB free on /, need \${need_mb} MiB (2x swap for safety)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create swapfile. fallocate is fast; dd is the fallback.
|
||||
if ! fallocate -l "\${SIZE_MB}M" "\$SWAP_PATH" 2>/dev/null; then
|
||||
dd if=/dev/zero of="\$SWAP_PATH" bs=1M count="\$SIZE_MB" status=progress
|
||||
fi
|
||||
chmod 600 "\$SWAP_PATH"
|
||||
mkswap "\$SWAP_PATH"
|
||||
swapon "\$SWAP_PATH"
|
||||
|
||||
# Persist via fstab (dedup).
|
||||
if ! grep -q "^\$SWAP_PATH " /etc/fstab; then
|
||||
echo "\$SWAP_PATH none swap sw 0 0" >> /etc/fstab
|
||||
fi
|
||||
|
||||
# Moderate swappiness -- swap as safety net, not aggressive paging.
|
||||
sysctl -w vm.swappiness=10
|
||||
if [ ! -f /etc/sysctl.d/99-k3s-swap.conf ] || ! grep -q '^vm.swappiness' /etc/sysctl.d/99-k3s-swap.conf; then
|
||||
echo 'vm.swappiness=10' > /etc/sysctl.d/99-k3s-swap.conf
|
||||
fi
|
||||
|
||||
echo "--- swap after ---"
|
||||
free -h
|
||||
swapon --show
|
||||
sysctl vm.swappiness
|
||||
|
||||
# Ensure etcdctl is available for etcd-quorum probes (idempotent apt install).
|
||||
# Needed because when this CP is the target of a later update, another CP
|
||||
# must probe etcd cluster status; if cp-1 is the target, one of cp-2/cp-3
|
||||
# is the probing peer and must have etcdctl.
|
||||
if ! command -v etcdctl >/dev/null 2>&1; then
|
||||
echo "--- installing etcd-client (provides etcdctl) ---"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -y >/dev/null
|
||||
apt-get install -y etcd-client
|
||||
command -v etcdctl && etcdctl version
|
||||
fi
|
||||
REMOTE
|
||||
|
||||
if [ "$DRY_RUN" != "1" ]; then
|
||||
log "verifying kubelet still Ready after swap add"
|
||||
local ready
|
||||
ready=$(kubectl get node "$NODE" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo Unknown)
|
||||
[ "$ready" = "True" ] || die "kubelet on $NODE not Ready after swap add -- halt"
|
||||
log "kubelet Ready=True -- Phase A complete"
|
||||
fi
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Guardrail probes
|
||||
# --------------------------------------------------------------------------- #
|
||||
kubectl_latency_s() {
|
||||
local start end
|
||||
start=$(date +%s)
|
||||
kubectl get nodes >/dev/null 2>&1 || echo "kubectl-error" >&2
|
||||
end=$(date +%s)
|
||||
echo $(( end - start ))
|
||||
}
|
||||
|
||||
target_mem_available_mib() {
|
||||
ssh $SSH_OPTS "root@$TARGET_HOST" "awk '/^MemAvailable:/{printf \"%d\n\", \$2/1024}' /proc/meminfo" 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
guard_kine_healthy() {
|
||||
local s
|
||||
s=$(kubectl_latency_s)
|
||||
if [ "$s" -gt "$MAX_KUBECTL_SECONDS" ]; then
|
||||
die "kubectl get nodes took ${s}s (>${MAX_KUBECTL_SECONDS}s) -- etcd slow, HALT"
|
||||
fi
|
||||
log " kubectl-latency ok: ${s}s"
|
||||
}
|
||||
|
||||
guard_target_memory() {
|
||||
local m
|
||||
m=$(target_mem_available_mib)
|
||||
if [ "$m" -lt "$MIN_TARGET_MEM_MIB" ]; then
|
||||
die "$NODE MemAvailable=${m} MiB below ${MIN_TARGET_MEM_MIB} MiB floor -- HALT"
|
||||
fi
|
||||
log " $NODE mem ok: MemAvailable=${m} MiB"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Phase B — Preflight
|
||||
# --------------------------------------------------------------------------- #
|
||||
phase_preflight() {
|
||||
log "=== Phase B: preflight for $NODE ==="
|
||||
|
||||
log "-- cluster health"
|
||||
if [ "$DRY_RUN" != "1" ]; then
|
||||
if ! RETRY_ON_TRANSIENT=1 "$HEALTH_SCRIPT" 2>&1 | tee -a "$LOG_LOCAL"; then
|
||||
die "cluster is not healthy -- refuse to start $NODE update"
|
||||
fi
|
||||
else
|
||||
log "DRY-RUN would run: $HEALTH_SCRIPT"
|
||||
fi
|
||||
|
||||
log "-- confirm $NODE is a control-plane node"
|
||||
if [ "$DRY_RUN" != "1" ]; then
|
||||
local is_cp
|
||||
is_cp=$(kubectl get node "$NODE" -o jsonpath='{.metadata.labels.node-role\.kubernetes\.io/control-plane}' 2>/dev/null || echo "")
|
||||
[ "$is_cp" = "true" ] || die "$NODE is not labelled control-plane -- refuse (use update-node.sh for workers)"
|
||||
fi
|
||||
|
||||
log "-- verify no OTHER CP is currently cordoned"
|
||||
if [ "$DRY_RUN" != "1" ]; then
|
||||
local other_cordoned
|
||||
other_cordoned=$(kubectl get nodes -l node-role.kubernetes.io/control-plane=true \
|
||||
-o json | jq -r --arg n "$NODE" '.items[] | select(.metadata.name != $n) | select(.spec.unschedulable == true) | .metadata.name' \
|
||||
| tr '\n' ' ')
|
||||
if [ -n "${other_cordoned// /}" ]; then
|
||||
die "another CP is already cordoned: $other_cordoned -- refuse (one CP at a time)"
|
||||
fi
|
||||
log " no other CP cordoned -- proceeding"
|
||||
fi
|
||||
|
||||
log "-- etcd cluster status (all members must be started)"
|
||||
if [ "$DRY_RUN" != "1" ]; then
|
||||
if [ -z "$PEER_HOST" ]; then
|
||||
die "no non-target CP has etcdctl installed -- run \`update-cp-node.sh <other-cp> --add-swap\` first on one of the OTHER CPs (that step installs etcd-client), then retry"
|
||||
fi
|
||||
etcd_status_via "$PEER_HOST" | tee -a "$LOG_LOCAL"
|
||||
fi
|
||||
|
||||
log "-- swap on $NODE"
|
||||
if [ "$DRY_RUN" != "1" ]; then
|
||||
local swap_total
|
||||
swap_total=$(ssh $SSH_OPTS "root@$TARGET_HOST" "awk '/^SwapTotal:/{print \$2}' /proc/meminfo")
|
||||
[ "${swap_total:-0}" -ge $((2 * 1024 * 1024)) ] \
|
||||
|| die "$NODE SwapTotal=${swap_total} KiB below 2 GiB -- run --add-swap first"
|
||||
log " $NODE SwapTotal=$(( swap_total / 1024 )) MiB"
|
||||
fi
|
||||
|
||||
log "-- kubectl-latency probe"
|
||||
if [ "$DRY_RUN" != "1" ]; then guard_kine_healthy; fi
|
||||
|
||||
log "-- record current etcd leader"
|
||||
if [ "$DRY_RUN" != "1" ] && [ -n "$PEER_HOST" ]; then
|
||||
if target_is_leader "$PEER_HOST" "$TARGET_HOST"; then
|
||||
warn "$NODE IS the current etcd leader. Per CP ordering rule, prefer updating a follower first."
|
||||
warn " Not aborting -- operator/agent must confirm this is intentional."
|
||||
else
|
||||
log " $NODE is a FOLLOWER -- safe to proceed."
|
||||
fi
|
||||
fi
|
||||
|
||||
log "-- k3s etcd snapshot"
|
||||
ssh_run "root@$TARGET_HOST" "k3s etcd-snapshot save --name pre-cp-os-update-${NODE}-${TS}"
|
||||
ssh_run "root@$TARGET_HOST" "ls -la /var/lib/rancher/k3s/server/db/snapshots/ | tail -10"
|
||||
|
||||
log "=== Phase B: preflight OK ==="
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Phase C — Cordon + drain
|
||||
# --------------------------------------------------------------------------- #
|
||||
phase_drain() {
|
||||
log "=== Phase C: cordon + drain $NODE ==="
|
||||
|
||||
log "-- cordon $NODE"
|
||||
run kubectl cordon "$NODE"
|
||||
|
||||
log "-- drain $NODE (timeout ${DRAIN_TIMEOUT_SECONDS}s)"
|
||||
set +e
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
log "DRY-RUN would run: kubectl drain $NODE --ignore-daemonsets --delete-emptydir-data --timeout=${DRAIN_TIMEOUT_SECONDS}s"
|
||||
local rc=0
|
||||
else
|
||||
kubectl drain "$NODE" \
|
||||
--ignore-daemonsets \
|
||||
--delete-emptydir-data \
|
||||
--timeout="${DRAIN_TIMEOUT_SECONDS}s" 2>&1 | tee -a "$LOG_LOCAL"
|
||||
local rc=${PIPESTATUS[0]}
|
||||
fi
|
||||
set -e
|
||||
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
log "drain FAILED (rc=$rc). Never force. Uncordoning."
|
||||
run kubectl uncordon "$NODE"
|
||||
die "drain failed on $NODE -- investigate PDB / orphan pods; do NOT proceed"
|
||||
fi
|
||||
|
||||
log "-- post-drain guardrails"
|
||||
if [ "$DRY_RUN" != "1" ]; then
|
||||
guard_kine_healthy
|
||||
guard_target_memory
|
||||
fi
|
||||
|
||||
log "=== Phase C: $NODE drained ==="
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Phase D — apt on the target
|
||||
# --------------------------------------------------------------------------- #
|
||||
phase_apt() {
|
||||
log "=== Phase D: apt on $NODE ==="
|
||||
|
||||
cat <<'REMOTE' | ssh_run_stdin
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
APT_OPTS='-y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold'
|
||||
|
||||
uname -r > /root/pre-apt-kernel
|
||||
dpkg-query -W -f='${Package}\t${Version}\n' > /root/pre-apt-packages.tsv
|
||||
echo "pre-apt kernel: $(cat /root/pre-apt-kernel)"
|
||||
|
||||
if dpkg --audit | grep -qE .; then
|
||||
echo "dpkg audit reported issues, running dpkg --configure -a"
|
||||
dpkg --configure -a || true
|
||||
fi
|
||||
|
||||
apt-get update
|
||||
|
||||
if ! apt-get $APT_OPTS upgrade; then
|
||||
echo "upgrade failed, attempting apt-get -f install"
|
||||
apt-get $APT_OPTS -f install
|
||||
apt-get $APT_OPTS upgrade
|
||||
fi
|
||||
|
||||
apt-get $APT_OPTS dist-upgrade
|
||||
apt-get $APT_OPTS autoremove --purge
|
||||
apt-get clean
|
||||
|
||||
if [ -f /var/run/reboot-required ]; then
|
||||
echo "REBOOT_REQUIRED=yes"
|
||||
echo "REBOOT_REASON<<EOF"
|
||||
cat /var/run/reboot-required.pkgs 2>/dev/null || echo "(no package list)"
|
||||
echo "EOF"
|
||||
else
|
||||
echo "REBOOT_REQUIRED=no"
|
||||
fi
|
||||
REMOTE
|
||||
|
||||
log "=== Phase D: apt complete (check REBOOT_REQUIRED in the log) ==="
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Phase E — Reboot and wait for target /livez + kubelet Ready + etcd rejoin
|
||||
# --------------------------------------------------------------------------- #
|
||||
phase_reboot() {
|
||||
log "=== Phase E: reboot $NODE ==="
|
||||
|
||||
if [ "$DRY_RUN" != "1" ]; then
|
||||
log "issuing 'systemctl reboot' on $NODE (ssh will drop; expected)"
|
||||
ssh $SSH_OPTS "root@$TARGET_HOST" 'systemctl reboot' 2>&1 | tee -a "$LOG_LOCAL" || true
|
||||
log "waiting 15s for ssh to fully drop before polling"
|
||||
sleep 15
|
||||
else
|
||||
log "DRY-RUN would ssh root@$TARGET_HOST 'systemctl reboot'"
|
||||
fi
|
||||
|
||||
log "-- poll $NODE api-server /livez (timeout ${REBOOT_MAX_WAIT_SECONDS}s)"
|
||||
if [ "$DRY_RUN" != "1" ]; then
|
||||
local deadline=$(( $(date +%s) + REBOOT_MAX_WAIT_SECONDS ))
|
||||
local code=000
|
||||
while [ $(date +%s) -lt $deadline ]; do
|
||||
code=$(curl -sk -o /dev/null -w '%{http_code}' "https://$TARGET_HOST:6443/livez" 2>/dev/null || echo 000)
|
||||
if [ "$code" = "200" ]; then
|
||||
log " $NODE api-server /livez=200"
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
[ "$code" = "200" ] || die "$NODE api-server did not return within ${REBOOT_MAX_WAIT_SECONDS}s -- escalate; check 'hcloud server describe $NODE' and Hetzner console"
|
||||
fi
|
||||
|
||||
log "-- verify etcd cluster status from peer ($PEER_HOST) -- $NODE should be 'started'"
|
||||
if [ "$DRY_RUN" != "1" ] && [ -n "$PEER_HOST" ]; then
|
||||
etcd_status_via "$PEER_HOST" | tee -a "$LOG_LOCAL"
|
||||
fi
|
||||
|
||||
log "-- wait for kubelet Ready on $NODE (max 300s)"
|
||||
if [ "$DRY_RUN" != "1" ]; then
|
||||
local deadline=$(( $(date +%s) + 300 ))
|
||||
local ready=Unknown
|
||||
while [ $(date +%s) -lt $deadline ]; do
|
||||
ready=$(kubectl get node "$NODE" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo Unknown)
|
||||
[ "$ready" = "True" ] && break
|
||||
sleep 5
|
||||
done
|
||||
[ "$ready" = "True" ] || die "kubelet on $NODE never returned Ready -- escalate (do NOT change k3s config)"
|
||||
log " kubelet Ready=True"
|
||||
fi
|
||||
|
||||
log "=== Phase E: $NODE is back ==="
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Phase F — Uncordon + verify + finalize
|
||||
# --------------------------------------------------------------------------- #
|
||||
phase_finalize() {
|
||||
log "=== Phase F: uncordon + verify $NODE ==="
|
||||
|
||||
run kubectl uncordon "$NODE"
|
||||
|
||||
log "-- settle wait ${POST_UNCORDON_WAIT_SECONDS}s"
|
||||
[ "$DRY_RUN" = "1" ] || sleep "$POST_UNCORDON_WAIT_SECONDS"
|
||||
|
||||
log "-- cluster health"
|
||||
if [ "$DRY_RUN" != "1" ]; then
|
||||
if ! RETRY_ON_TRANSIENT=1 "$HEALTH_SCRIPT" 2>&1 | tee -a "$LOG_LOCAL"; then
|
||||
die "cluster health failed after $NODE update -- escalate, do NOT touch k3s"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "-- etcd cluster status (all three should be started)"
|
||||
if [ "$DRY_RUN" != "1" ] && [ -n "$PEER_HOST" ]; then
|
||||
etcd_status_via "$PEER_HOST" | tee -a "$LOG_LOCAL"
|
||||
fi
|
||||
|
||||
log "-- apt history summary (audit)"
|
||||
ssh_run "root@$TARGET_HOST" 'zgrep -h "Commandline\|Install\|Upgrade\|Remove" /var/log/apt/history.log* 2>/dev/null | tail -60'
|
||||
|
||||
log "-- old snapshots (>30d) -- listing only, review manually"
|
||||
ssh_run "root@$TARGET_HOST" 'find /var/lib/rancher/k3s/server/db/snapshots/ -type f -mtime +30 -name "pre-*" -print 2>/dev/null || true'
|
||||
|
||||
log "=== $NODE OS update complete -- attach $LOG_LOCAL to the execution ticket ==="
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# --run — orchestrate all phases with confirmations
|
||||
# --------------------------------------------------------------------------- #
|
||||
phase_run_all() {
|
||||
log "=== full $NODE update run (log: $LOG_LOCAL) ==="
|
||||
|
||||
confirm "Phase A (add swap) -- proceed?"
|
||||
phase_add_swap
|
||||
|
||||
confirm "Phase B (preflight) -- proceed?"
|
||||
phase_preflight
|
||||
|
||||
confirm "Phase C (cordon + drain) -- proceed?"
|
||||
phase_drain
|
||||
|
||||
confirm "Phase D (apt) -- proceed?"
|
||||
phase_apt
|
||||
|
||||
confirm "Phase E (reboot $NODE; api-server on THIS node unavailable ~90-180s, other 2 CPs keep serving) -- proceed?"
|
||||
phase_reboot
|
||||
|
||||
confirm "Phase F (uncordon + verify) -- proceed?"
|
||||
phase_finalize
|
||||
|
||||
log "=== FULL RUN COMPLETE for $NODE ==="
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Phase dispatch
|
||||
# --------------------------------------------------------------------------- #
|
||||
: > "$LOG_LOCAL"
|
||||
log "update-cp-node.sh started (NODE=$NODE, TARGET_HOST=$TARGET_HOST, PEER_HOST=$PEER_HOST, DRY_RUN=$DRY_RUN)"
|
||||
log "log file: $LOG_LOCAL"
|
||||
|
||||
case "$1" in
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
export DRY_RUN
|
||||
log "DRY_RUN=1 -- walking Phases A..F without touching state"
|
||||
phase_add_swap
|
||||
phase_preflight
|
||||
phase_drain
|
||||
phase_apt
|
||||
phase_reboot
|
||||
phase_finalize
|
||||
;;
|
||||
--add-swap) phase_add_swap ;;
|
||||
--preflight) phase_preflight ;;
|
||||
--drain) phase_drain ;;
|
||||
--apt) phase_apt ;;
|
||||
--reboot) phase_reboot ;;
|
||||
--finalize) phase_finalize ;;
|
||||
--run) phase_run_all ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "unknown arg: $1" >&2; usage ;;
|
||||
esac
|
||||
197
infrastructure/scripts/os-update/update-node.sh
Executable file
197
infrastructure/scripts/os-update/update-node.sh
Executable file
|
|
@ -0,0 +1,197 @@
|
|||
#!/bin/bash
|
||||
# update-node.sh — drain, apt-update, reboot-if-needed, wait, uncordon a single node.
|
||||
#
|
||||
# Usage:
|
||||
# update-node.sh <node-name>
|
||||
#
|
||||
# Runs from an operator machine (or from the control plane); needs kubectl and
|
||||
# ssh access to root@<node-ssh-target>. Node → SSH target resolution is in
|
||||
# `node_ssh_target` below; edit that mapping when you add nodes.
|
||||
#
|
||||
# NEVER touches k3s config, k3s services, containerd, or any manifest.
|
||||
# Only fixes it will attempt: apt/dpkg recovery on the same node (see step 3).
|
||||
#
|
||||
# Environment overrides:
|
||||
# DRAIN_TIMEOUT_SECONDS (default 600)
|
||||
# REBOOT_MAX_WAIT_SECONDS (default 600)
|
||||
# POST_UNCORDON_WAIT_SECONDS (default 180)
|
||||
# CONTROL_PLANE_HOST (default 178.105.17.239)
|
||||
# SSH_OPTS (default "-o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new")
|
||||
# ASSUME_YES=1 to skip interactive confirmations
|
||||
set -euo pipefail
|
||||
|
||||
NODE="${1:-}"
|
||||
if [ -z "$NODE" ]; then
|
||||
echo "usage: $0 <node-name>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
DRAIN_TIMEOUT_SECONDS="${DRAIN_TIMEOUT_SECONDS:-600}"
|
||||
REBOOT_MAX_WAIT_SECONDS="${REBOOT_MAX_WAIT_SECONDS:-600}"
|
||||
POST_UNCORDON_WAIT_SECONDS="${POST_UNCORDON_WAIT_SECONDS:-180}"
|
||||
CONTROL_PLANE_HOST="${CONTROL_PLANE_HOST:-178.105.17.239}"
|
||||
SSH_OPTS="${SSH_OPTS:--o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new}"
|
||||
|
||||
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"; }
|
||||
die() { log "FATAL: $*"; exit 1; }
|
||||
|
||||
# node -> ssh target. Private-IP workers are reached via ProxyJump through the CP.
|
||||
node_ssh_target() {
|
||||
case "$1" in
|
||||
k3s-cp-1) echo "root@178.105.17.239" ;;
|
||||
k3s-worker-1) echo "-J root@$CONTROL_PLANE_HOST root@10.42.1.2" ;;
|
||||
k3s-worker-2) echo "-J root@$CONTROL_PLANE_HOST root@10.42.1.3" ;;
|
||||
k3s-worker-3) echo "root@167.233.121.121" ;;
|
||||
k3s-worker-4) echo "root@128.140.3.80" ;;
|
||||
k3s-worker-5) echo "root@167.233.192.86" ;;
|
||||
k3s-update-runner) echo "root@167.233.79.65" ;;
|
||||
*) die "unknown node $1 — update node_ssh_target() in $0" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
SSH_TARGET=$(node_ssh_target "$NODE")
|
||||
|
||||
log "=== update-node.sh $NODE ==="
|
||||
log "ssh target: $SSH_TARGET"
|
||||
|
||||
# --- 1. pre-check --------------------------------------------------------------
|
||||
log "[1/7] pre-check"
|
||||
kubectl get node "$NODE" >/dev/null || die "node $NODE not found in cluster"
|
||||
READY=$(kubectl get node "$NODE" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}')
|
||||
[ "$READY" = "True" ] || die "node $NODE is not Ready before we start (Ready=$READY)"
|
||||
|
||||
for cond in DiskPressure MemoryPressure PIDPressure; do
|
||||
v=$(kubectl get node "$NODE" -o jsonpath="{.status.conditions[?(@.type==\"$cond\")].status}")
|
||||
[ "$v" = "True" ] && die "node $NODE has $cond=True — refuse to update"
|
||||
done
|
||||
|
||||
# --- 2. cordon + drain --------------------------------------------------------
|
||||
log "[2/7] cordon + drain (timeout ${DRAIN_TIMEOUT_SECONDS}s)"
|
||||
kubectl cordon "$NODE"
|
||||
|
||||
set +e
|
||||
kubectl drain "$NODE" \
|
||||
--ignore-daemonsets \
|
||||
--delete-emptydir-data \
|
||||
--timeout="${DRAIN_TIMEOUT_SECONDS}s"
|
||||
DRAIN_RC=$?
|
||||
set -e
|
||||
|
||||
if [ $DRAIN_RC -ne 0 ]; then
|
||||
log "drain FAILED (rc=$DRAIN_RC). Never force. Uncordoning $NODE and marking SKIPPED."
|
||||
kubectl uncordon "$NODE" || true
|
||||
echo "SKIPPED_DRAIN_FAILED $NODE"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# --- 3. apt update on the node ------------------------------------------------
|
||||
log "[3/7] apt update/upgrade on $NODE"
|
||||
REMOTE_APT=$(cat <<'REMOTE'
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
APT_OPTS='-y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold'
|
||||
|
||||
# Recover from any half-finished dpkg state before touching apt.
|
||||
if ! dpkg --audit | grep -qE .; then
|
||||
:
|
||||
else
|
||||
echo "dpkg audit reported issues, running dpkg --configure -a"
|
||||
dpkg --configure -a || true
|
||||
fi
|
||||
|
||||
apt-get update
|
||||
|
||||
# Before any upgrade/autoremove pass, pin docker.io as manually-installed on
|
||||
# nodes where it is present. Without this, apt autoremove --purge has, in the
|
||||
# past, taken docker.io out from under the Forgejo runner (DEV-498/DEV-499).
|
||||
if dpkg -s docker.io >/dev/null 2>&1; then
|
||||
apt-mark manual docker.io >/dev/null
|
||||
fi
|
||||
|
||||
# Try upgrade; on broken deps, one attempt at apt-get -f install then retry.
|
||||
if ! apt-get $APT_OPTS upgrade; then
|
||||
echo "upgrade failed, attempting apt-get -f install"
|
||||
apt-get $APT_OPTS -f install
|
||||
apt-get $APT_OPTS upgrade
|
||||
fi
|
||||
|
||||
apt-get $APT_OPTS dist-upgrade
|
||||
apt-get $APT_OPTS autoremove --purge
|
||||
apt-get clean
|
||||
|
||||
if [ -f /var/run/reboot-required ]; then
|
||||
echo "REBOOT_REQUIRED=yes"
|
||||
echo "REBOOT_REASON<<EOF"
|
||||
cat /var/run/reboot-required.pkgs 2>/dev/null || echo "(no package list)"
|
||||
echo "EOF"
|
||||
else
|
||||
echo "REBOOT_REQUIRED=no"
|
||||
fi
|
||||
REMOTE
|
||||
)
|
||||
|
||||
APT_OUT=$(ssh $SSH_OPTS $SSH_TARGET "bash -s" <<< "$REMOTE_APT")
|
||||
echo "$APT_OUT" | sed 's/^/ /'
|
||||
|
||||
if echo "$APT_OUT" | grep -q '^REBOOT_REQUIRED=yes'; then
|
||||
REBOOT=1
|
||||
else
|
||||
REBOOT=0
|
||||
fi
|
||||
|
||||
# --- 4. reboot if required ----------------------------------------------------
|
||||
if [ "$REBOOT" -eq 1 ]; then
|
||||
log "[4/7] reboot required — rebooting $NODE"
|
||||
ssh $SSH_OPTS $SSH_TARGET 'systemctl reboot' || true
|
||||
|
||||
# Give SSH a moment to actually drop before we start polling.
|
||||
sleep 15
|
||||
|
||||
deadline=$(( $(date +%s) + REBOOT_MAX_WAIT_SECONDS ))
|
||||
while [ $(date +%s) -lt $deadline ]; do
|
||||
if ssh $SSH_OPTS -o ConnectTimeout=5 $SSH_TARGET 'uptime' >/dev/null 2>&1; then
|
||||
log " $NODE ssh is back"
|
||||
break
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
|
||||
if ! ssh $SSH_OPTS -o ConnectTimeout=5 $SSH_TARGET 'uptime' >/dev/null 2>&1; then
|
||||
die "node $NODE did not return within ${REBOOT_MAX_WAIT_SECONDS}s — escalate"
|
||||
fi
|
||||
else
|
||||
log "[4/7] no reboot needed"
|
||||
fi
|
||||
|
||||
# --- 5. wait for kubelet Ready ------------------------------------------------
|
||||
log "[5/7] wait for kubelet Ready on $NODE"
|
||||
deadline=$(( $(date +%s) + 300 ))
|
||||
while [ $(date +%s) -lt $deadline ]; do
|
||||
READY=$(kubectl get node "$NODE" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo Unknown)
|
||||
[ "$READY" = "True" ] && break
|
||||
sleep 5
|
||||
done
|
||||
[ "$READY" = "True" ] || die "kubelet on $NODE never returned Ready — escalate (do NOT change k3s config)"
|
||||
log " Ready=True"
|
||||
|
||||
# --- 5b. ensure Docker on designated nodes ------------------------------------
|
||||
# Do this BEFORE uncordoning so the runner pod's first scheduling attempt
|
||||
# succeeds instead of racing through ContainerCreating. Idempotent no-op on
|
||||
# nodes that are not labeled basicstack.de/docker=true. (DEV-499)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
log "[5b/7] ensure docker.io on nodes labeled basicstack.de/docker=true"
|
||||
"$SCRIPT_DIR/ensure-node-docker.sh" "$NODE"
|
||||
|
||||
# --- 6. uncordon --------------------------------------------------------------
|
||||
log "[6/7] uncordon $NODE"
|
||||
kubectl uncordon "$NODE"
|
||||
|
||||
# --- 7. post-node settle ------------------------------------------------------
|
||||
log "[7/7] post-node settle (${POST_UNCORDON_WAIT_SECONDS}s) + health check"
|
||||
sleep "$POST_UNCORDON_WAIT_SECONDS"
|
||||
|
||||
if RETRY_ON_TRANSIENT=1 "$SCRIPT_DIR/cluster-health.sh"; then
|
||||
log "=== $NODE update: OK ==="
|
||||
else
|
||||
die "cluster health failed after updating $NODE — STOP the cycle, do NOT continue"
|
||||
fi
|
||||
Loading…
Add table
Reference in a new issue