Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
879b53cf8b | ||
|
|
146178655d | ||
|
|
3d20f4e651 | ||
|
|
b5c9aab10a | ||
|
|
47449250f8 | ||
|
|
799d35cd56 | ||
|
|
b27b5f2d5b | ||
|
|
47a48950e8 | ||
|
|
274b96b8b3 | ||
|
|
3d7dd87821 | ||
|
|
b5b499f2f6 | ||
|
|
1335560c94 | ||
|
|
69a5652c39 | ||
|
|
3a8a41fc16 | ||
|
|
358ae278d4 | ||
|
|
123759b709 | ||
|
|
cb6f6e803a | ||
|
|
775419c2a4 | ||
|
|
daf55565ee | ||
|
|
5cb7c191ba | ||
|
|
3e0a2173be | ||
|
|
c789c448de | ||
|
|
0e02723daf | ||
|
|
68ee347844 | ||
|
|
4282f6945b | ||
|
|
221b350ea2 | ||
|
|
80e18f60d0 | ||
|
|
d4afb61324 | ||
|
|
7a85fd9390 | ||
|
|
83c5c9958b | ||
|
|
a7fee5c2fb | ||
|
|
f27f124cfb | ||
|
|
77bbf65a55 | ||
|
|
76515019f8 |
@@ -101,7 +101,7 @@ steps:
|
||||
bash scripts/ci/make-diag-kubeconfig.sh
|
||||
|
||||
- name: alert-triage-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -35,19 +35,19 @@ when:
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
- name: promtool-rules-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
commands:
|
||||
- sh scripts/ci/test-promtool-rules.sh
|
||||
|
||||
- name: yaml-citations-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
commands:
|
||||
- sh scripts/ci/test-yaml-citations.sh
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ steps:
|
||||
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
|
||||
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
|
||||
# test from rotting (it exercises the exact injection shapes).
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ steps:
|
||||
- bash scripts/ci/make-diag-kubeconfig.sh
|
||||
|
||||
- name: issue-fix-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -70,7 +70,7 @@ steps:
|
||||
# this step is skipped — otherwise we'd fork-bomb PRs.
|
||||
when:
|
||||
- branch: master
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -30,7 +30,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: update-audit-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -17,7 +17,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: checkout-notes
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
pull: true
|
||||
environment:
|
||||
WIKI_BOT_TOKEN:
|
||||
@@ -54,7 +54,7 @@ steps:
|
||||
- bash scripts/ci/make-diag-kubeconfig.sh
|
||||
|
||||
- name: distill
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
pull: true
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
@@ -89,14 +89,14 @@ steps:
|
||||
-p
|
||||
|
||||
- name: audit
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
pull: true
|
||||
commands:
|
||||
# Findings are report lines in output/, not a pipeline failure.
|
||||
- python3 -B notes/bin/wiki_audit.py --vault "$$PWD/notes"
|
||||
|
||||
- name: consolidate
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
pull: true
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
@@ -145,7 +145,7 @@ steps:
|
||||
exit $$rc
|
||||
|
||||
- name: push-notes
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.289
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.296
|
||||
pull: true
|
||||
environment:
|
||||
WIKI_BOT_TOKEN:
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""netbird-service-sync: reconcile NetBird reverse-proxy services from Ingresses.
|
||||
|
||||
Source of truth: every Ingress carrying the public label (dns.renner.uno/public).
|
||||
Source of truth: every Ingress carrying one of the public labels.
|
||||
dns.renner.uno/public also takes the host out of the LAN DNS (external-dns-unifi
|
||||
skips it); netbird.renner.uno/public publishes on the proxy only and leaves the
|
||||
LAN record in place.
|
||||
For each host on such an Ingress the NetBird management gets one reverse-proxy
|
||||
service in TLS-passthrough mode on :443, targeting Traefik's ClusterIP through a
|
||||
subnet resource that covers the cluster's service CIDR. SNI and Host reach
|
||||
@@ -42,7 +45,7 @@ NETWORK_NAME = os.environ.get("NETWORK_NAME", "k8s public ingress")
|
||||
ROUTER_GROUP = os.environ.get("ROUTER_GROUP_NAME", "k8s-ingress-router")
|
||||
SOURCE_GROUP = os.environ.get("SOURCE_GROUP_NAME", "All")
|
||||
TRAEFIK_NS, TRAEFIK_SVC = os.environ.get("TRAEFIK_SERVICE", "ingress/traefik").split("/", 1)
|
||||
PUBLIC_LABEL = os.environ.get("PUBLIC_LABEL", "dns.renner.uno/public")
|
||||
PUBLIC_LABELS = os.environ.get("PUBLIC_LABELS", "dns.renner.uno/public,netbird.renner.uno/public").split(",")
|
||||
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "443"))
|
||||
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
|
||||
HTTP_TIMEOUT = 20
|
||||
@@ -86,10 +89,12 @@ def k8s(path):
|
||||
|
||||
|
||||
def desired_hosts():
|
||||
sel = urllib.parse.quote(f"{PUBLIC_LABEL}=true")
|
||||
ingresses = k8s(f"/apis/networking.k8s.io/v1/ingresses?labelSelector={sel}")
|
||||
items = []
|
||||
for label in PUBLIC_LABELS:
|
||||
sel = urllib.parse.quote(f"{label}=true")
|
||||
items += k8s(f"/apis/networking.k8s.io/v1/ingresses?labelSelector={sel}").get("items", [])
|
||||
hosts = {}
|
||||
for ing in ingresses.get("items", []):
|
||||
for ing in items:
|
||||
ref = f'{ing["metadata"]["namespace"]}/{ing["metadata"]["name"]}'
|
||||
for rule in ing.get("spec", {}).get("rules", []):
|
||||
host = rule.get("host")
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
# Gitea MCP Server Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Deploy the official Gitea MCP server in-cluster at `https://gitea-mcp.renner.uno/mcp`, published on the NetBird proxy, authenticating every request with the caller's own Gitea PAT and rejecting tokenless requests.
|
||||
|
||||
**Architecture:** One stateless Deployment in a new `gitea-mcp` namespace, Flux-managed through its own Kustomization. Traefik Ingress with the proxy-only public label; netbird-service-sync publishes it. gitea-mcp runs in `-oauth` mode purely to get the 401-on-missing-token gate; it talks to Gitea via the in-cluster Service.
|
||||
|
||||
**Tech Stack:** Kubernetes manifests, Flux (Kustomization, image automation), Cilium CiliumNetworkPolicy, Traefik Ingress, cert-manager.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-11-gitea-mcp-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Image `docker.gitea.com/gitea-mcp-server`, initial tag `1.8.1`, ImagePolicy semver `>=1.8.1`, marker `# {"$imagepolicy": "flux-system:gitea-mcp"}`.
|
||||
- Args exactly: `-t http -p 8080 -oauth -oauth-public-url https://gitea-mcp.renner.uno -H http://gitea.gitea.svc.cluster.local:3000`. No `-T`/`GITEA_ACCESS_TOKEN`, no `-d`.
|
||||
- Host `gitea-mcp.renner.uno`; Ingress label `netbird.renner.uno/public: "true"` (not `dns.renner.uno/public`); cert issuer `letsencrypt-cluster`; TLS secret `gitea-mcp-tls`.
|
||||
- Pod label `app.kubernetes.io/name: gitea-mcp`; namespace `gitea-mcp`; Service `gitea-mcp:8080`.
|
||||
- No `world` egress from `gitea-mcp`.
|
||||
- Manifest comments: one terse factual line at most; rationale goes in commit/PR text.
|
||||
- Commits as claude-bot: `git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit …`; each git command its own Bash call.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Tokenless `POST /mcp` from the internet → must be 401, never anonymous Gitea data (Task 4 step 4).
|
||||
2. Request with an invalid/revoked PAT → 401 `invalid_token` (Task 4 step 5).
|
||||
3. Kubelet probes with `Host: <podIP>` → `/healthz` must stay 200 despite the oauth Host check (Task 3 readiness, Task 4 step 2).
|
||||
4. LAN client resolving `gitea-mcp.renner.uno` → must reach Traefik VIP directly, not the proxy (Task 4 step 3).
|
||||
5. Gitea refusing gitea-mcp's connection (missing CNP half) → MCP calls fail; authenticated `list_my_repos` must succeed (Task 4 step 6).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Namespace and network policies
|
||||
|
||||
**Files:**
|
||||
- Modify: `kubernetes/infrastructure/namespaces/namespaces.yaml` (append after the `gitea` Namespace)
|
||||
- Create: `kubernetes/infrastructure/network-policies/gitea-mcp-ns.yaml`
|
||||
- Modify: `kubernetes/infrastructure/network-policies/kustomization.yaml` (add entry after `gitea-ns.yaml`)
|
||||
- Modify: `kubernetes/apps/gitea/networkpolicy.yaml` (append CNP)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: namespace `gitea-mcp`; ingress to pods labelled `app.kubernetes.io/name: gitea-mcp` on 8080 from Traefik; egress from `gitea-mcp` to `gitea` pod :3000; Gitea-side ingress allowance from namespace `gitea-mcp` on :3000.
|
||||
|
||||
- [ ] **Step 1: Add the Namespace** — insert after the `name: gitea` Namespace document in `namespaces.yaml`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
# Gitea MCP server. Declared here so it exists before infrastructure-crds applies its CNPs.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `gitea-mcp-ns.yaml`**
|
||||
|
||||
```yaml
|
||||
---
|
||||
# Baseline policy for the `gitea-mcp` namespace.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
namespace: gitea-mcp
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea-mcp
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
- fromEntities:
|
||||
- host
|
||||
- remote-node
|
||||
- fromEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
operator: Exists
|
||||
icmps:
|
||||
- fields:
|
||||
- { type: 0, family: IPv4 }
|
||||
- { type: 3, family: IPv4 }
|
||||
- { type: 8, family: IPv4 }
|
||||
- { type: 11, family: IPv4 }
|
||||
- { type: 12, family: IPv4 }
|
||||
- { type: 1, family: IPv6 }
|
||||
- { type: 2, family: IPv6 }
|
||||
- { type: 3, family: IPv6 }
|
||||
- { type: 4, family: IPv6 }
|
||||
- { type: 128, family: IPv6 }
|
||||
- { type: 129, family: IPv6 }
|
||||
- { type: 133, family: IPv6 }
|
||||
- { type: 134, family: IPv6 }
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea-mcp
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea
|
||||
app.kubernetes.io/name: gitea
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3000"
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: gitea-mcp-allow-traefik
|
||||
namespace: gitea-mcp
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register it** — in `network-policies/kustomization.yaml` add ` - gitea-mcp-ns.yaml` directly after ` - gitea-ns.yaml`.
|
||||
|
||||
- [ ] **Step 4: Gitea-side allowance** — append to `kubernetes/apps/gitea/networkpolicy.yaml`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
# gitea-mcp reaches the in-cluster Service; egress half is in network-policies/gitea-mcp-ns.yaml.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: gitea-allow-gitea-mcp
|
||||
namespace: gitea
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea-mcp
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3000"
|
||||
protocol: TCP
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Validate builds**
|
||||
|
||||
Run each separately:
|
||||
`kubectl kustomize kubernetes/infrastructure/namespaces` → output contains `name: gitea-mcp`
|
||||
`kubectl kustomize kubernetes/infrastructure/network-policies` → output contains `gitea-mcp-allow-traefik`
|
||||
`kubectl kustomize kubernetes/apps/gitea` → output contains `gitea-allow-gitea-mcp`
|
||||
|
||||
- [ ] **Step 6: Server-side dry-run of the Gitea-side CNP** (namespace already exists):
|
||||
`kubectl apply --dry-run=server -f kubernetes/apps/gitea/networkpolicy.yaml`
|
||||
Expected: every CNP `configured (server dry run)` or `unchanged`, `gitea-allow-gitea-mcp created (server dry run)`.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
`git add kubernetes/infrastructure/namespaces/namespaces.yaml kubernetes/infrastructure/network-policies/gitea-mcp-ns.yaml kubernetes/infrastructure/network-policies/kustomization.yaml kubernetes/apps/gitea/networkpolicy.yaml`
|
||||
`git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit -m "feat(gitea-mcp): namespace and network policies" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||||
|
||||
### Task 2: Image automation
|
||||
|
||||
**Files:**
|
||||
- Create: `kubernetes/infrastructure/image-automation/gitea-mcp.yaml`
|
||||
- Modify: `kubernetes/infrastructure/image-automation/kustomization.yaml` (insert ` - gitea-mcp.yaml` alphabetically after ` - gatus.yaml`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: ImagePolicy `flux-system:gitea-mcp`, referenced by the marker in Task 3.
|
||||
|
||||
- [ ] **Step 1: Create `gitea-mcp.yaml`**
|
||||
|
||||
```yaml
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: docker.gitea.com/gitea-mcp-server
|
||||
interval: 1h
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: gitea-mcp
|
||||
policy:
|
||||
semver:
|
||||
range: ">=1.8.1"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register it** in `image-automation/kustomization.yaml` after ` - gatus.yaml`.
|
||||
|
||||
- [ ] **Step 3: Validate**
|
||||
`kubectl kustomize kubernetes/infrastructure/image-automation` → contains `docker.gitea.com/gitea-mcp-server`
|
||||
`kubectl apply --dry-run=server -f kubernetes/infrastructure/image-automation/gitea-mcp.yaml` → both objects `created (server dry run)`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
`git add kubernetes/infrastructure/image-automation/gitea-mcp.yaml kubernetes/infrastructure/image-automation/kustomization.yaml`
|
||||
`git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit -m "feat(gitea-mcp): track docker.gitea.com/gitea-mcp-server via image automation" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||||
|
||||
### Task 3: App manifests and Flux Kustomization
|
||||
|
||||
**Files:**
|
||||
- Create: `kubernetes/apps/gitea-mcp/deployment.yaml`, `service.yaml`, `ingress.yaml`, `kustomization.yaml`
|
||||
- Create: `kubernetes/clusters/homelab/gitea-mcp.yaml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: namespace + CNPs (Task 1), ImagePolicy `flux-system:gitea-mcp` (Task 2).
|
||||
- Produces: `https://gitea-mcp.renner.uno/mcp` and `/healthz`.
|
||||
|
||||
- [ ] **Step 1: `deployment.yaml`**
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: gitea-mcp
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gitea-mcp
|
||||
image: docker.gitea.com/gitea-mcp-server:1.8.1 # {"$imagepolicy": "flux-system:gitea-mcp"}
|
||||
args:
|
||||
- -t
|
||||
- http
|
||||
- -p
|
||||
- "8080"
|
||||
# Only for the 401 on tokenless requests; clients send their own Gitea PAT.
|
||||
- -oauth
|
||||
- -oauth-public-url
|
||||
- https://gitea-mcp.renner.uno
|
||||
- -H
|
||||
- http://gitea.gitea.svc.cluster.local:3000
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 128Mi
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 16Mi
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `service.yaml`**
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: gitea-mcp
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `ingress.yaml`**
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: gitea-mcp
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
# Published on the NetBird proxy; LAN DNS stays on the Traefik VIP.
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: gitea-mcp.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: gitea-mcp
|
||||
port:
|
||||
number: 8080
|
||||
tls:
|
||||
- secretName: gitea-mcp-tls
|
||||
hosts:
|
||||
- gitea-mcp.renner.uno
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `kustomization.yaml`**
|
||||
|
||||
```yaml
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `kubernetes/clusters/homelab/gitea-mcp.yaml`**
|
||||
|
||||
```yaml
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 2m
|
||||
timeout: 5m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/gitea-mcp
|
||||
prune: true
|
||||
wait: true
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Validate**
|
||||
`kubectl kustomize kubernetes/apps/gitea-mcp` → renders Deployment, Service, Ingress, all in namespace `gitea-mcp`.
|
||||
`kubectl apply --dry-run=server -f kubernetes/clusters/homelab/gitea-mcp.yaml` → `created (server dry run)`.
|
||||
Confirm `kubernetes/clusters/homelab/` is picked up without a listing file: `ls kubernetes/clusters/homelab/flux-system` and read its `kustomization.yaml`; if it lists files explicitly, add `gitea-mcp.yaml` where `gitea.yaml` is listed.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
`git add kubernetes/apps/gitea-mcp kubernetes/clusters/homelab/gitea-mcp.yaml`
|
||||
`git -c user.name='Claude Bot' -c user.email='claude-bot@renner.uno' commit -m "feat(gitea-mcp): deploy the Gitea MCP server at gitea-mcp.renner.uno" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||||
|
||||
### Task 4: PR, rollout and live verification
|
||||
|
||||
- [ ] **Step 1: Push and open the PR**
|
||||
|
||||
`git -c credential.helper='store --file=/home/krenner/.config/claude-bot/creds' push -u origin feat/gitea-mcp`
|
||||
`tea pr create --login claude-bot --repo k.renner/homelab --base master --head feat/gitea-mcp --title "feat(gitea-mcp): in-cluster Gitea MCP server on the NetBird proxy" --description "<summary of spec + verification plan; ends with 🤖 Generated with [Claude Code](https://claude.com/claude-code)>"`
|
||||
Wait for `ci-manifests-test` green (`woodpecker` CLI). Owner merges; never self-merge.
|
||||
|
||||
- [ ] **Step 2: After merge — rollout**
|
||||
`flux get kustomization gitea-mcp` → Ready True. `kubectl -n gitea-mcp get pods` → 1/1 Running, 0 restarts.
|
||||
`curl -sS -o /dev/stdout -w "\nHTTP %{http_code}\n" https://gitea-mcp.renner.uno/healthz` → `ok`, HTTP 200.
|
||||
|
||||
- [ ] **Step 3: DNS paths**
|
||||
`getent ahosts gitea-mcp.renner.uno` from the LAN → Traefik VIP (fd99::86 / 10.2.99.86).
|
||||
Public answer: query Cloudflare's authoritative NS directly (UDM intercepts `@1.1.1.1`) → NetBird proxy address. NetBird management lists a reverse-proxy service for `gitea-mcp.renner.uno` (netbird-service-sync logs via `logcli`).
|
||||
|
||||
- [ ] **Step 4: Tokenless request is rejected**
|
||||
|
||||
```
|
||||
curl -sS -i https://gitea-mcp.renner.uno/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
|
||||
```
|
||||
Expected: `HTTP/2 401` with `WWW-Authenticate: Bearer resource_metadata=…`.
|
||||
|
||||
- [ ] **Step 5: Invalid token is rejected** — same request plus `-H 'Authorization: Bearer invalid'` → 401 with `error="invalid_token"`.
|
||||
|
||||
- [ ] **Step 6: Authenticated calls work** — same `initialize` request with `--oauth2-bearer "$(cat /home/krenner/.config/claude-bot/gitea-token)"` → 200 with `serverInfo`. Then `tools/call` `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_my_repos","arguments":{}}}` with the same token → non-empty repo list; `list_my_repos` resolves the caller from the token, so a result proves the PAT was used. If the server demands `Mcp-Protocol-Version`/`Mcp-Method` headers, take the required values from the error and resend.
|
||||
|
||||
- [ ] **Step 7: Image policy** — `flux get image policy gitea-mcp` → latest image `docker.gitea.com/gitea-mcp-server:1.8.1` or newer.
|
||||
|
||||
- [ ] **Step 8: Wiki** — route through `wiki-ingest` → `wiki-distill`: netbird-service-sync label mechanism (`dns.renner.uno/public` vs `netbird.renner.uno/public`), and gitea-mcp deployment facts (oauth-mode-as-auth-gate, PAT passthrough, `/healthz` outside the Host check).
|
||||
@@ -0,0 +1,127 @@
|
||||
# Gitea MCP server — design
|
||||
|
||||
Date: 2026-10-11
|
||||
Status: draft, awaiting review
|
||||
|
||||
## Goal
|
||||
|
||||
Run the official Gitea MCP server (`gitea.com/gitea/gitea-mcp`) in the cluster
|
||||
and publish it on the NetBird proxy, so MCP clients anywhere (Claude Code on
|
||||
the workstation, Paperclip agents, other machines) can use Gitea through MCP
|
||||
with their own Gitea personal access token.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Hosting | In-cluster HTTP service, own namespace `gitea-mcp`, one stateless replica. |
|
||||
| Exposure | Public via NetBird proxy, host `gitea-mcp.renner.uno`, label `netbird.renner.uno/public` (proxy-only; the LAN record on the Traefik VIP stays, so LAN clients don't hairpin). |
|
||||
| Client auth | Bearer PAT per request (`Authorization: Bearer <pat>` or `token <pat>`). The server holds no token. Every call runs with exactly the caller's Gitea permissions. |
|
||||
| Anonymous access | Rejected with 401. Achieved by running with `-oauth`: in that mode a missing token is refused, a present token is passed through unchanged and checked with one `GET /api/v1/user`. No OAuth app or client secret is needed server-side. |
|
||||
| Gitea backend | `http://gitea.gitea.svc.cluster.local:3000` (in-cluster Service, no hairpin via the ingress LB). |
|
||||
| Image | `docker.gitea.com/gitea-mcp-server`, starting at `1.8.1`, tracked by Flux image automation (semver). |
|
||||
| Read-only mode | Off; write tools exposed. Write access is bounded by each caller's token. |
|
||||
|
||||
## Why the `-oauth` flag with PATs
|
||||
|
||||
Gitea answers anonymous API calls: `GET /api/v1/repos/search` without
|
||||
credentials returns `k.renner/homelab`, `konairius/Wiki` and
|
||||
`gitea_admin/_cargo-index`. In plain HTTP mode (no `-oauth`, no server token)
|
||||
gitea-mcp forwards tokenless requests as an anonymous Gitea client, so the MCP
|
||||
endpoint would serve those repos to anyone. Upstream has no "require token"
|
||||
flag outside OAuth mode.
|
||||
|
||||
With `-oauth -oauth-public-url https://gitea-mcp.renner.uno` (upstream
|
||||
`pkg/oauth/oauth.go`):
|
||||
|
||||
- `/mcp` without a token → 401 with an RFC 9728 `WWW-Authenticate` challenge.
|
||||
- `/mcp` with a token → `TokenRejected` calls Gitea `GET /user` with it; only a
|
||||
401 there rejects. The token is then used as-is for the API calls, so a plain
|
||||
PAT works. There is no JWT/introspection/audience check.
|
||||
- The server only publishes `/.well-known/oauth-protected-resource` metadata;
|
||||
config is the public URL and the Gitea URL, nothing else.
|
||||
- `/mcp` additionally checks `Host` against the public URL's host (or
|
||||
loopback) and returns 421 otherwise. `/healthz` is outside both checks.
|
||||
|
||||
Costs: one extra `/user` call per MCP request. The metadata document advertises
|
||||
the in-cluster Gitea URL as authorization server; this only matters for
|
||||
OAuth-flow clients (claude.ai connectors), which are out of scope.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
client ──HTTPS, Authorization: Bearer <pat>──▶ gitea-mcp.renner.uno
|
||||
├─ internet: NetBird proxy (TLS passthrough, PROXY v2) ─▶ Traefik
|
||||
└─ LAN: Traefik VIP (LAN DNS record) ─▶ Traefik
|
||||
Traefik (TLS termination, letsencrypt-cluster cert) ──HTTP :8080──▶ gitea-mcp pod
|
||||
gitea-mcp ──HTTP :3000, same token──▶ gitea.gitea.svc (Gitea API)
|
||||
```
|
||||
|
||||
Traefik forwards `Host` and the `Mcp-*` headers unchanged by default, which the
|
||||
Host check and the MCP transport need. `/mcp` is stateless POST only, so no
|
||||
sticky sessions or long-lived streams.
|
||||
|
||||
## Components
|
||||
|
||||
All paths under `kubernetes/`.
|
||||
|
||||
- `infrastructure/namespaces/namespaces.yaml` — Namespace `gitea-mcp`.
|
||||
- `clusters/homelab/gitea-mcp.yaml` — Flux Kustomization, path
|
||||
`./kubernetes/apps/gitea-mcp`, `prune: true`, `wait: true`, dependsOn
|
||||
`infrastructure`, `infrastructure-crds`.
|
||||
- `apps/gitea-mcp/`
|
||||
- `deployment.yaml` — 1 replica; label `app.kubernetes.io/name: gitea-mcp`;
|
||||
args `-t http -p 8080 -oauth -oauth-public-url https://gitea-mcp.renner.uno
|
||||
-H http://gitea.gitea.svc.cluster.local:3000`; no `GITEA_ACCESS_TOKEN`, no
|
||||
`-d` (debug may log tokens via the SDK); pod/container securityContext as in
|
||||
gatus (non-root 65532, drop ALL, no privilege escalation, RuntimeDefault
|
||||
seccomp) plus `readOnlyRootFilesystem: true` with a disk-backed `emptyDir`
|
||||
(`sizeLimit: 2Gi`) at `/tmp` for the app's own rotated log file; liveness/readiness `httpGet /healthz` on `http`;
|
||||
requests `cpu: 10m, memory: 32Mi`, limit `memory: 128Mi`; image line
|
||||
carries the `$imagepolicy` marker.
|
||||
- `service.yaml` — ClusterIP `gitea-mcp`, port 8080 → `http`.
|
||||
- `ingress.yaml` — host `gitea-mcp.renner.uno`, `ingressClassName: traefik`,
|
||||
`cert-manager.io/cluster-issuer: letsencrypt-cluster`, TLS secret
|
||||
`gitea-mcp-tls`, label `netbird.renner.uno/public: "true"`.
|
||||
- `kustomization.yaml`.
|
||||
- `infrastructure/network-policies/gitea-mcp-ns.yaml` (+ entry in that
|
||||
directory's `kustomization.yaml`) — default-deny CNP following
|
||||
`gitea-ns.yaml`: ingress from same namespace, `monitoring`, `host`/
|
||||
`remote-node`, ICMP; ingress from Traefik (`ingress` ns,
|
||||
`app.kubernetes.io/name: traefik`) on 8080; egress to kube-dns and to
|
||||
`gitea` ns `app.kubernetes.io/name: gitea` on 3000. No `world` egress.
|
||||
- `apps/gitea/networkpolicy.yaml` — new CNP `gitea-allow-gitea-mcp`, modelled on
|
||||
`gitea-allow-flux-rotator`: ingress to Gitea on 3000 from the `gitea-mcp`
|
||||
namespace.
|
||||
- `infrastructure/image-automation/gitea-mcp.yaml` (+ kustomization entry) —
|
||||
ImageRepository `docker.gitea.com/gitea-mcp-server`, ImagePolicy semver
|
||||
`>=1.8.1`.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Invalid/expired token → Gitea `/user` 401 → gitea-mcp returns 401
|
||||
`invalid_token`.
|
||||
- Gitea unreachable → upstream's token check fails open (transport errors are
|
||||
not treated as rejection), but the subsequent API call fails, so the client
|
||||
gets a tool error, never data.
|
||||
- Pod down → Traefik 503; Flux `wait` surfaces a failed rollout as
|
||||
`FluxResourceNotReady`.
|
||||
|
||||
## Verification (after Flux reconciles)
|
||||
|
||||
1. `gitea-mcp` Kustomization Ready; pod Ready.
|
||||
2. `https://gitea-mcp.renner.uno/healthz` → 200, from the LAN and through the
|
||||
NetBird proxy (public resolution).
|
||||
3. `POST /mcp` `initialize` without a token → 401.
|
||||
4. `POST /mcp` with the `claude-bot` PAT: `initialize`, then `tools/call`
|
||||
`list_my_repos` → returns repos.
|
||||
5. NetBird management lists a reverse-proxy service for
|
||||
`gitea-mcp.renner.uno`.
|
||||
6. ImagePolicy `gitea-mcp` resolves `1.8.1` (or newer).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Registering the server in Claude Code / other clients.
|
||||
- OAuth browser flow for claude.ai connectors (would need a Gitea OAuth2 app
|
||||
and `-H https://gitea.renner.uno`).
|
||||
- Read-only mode, tool filtering (`-O`/`-S`).
|
||||
+21
-14
@@ -165,26 +165,33 @@ permanent recovery.
|
||||
|
||||
---
|
||||
|
||||
## Velero restore-test: 80% OSD capacity gate
|
||||
## Velero backup verification (backup-verify)
|
||||
|
||||
The monthly Velero restore-test restores into the production Ceph pools. Before
|
||||
each schedule's restore it projects the restore's raw size (DataUpload
|
||||
`totalBytes` x the pool's replication/EC factor, spread over all OSDs) onto
|
||||
current OSD usage from Prometheus. If the fullest OSD would go above **80%**
|
||||
(`OSD_MAX_PCT` in `restore-test-cronjob.yaml`), that schedule is not restored:
|
||||
the Job log shows it as **SKIP** with the projected %, and the summary lists it
|
||||
under "Skipped (backup NOT restore-tested)". A SKIP does not fail the Job, so
|
||||
read the summary each month. If the projection cannot be made (Prometheus
|
||||
down, unsized DataUpload), the schedule is not restored and counts as FAIL.
|
||||
Background: REN-441, REN-442.
|
||||
Monthly (1st, 08:00 UTC) CronJob `velero/backup-verify`. It creates a throwaway
|
||||
Hetzner Cloud VM in fsn1 (project `homelab-backup-verify`) and runs
|
||||
`kopia snapshot verify --verify-files-percent=100` read-only against the latest
|
||||
snapshot of every DataUpload of each schedule's newest finished backup. Snapshot
|
||||
sizes are checked against `DataUpload.status.progress.totalBytes`. Nothing is
|
||||
restored into the cluster. A full run takes about an hour (2026-10-10: 39 PVCs,
|
||||
~410 GB, 61 min, cx23).
|
||||
|
||||
- Results: `kubectl -n velero logs job/<backup-verify-...>`; `FAIL` lines name the
|
||||
backup, PVC, snapshot and cause.
|
||||
- A failed run fires `KubeJobFailed` (velero).
|
||||
- Manual run: `kubectl -n velero create job bv-manual --from=cronjob/backup-verify`;
|
||||
limit it with env `ONLY_NAMESPACES=apps,gitea`.
|
||||
- Every run first deletes everything labelled `managed-by=backup-verify` in the
|
||||
Hetzner project, so a leftover VM is cleaned up by the next run. Do not start a
|
||||
manual run while another one is running: its sweep deletes the other run's VM.
|
||||
- This checks data readability, not a full restore. Disaster-recovery rehearsal is separate.
|
||||
|
||||
---
|
||||
|
||||
## CNPG restore-test (verify backups are restorable — non-destructive)
|
||||
|
||||
The monthly Velero restore-test (`kubernetes/infrastructure/velero/`) exercises
|
||||
PVC restores but **explicitly does not touch CNPG** (barman-cloud is a separate
|
||||
code path). Run this drill periodically to prove the CNPG backups are actually
|
||||
The monthly Velero backup verification (`kubernetes/infrastructure/velero/`)
|
||||
checks the Velero PVC backups but **does not cover CNPG** (barman-cloud is a
|
||||
separate code path). Run this drill periodically to prove the CNPG backups are actually
|
||||
restorable, **without touching the live clusters**. It restores into a throwaway
|
||||
cluster in the *same* namespace (so it can reference the existing namespaced
|
||||
`ObjectStore` and creds) and declares the barman plugin with
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: gitea-mcp
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gitea-mcp
|
||||
image: docker.gitea.com/gitea-mcp-server:1.8.1 # {"$imagepolicy": "flux-system:gitea-mcp"}
|
||||
# The image has no ENTRYPOINT; args alone would replace its CMD.
|
||||
command:
|
||||
- /app/gitea-mcp
|
||||
args:
|
||||
- -t
|
||||
- http
|
||||
- -p
|
||||
- "8080"
|
||||
# Only for the 401 on tokenless requests; clients send their own Gitea PAT.
|
||||
- -oauth
|
||||
- -oauth-public-url
|
||||
- https://gitea-mcp.renner.uno
|
||||
- -H
|
||||
- http://gitea.gitea.svc.cluster.local:3000
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 128Mi
|
||||
volumes:
|
||||
# Holds the app's own rotated log file (100MB x 11 max); stdout carries the same lines.
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 2Gi
|
||||
@@ -0,0 +1,28 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: gitea-mcp
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
# Published on the NetBird proxy; LAN DNS stays on the Traefik VIP.
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: gitea-mcp.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: gitea-mcp
|
||||
port:
|
||||
number: 8080
|
||||
tls:
|
||||
- secretName: gitea-mcp-tls
|
||||
hosts:
|
||||
- gitea-mcp.renner.uno
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: gitea-mcp
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
@@ -5,6 +5,9 @@ metadata:
|
||||
namespace: gitea
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea
|
||||
# Published on the NetBird proxy; LAN DNS stays on the Traefik VIP so
|
||||
# SSH (gitea-ssh) and node image pulls stay local.
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
# Long-running git fetch/push and container-registry blob uploads:
|
||||
|
||||
@@ -72,3 +72,23 @@ spec:
|
||||
- ports:
|
||||
- port: "6443"
|
||||
protocol: TCP
|
||||
---
|
||||
# gitea-mcp reaches the in-cluster Service; egress half is in network-policies/gitea-mcp-ns.yaml.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: gitea-allow-gitea-mcp
|
||||
namespace: gitea
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea-mcp
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3000"
|
||||
protocol: TCP
|
||||
|
||||
@@ -95,7 +95,7 @@ spec:
|
||||
memory: 32Mi
|
||||
containers:
|
||||
- name: home-assistant
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.10.0 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.10.1 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
env:
|
||||
- name: TZ
|
||||
value: Europe/Berlin
|
||||
|
||||
@@ -15,9 +15,6 @@ spec:
|
||||
# Per-check rather than ceph_health_status, so one permanently-active
|
||||
# check cannot mask every other Ceph warning.
|
||||
#
|
||||
# AUTH_INSECURE_* is excluded: it cannot clear until the nodes run a
|
||||
# kernel supporting aes256k over the CSI mount path (#527).
|
||||
#
|
||||
# POOL_NO_REDUNDANCY is in scope. An unreplicated pool makes
|
||||
# `ceph osd ok-to-stop` refuse for every OSD, so it reports blocked
|
||||
# maintenance and not just data risk — do not exclude it for a pool
|
||||
@@ -34,20 +31,14 @@ spec:
|
||||
# both episodes in the 30d to 2026-08-23 ran ~26h and cleared
|
||||
# untouched, the first ~24h after the last stall.
|
||||
#
|
||||
# Both began inside the Velero restore-test sweep — a ~5h sustained
|
||||
# restore that outruns the SLC cache on these QLC NVMe. The sweep
|
||||
# runs monthly, 08:00-14:00Z on the 1st.
|
||||
#
|
||||
# Expected for the hardware, but not excludable:
|
||||
# this is also the earliest signal of a genuinely failing drive, and
|
||||
# all three are at 80-87% endurance used (see cephcluster.yaml).
|
||||
expr: ceph_health_detail{name!~"AUTH_INSECURE_.+"} == 1
|
||||
# Not excludable: it is also the earliest signal of a failing drive.
|
||||
expr: ceph_health_detail == 1
|
||||
for: 6h
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Ceph health check {{ $labels.name }} active for 6h"
|
||||
description: "Ceph health check {{ $labels.name }} has been active for six hours. Only AUTH_INSECURE_* is excluded (kernel-blocked, see #527). BLUESTORE_SLOW_OP_ALERT is sticky for 24h after the last stall and clears itself: check whether the stall fell inside the monthly Velero restore-test window (08:00-14:00Z on the 1st), which is expected on these QLC drives — one outside that window, or a repeat on the same OSD, means check that drive (#557). Detail: `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph health detail`."
|
||||
description: "Ceph health check {{ $labels.name }} has been active for six hours. BLUESTORE_SLOW_OP_ALERT stays for 24h after the last stall even when latency is back to normal; find the OSD in the detail output and check that drive's SMART data (#557). Detail: `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph health detail`."
|
||||
|
||||
- alert: CephPoolLostRedundancy
|
||||
# Names the pool, which POOL_NO_REDUNDANCY does not.
|
||||
|
||||
@@ -50,11 +50,9 @@ data:
|
||||
PAPERLESS_TIKA_ENABLED: "1"
|
||||
PAPERLESS_TIKA_ENDPOINT: "http://tika:9998"
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: "http://gotenberg:3000"
|
||||
# AI suggestions against the cluster's own LLM (kubernetes/apps/llama-server).
|
||||
# Nothing leaves the cluster: both endpoints are ClusterIP Services, so
|
||||
# document text is never sent to a third party and there is no API key to
|
||||
# rotate — llama-server has no authentication, and the ai namespace's network
|
||||
# policy is what restricts who may reach it.
|
||||
# AI suggestions against Mistral AI's hosted API (OpenAI-compatible). Document
|
||||
# text is sent to Mistral. The key is PAPERLESS_AI_LLM_API_KEY, from the
|
||||
# paperless-secrets Secret (OpenBao apps/paperless, field minstral-token).
|
||||
#
|
||||
# CAVEAT, and it is a real one: paperless reads these from an
|
||||
# ApplicationConfiguration row in the database and falls back to the
|
||||
@@ -63,20 +61,14 @@ data:
|
||||
# here — and leave those fields untouched in the UI.
|
||||
PAPERLESS_AI_ENABLED: "true"
|
||||
PAPERLESS_AI_LLM_BACKEND: "openai-like"
|
||||
PAPERLESS_AI_LLM_ENDPOINT: "http://llama-server.ai.svc.cluster.local:8080/v1"
|
||||
# Must match --alias in kubernetes/apps/llama-server/deployment.yaml. The
|
||||
# server ignores the field on the wire, but paperless sends it and the value
|
||||
# is what appears in its logs.
|
||||
PAPERLESS_AI_LLM_MODEL: "qwen3.6-35b-a3b"
|
||||
# ClusterIP addresses are private, and paperless blocks non-public endpoints
|
||||
# unless this is set. Default is already true; explicit because the whole
|
||||
# integration silently stops working if it ever flips.
|
||||
PAPERLESS_AI_LLM_ENDPOINT: "https://api.mistral.ai/v1"
|
||||
PAPERLESS_AI_LLM_MODEL: "mistral-small-2603"
|
||||
# The embedding endpoint is the in-cluster llama-embed, a ClusterIP address,
|
||||
# and paperless blocks non-public endpoints unless this is set. Default is
|
||||
# already true; explicit because the embedding integration silently stops
|
||||
# working if it ever flips.
|
||||
PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS: "true"
|
||||
# Under llama-server's own 16384, leaving room for the response.
|
||||
PAPERLESS_AI_LLM_CONTEXT_SIZE: "12288"
|
||||
# The 120s default is too tight at ~11 tok/s: a classification is roughly
|
||||
# 2-4k prompt tokens plus a second round trip for localisation.
|
||||
PAPERLESS_AI_LLM_REQUEST_TIMEOUT: "300"
|
||||
# Matched against Django's settings.LANGUAGES, so the region is required —
|
||||
# "de" alone does not resolve. Without it the model returns German tags but
|
||||
# English document types, which was reproducible against this endpoint.
|
||||
@@ -86,6 +78,9 @@ data:
|
||||
# primarily English archives — the better fit for a German corpus.
|
||||
PAPERLESS_AI_LLM_EMBEDDING_BACKEND: "openai-like"
|
||||
PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT: "http://llama-embed.ai.svc.cluster.local:8080/v1"
|
||||
# The embedding key falls back to PAPERLESS_AI_LLM_API_KEY when unset, which
|
||||
# would send the Mistral token to llama-embed. llama-embed has no auth.
|
||||
PAPERLESS_AI_LLM_EMBEDDING_API_KEY: "unused"
|
||||
# Must match --alias in kubernetes/apps/llama-server/embed.yaml. Changing the
|
||||
# embedding model invalidates every stored vector, so a swap means the LLM
|
||||
# index has to be rebuilt.
|
||||
@@ -200,6 +195,11 @@ spec:
|
||||
secretKeyRef:
|
||||
name: paperless-secrets
|
||||
key: secret-key
|
||||
- name: PAPERLESS_AI_LLM_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: paperless-secrets
|
||||
key: minstral-token
|
||||
- name: PAPERLESS_DBUSER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 2m
|
||||
timeout: 5m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/gitea-mcp
|
||||
prune: true
|
||||
wait: true
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
@@ -4,7 +4,9 @@ metadata:
|
||||
name: alertmanager
|
||||
namespace: flux-system
|
||||
spec:
|
||||
# The notification controller appends `/api/v2/alerts/` itself, so
|
||||
# spec.address is the bare base URL with trailing slash.
|
||||
# The notification controller POSTs to spec.address verbatim (it does
|
||||
# not append a path), so the address must be the full Alertmanager v2
|
||||
# alerts endpoint. The bare base URL is answered with 405 Method Not
|
||||
# Allowed and every Flux notification is dropped.
|
||||
type: alertmanager
|
||||
address: http://kube-prometheus-stack-alertmanager.monitoring.svc.cluster.local:9093/
|
||||
address: http://kube-prometheus-stack-alertmanager.monitoring.svc.cluster.local:9093/api/v2/alerts/
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: docker.gitea.com/gitea-mcp-server
|
||||
interval: 1h
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: gitea-mcp
|
||||
policy:
|
||||
semver:
|
||||
range: ">=1.8.1"
|
||||
@@ -7,6 +7,7 @@ resources:
|
||||
- ceph.yaml
|
||||
- cnpg-barman-plugin.yaml
|
||||
- gatus.yaml
|
||||
- gitea-mcp.yaml
|
||||
- gotenberg.yaml
|
||||
- home-assistant.yaml
|
||||
- immich.yaml
|
||||
|
||||
@@ -198,6 +198,12 @@ kind: Namespace
|
||||
metadata:
|
||||
name: gitea
|
||||
---
|
||||
# Gitea MCP server. Declared here so it exists before infrastructure-crds applies its CNPs.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: gitea-mcp
|
||||
---
|
||||
# netbird-service-sync, moving out of `openbao` (issue #876). Declared here like
|
||||
# `woodpecker` above, so it exists before infrastructure-crds applies its
|
||||
# CiliumNetworkPolicies. No pod-security labels: the cluster default applies.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Reconciles NetBird reverse-proxy services from Ingresses that carry
|
||||
# dns.renner.uno/public: one TLS-passthrough service per host on :443,
|
||||
# dns.renner.uno/public or netbird.renner.uno/public (proxy only, LAN DNS
|
||||
# untouched): one TLS-passthrough service per host on :443,
|
||||
# targeting Traefik's ClusterIP through a subnet resource over the service
|
||||
# CIDR (ci/images/netbird-service-sync/controller.py).
|
||||
#
|
||||
@@ -80,7 +81,7 @@ spec:
|
||||
# image directory (branch and master alike) and the registry keeps
|
||||
# only the newest manifest per tag, so the digest must come from
|
||||
# the MASTER build of the code change.
|
||||
image: gitea.renner.uno/gitea_admin/netbird-service-sync:2026-09-23@sha256:64f6057c508640e1dbc50457dc26e9b2de058a111fd46ca096021d7444b5bbff
|
||||
image: gitea.renner.uno/gitea_admin/netbird-service-sync:2026-10-11@sha256:2e8c5e45cffd515cc93e04dc9fd47403a7910368fc7516e94c78d8f53dcd5272
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
# Baseline policy for the `gitea-mcp` namespace.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
namespace: gitea-mcp
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea-mcp
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
- fromEntities:
|
||||
- host
|
||||
- remote-node
|
||||
- fromEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
operator: Exists
|
||||
icmps:
|
||||
- fields:
|
||||
- { type: 0, family: IPv4 }
|
||||
- { type: 3, family: IPv4 }
|
||||
- { type: 8, family: IPv4 }
|
||||
- { type: 11, family: IPv4 }
|
||||
- { type: 12, family: IPv4 }
|
||||
- { type: 1, family: IPv6 }
|
||||
- { type: 2, family: IPv6 }
|
||||
- { type: 3, family: IPv6 }
|
||||
- { type: 4, family: IPv6 }
|
||||
- { type: 128, family: IPv6 }
|
||||
- { type: 129, family: IPv6 }
|
||||
- { type: 133, family: IPv6 }
|
||||
- { type: 134, family: IPv6 }
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea-mcp
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": gitea
|
||||
app.kubernetes.io/name: gitea
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "3000"
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: gitea-mcp-allow-traefik
|
||||
namespace: gitea-mcp
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
@@ -25,6 +25,7 @@ resources:
|
||||
- woodpecker-ci-ns.yaml
|
||||
- woodpecker-ns.yaml
|
||||
- gitea-ns.yaml
|
||||
- gitea-mcp-ns.yaml
|
||||
- netbird-service-sync-ns.yaml
|
||||
- trmnl-ns.yaml
|
||||
- matrix-ns.yaml
|
||||
|
||||
@@ -50,17 +50,6 @@ spec:
|
||||
- ports:
|
||||
- port: "9090"
|
||||
protocol: TCP
|
||||
# velero restore-test Job: its capacity gate reads OSD usage from
|
||||
# Prometheus :9090 (restore-test.sh, preflight_capacity). Scoped to the
|
||||
# Job's pod label; the velero namespace has no egress policy.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": velero
|
||||
app.kubernetes.io/name: restore-test
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "9090"
|
||||
protocol: TCP
|
||||
# Kubelet probes + control-plane (PrometheusRule/ServiceMonitor admission
|
||||
# webhooks via kube-prometheus-stack-operator are called by kube-apiserver).
|
||||
# Also covers external alloy-syslog traffic — ETP=Cluster on the LB SNATs
|
||||
|
||||
@@ -6,7 +6,7 @@ metadata:
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
# Intra-namespace — velero server ↔ node-agent ↔ restore-test job.
|
||||
# Intra-namespace — velero server ↔ node-agent.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": velero
|
||||
|
||||
@@ -6,7 +6,7 @@ metadata:
|
||||
spec:
|
||||
schedule: "0 8 1 * *"
|
||||
timeZone: Etc/UTC
|
||||
suspend: true
|
||||
suspend: false
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 2
|
||||
failedJobsHistoryLimit: 3
|
||||
|
||||
@@ -23,16 +23,19 @@ START_S=$(date +%s)
|
||||
|
||||
log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" >&2; }
|
||||
|
||||
sweep() {
|
||||
local kind id
|
||||
for kind in server firewall ssh-key; do
|
||||
for id in $(hcloud "$kind" list -l "$LABEL" -o noheader -o columns=id); do
|
||||
log "deleting $kind $id"
|
||||
hcloud "$kind" delete "$id" >&2 || log "WARN: could not delete $kind $id"
|
||||
done
|
||||
delete_kind() {
|
||||
local id
|
||||
for id in $(hcloud "$1" list -l "$LABEL" -o noheader -o columns=id); do
|
||||
log "deleting $1 $id"
|
||||
hcloud "$1" delete "$id" >&2 || log "WARN: could not delete $1 $id"
|
||||
done
|
||||
}
|
||||
|
||||
sweep() {
|
||||
local kind
|
||||
for kind in server firewall ssh-key; do delete_kind "$kind"; done
|
||||
}
|
||||
|
||||
build_plan() {
|
||||
local only
|
||||
only=$(jq -cn --arg s "$ONLY_NAMESPACES" '$s | split(",") | map(select(. != ""))')
|
||||
@@ -49,13 +52,20 @@ build_plan() {
|
||||
log "plan: $(jq -r 'group_by(.status) | map("\(.[0].status)=\(length)") | join(" ")' "$WORK/plan.json")"
|
||||
}
|
||||
|
||||
# cloud-config as JSON (a YAML subset), so jq does the escaping.
|
||||
render_user_data() {
|
||||
printf '#cloud-config\nssh_pwauth: false\nssh_deletekeys: true\nssh_genkeytypes: []\nssh_keys:\n ed25519_private: |\n'
|
||||
sed 's/^/ /' "$WORK/host_key"
|
||||
printf ' ed25519_public: %s\n' "$(cat "$WORK/host_key.pub")"
|
||||
printf 'packages: [jq, curl, ca-certificates]\nruncmd:\n'
|
||||
printf ' - mkdir -p /run/verify && mount -t tmpfs -o size=1500m,mode=0700 tmpfs /run/verify\n'
|
||||
printf ' - [shutdown, -h, "+240"]\n'
|
||||
printf '#cloud-config\n'
|
||||
jq -n --rawfile priv "$WORK/host_key" --arg pub "$(cat "$WORK/host_key.pub")" '{
|
||||
ssh_pwauth: false,
|
||||
ssh_deletekeys: true,
|
||||
ssh_genkeytypes: [],
|
||||
ssh_keys: {ed25519_private: $priv, ed25519_public: $pub},
|
||||
packages: ["jq", "curl", "ca-certificates"],
|
||||
runcmd: [
|
||||
"mkdir -p /run/verify && mount -t tmpfs -o size=1500m,mode=0700 tmpfs /run/verify",
|
||||
["shutdown", "-h", "+240"]
|
||||
]
|
||||
}'
|
||||
}
|
||||
|
||||
create_vm() {
|
||||
@@ -75,12 +85,14 @@ create_vm() {
|
||||
return 0
|
||||
fi
|
||||
log "server type $type not available"
|
||||
# A create that timed out may still have left the server behind under $NAME.
|
||||
delete_kind server
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_ssh() {
|
||||
local v4 v6 hostkey addr deadline
|
||||
local v4 v6 hostkey addr deadline rc
|
||||
v6=$(hcloud server ip -6 "$NAME")
|
||||
v4=$(hcloud server ip "$NAME")
|
||||
hostkey=$(cut -d' ' -f1,2 "$WORK/host_key.pub")
|
||||
@@ -96,6 +108,14 @@ wait_ssh() {
|
||||
VM_ADDR=$addr
|
||||
log "ssh ready via $addr"
|
||||
return 0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
# 255 is an ssh connection error; anything else is cloud-init itself failing.
|
||||
if [ "$rc" -ne 255 ]; then
|
||||
log "cloud-init failed on $addr (exit $rc):"
|
||||
ssh "${SSH_OPTS[@]}" "root@$addr" 'cloud-init status --long' < /dev/null >&2 || true
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
sleep 10
|
||||
@@ -112,11 +132,14 @@ secrets() {
|
||||
push() { ssh "${SSH_OPTS[@]}" "root@$VM_ADDR" "cat > /run/verify/$2" < "$1"; }
|
||||
|
||||
run_vm() {
|
||||
ssh "${SSH_OPTS[@]}" "root@$VM_ADDR" 'mkdir -p /run/verify' < /dev/null
|
||||
jq '[.[] | select(.status == "VERIFY")]' "$WORK/plan.json" > "$WORK/vm-plan.json"
|
||||
push "$WORK/vm-plan.json" plan.json
|
||||
push "$SCRIPT_DIR/check.jq" check.jq
|
||||
push "$SCRIPT_DIR/vm-verify.sh" vm-verify.sh
|
||||
if ! { ssh "${SSH_OPTS[@]}" "root@$VM_ADDR" 'mkdir -p /run/verify' < /dev/null \
|
||||
&& push "$WORK/vm-plan.json" plan.json \
|
||||
&& push "$SCRIPT_DIR/check.jq" check.jq \
|
||||
&& push "$SCRIPT_DIR/vm-verify.sh" vm-verify.sh; }; then
|
||||
log "could not stage files on the VM; every VERIFY entry will report no result"
|
||||
return 0
|
||||
fi
|
||||
# Background + wait: bash runs the TERM trap during `wait`, not during a foreground child.
|
||||
( secrets | ssh "${SSH_OPTS[@]}" "root@$VM_ADDR" 'bash /run/verify/vm-verify.sh /run/verify/plan.json' \
|
||||
| tee "$WORK/vm.out" >&2 ) &
|
||||
|
||||
@@ -6,7 +6,9 @@ case "$verb" in
|
||||
create)
|
||||
if [ "$kind" = server ]; then
|
||||
type=$(printf '%s\n' "$@" | grep -A1 -x -- '--type' | tail -n1)
|
||||
if [ -s "$state" ]; then echo "name is already used (uniqueness_error)" >&2; exit 1; fi
|
||||
for bad in ${STUB_UNAVAILABLE_TYPES:-}; do [ "$type" = "$bad" ] && { echo "server type unavailable" >&2; exit 1; }; done
|
||||
if [ "$type" = "${STUB_CREATE_LEAVES_SERVER:-}" ]; then echo "server-id" >> "$state"; echo "action timed out" >&2; exit 1; fi
|
||||
echo '{"server":{"id":4711}}'
|
||||
fi
|
||||
echo "${kind}-id" >> "$state" ;;
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
printf '%s\n' "$*" >> "$STUB_LOG"
|
||||
args=" $* "
|
||||
case "$args" in
|
||||
*" repository connect "*) [ -z "${STUB_CONNECT_FAIL:-}" ] ;;
|
||||
*" repository connect "*)
|
||||
echo "CHECK=${KOPIA_CHECK_FOR_UPDATES:-unset}" >> "$STUB_LOG"
|
||||
[ -z "${STUB_CONNECT_FAIL:-}" ] ;;
|
||||
*" snapshot list "*) cat "$STUB_SNAPSHOTS" ;;
|
||||
*" snapshot verify "*)
|
||||
if read -r -t 0.2 _; then echo "STDIN_LEAK" >> "$STUB_LOG"; fi
|
||||
for d in ${STUB_BAD_DIRS:-}; do
|
||||
case "$args" in *"--directory-id=$d "*) echo "error processing $d: boom" >&2; exit 1 ;; esac
|
||||
done ;;
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
printf 'ssh %s\n' "$*" >> "$STUB_LOG"
|
||||
cat > /dev/null
|
||||
case "$*" in
|
||||
*"cloud-init status --long"*)
|
||||
printf 'status: error\nerrors: %s\n' "${STUB_CLOUDINIT_ERROR:-boom}" ;;
|
||||
*"cloud-init status --wait"*)
|
||||
echo "wait started" >> "$STUB_LOG"
|
||||
sleep "${STUB_WAIT_SLEEP:-0}" ;;
|
||||
sleep "${STUB_WAIT_SLEEP:-0}"
|
||||
[ -z "${STUB_CLOUDINIT_FAIL:-}" ] || exit 1 ;;
|
||||
*"cat > /run/verify/"*)
|
||||
cat > /dev/null
|
||||
[ -z "${STUB_PUSH_FAIL:-}" ] || exit 1 ;;
|
||||
*"bash /run/verify/vm-verify.sh"*)
|
||||
cat > "${STUB_STDIN:-/dev/null}"
|
||||
echo "run started" >> "$STUB_LOG"
|
||||
sleep "${STUB_VM_SLEEP:-0}"
|
||||
cat "$STUB_VM_OUT" ;;
|
||||
*) cat > /dev/null ;;
|
||||
esac
|
||||
|
||||
@@ -32,7 +32,47 @@ grep -q 'PASS=1 FAIL=0 SKIP=0' <<<"$out" && ok "happy summary" || bad "happy sum
|
||||
grep -q 'type cx23' "$STUB_LOG" && ok "happy uses cx23" || bad "happy uses cx23"
|
||||
grep -q $'^OK\tapps-daily\tapps-daily-2\tapps/gatus-data\tsnapshot=s1\t' <<<"$out" && ok "summary row names backup and snapshot" || bad "summary row: $out"
|
||||
grep -qE '^elapsed=[0-9]+s server_type=cx23$' <<<"$out" && ok "summary elapsed and server type" || bad "summary elapsed/type: $out"
|
||||
grep -q 'mount -t tmpfs -o size=1500m,mode=0700 tmpfs /run/verify' "$T/work/user-data.yaml" && ok "user-data mounts tmpfs at /run/verify" || bad "user-data tmpfs mount"
|
||||
[ "$(head -n1 "$T/work/user-data.yaml")" = "#cloud-config" ] && ok "user-data cloud-config header" || bad "user-data header"
|
||||
tail -n +2 "$T/work/user-data.yaml" | jq -e '
|
||||
.ssh_pwauth == false
|
||||
and (.ssh_keys.ed25519_private | startswith("-----BEGIN OPENSSH PRIVATE KEY-----"))
|
||||
and (.ssh_keys.ed25519_public | startswith("ssh-ed25519 "))
|
||||
and (.runcmd | map(tostring) | any(test("mount -t tmpfs -o size=1500m,mode=0700 tmpfs /run/verify")))' > /dev/null \
|
||||
&& ok "user-data parses, pins host key, mounts tmpfs" || bad "user-data structure"
|
||||
|
||||
# secrets: AWS ini with and without spaces around '=' reaches the VM on stdin.
|
||||
want_stdin=$'AWS_ACCESS_KEY_ID=ak\nAWS_SECRET_ACCESS_KEY=sk\nKOPIA_PASSWORD=pw'
|
||||
setup
|
||||
STUB_STDIN="$T/stdin" bash "$bv/backup-verify.sh" > /dev/null 2> "$T/err"
|
||||
[ "$(cat "$T/stdin")" = "$want_stdin" ] && ok "secrets on stdin (no spaces)" || bad "secrets on stdin (no spaces): $(cat "$T/stdin")"
|
||||
setup
|
||||
printf '[default]\naws_access_key_id = ak\naws_secret_access_key = sk\n' > "$T/secrets/cloud"
|
||||
STUB_STDIN="$T/stdin" bash "$bv/backup-verify.sh" > /dev/null 2> "$T/err"
|
||||
[ "$(cat "$T/stdin")" = "$want_stdin" ] && ok "secrets on stdin (spaces)" || bad "secrets on stdin (spaces): $(cat "$T/stdin")"
|
||||
|
||||
# no-types: every server type unavailable -> exit 1, entries FAIL, nothing left.
|
||||
setup
|
||||
if out=$(STUB_UNAVAILABLE_TYPES="cx23 cax11" bash "$bv/backup-verify.sh" 2> "$T/err"); then bad "no-types exit 1"; else ok "no-types exit 1"; fi
|
||||
grep -q $'^FAIL\t.*no result from VM' <<<"$out" && ok "no-types reports FAIL per entry" || bad "no-types output: $out"
|
||||
[ "$(leftovers)" = 0 ] && ok "no-types teardown" || bad "no-types teardown"
|
||||
|
||||
# retry: cx23 create leaves a server behind server-side -> removed before cax11.
|
||||
setup
|
||||
if STUB_CREATE_LEAVES_SERVER=cx23 bash "$bv/backup-verify.sh" > /dev/null 2> "$T/err"; then ok "retry exit 0"; else bad "retry exit 0"; cat "$T/err"; fi
|
||||
grep -q 'type cax11' "$STUB_LOG" && ok "retry uses cax11" || bad "retry uses cax11"
|
||||
|
||||
# push-fail: staging files on the VM fails -> summary still printed, exit 1.
|
||||
setup
|
||||
if out=$(STUB_PUSH_FAIL=1 bash "$bv/backup-verify.sh" 2> "$T/err"); then bad "push-fail exit 1"; else ok "push-fail exit 1"; fi
|
||||
grep -q 'PASS=0 FAIL=1 SKIP=0' <<<"$out" && ok "push-fail still summarizes" || bad "push-fail output: $out"
|
||||
|
||||
# cloud-init fails: reason logged, no wait until SSH_WAIT_S.
|
||||
setup
|
||||
start=$(date +%s)
|
||||
SSH_WAIT_S=60 STUB_CLOUDINIT_FAIL=1 STUB_CLOUDINIT_ERROR=pkg-install-failed bash "$bv/backup-verify.sh" > /dev/null 2> "$T/err"
|
||||
took=$(( $(date +%s) - start ))
|
||||
grep -q 'pkg-install-failed' "$T/err" && ok "cloud-init failure reason logged" || bad "cloud-init reason missing"
|
||||
[ "$took" -lt 10 ] && ok "cloud-init failure fails fast (${took}s)" || bad "cloud-init failure took ${took}s"
|
||||
|
||||
# fallback: cx23 unavailable -> cax11 (Review Focus 5).
|
||||
setup
|
||||
|
||||
@@ -19,6 +19,8 @@ check "connect is readonly" "$(grep -c -- '--readonly' "$STUB_LOG")" "1"
|
||||
check "prefix per namespace" "$(grep -c -- '--prefix=kopia/apps/' "$STUB_LOG")" "1"
|
||||
check "full verify" "$(grep -c -- '--verify-files-percent=100' "$STUB_LOG")" "2"
|
||||
check "verify parallelism" "$(grep -c -- '--file-parallelism=8' "$STUB_LOG")" "2"
|
||||
check "verify reads no loop stdin" "$(grep -c 'STDIN_LEAK' "$STUB_LOG" || true)" "0"
|
||||
check "kopia update check off" "$(grep -c '^CHECK=false$' "$STUB_LOG")" "1"
|
||||
|
||||
# 2: connect fails -> every entry FAIL, script still prints DONE.
|
||||
: > "$STUB_LOG"
|
||||
|
||||
@@ -10,6 +10,7 @@ BUCKET=${BUCKET:-renner-uno-k8s-velero}
|
||||
S3_ENDPOINT=${S3_ENDPOINT:-fsn1.your-objectstorage.com}
|
||||
S3_REGION=${S3_REGION:-fsn1}
|
||||
KOPIA_VERSION=0.16.0
|
||||
export KOPIA_CHECK_FOR_UPDATES=false
|
||||
declare -A KOPIA_SHA256=(
|
||||
[x64]=a29f2cc1a49f985d1bfe09340eda0f7ed7b3c98037704da249b47034f1be1a18
|
||||
[arm64]=dc20b1d50d0d6e6d0e429f4f6062088f188100f3c9dab6d868d232169ab413a8
|
||||
@@ -77,7 +78,7 @@ verify_namespace() {
|
||||
root=$(jq -r .rootID <<<"$e")
|
||||
bytes=$(jq -r .kopiaBytes <<<"$e")
|
||||
if "$KOPIA" --config-file="$cfg" snapshot verify --directory-id="$root" \
|
||||
--verify-files-percent=100 --file-parallelism=8 >&2; then
|
||||
--verify-files-percent=100 --file-parallelism=8 < /dev/null >&2; then
|
||||
result OK "$ns" "$pvc" "$snap" "verified $bytes bytes"
|
||||
else
|
||||
result FAIL "$ns" "$pvc" "$snap" "kopia verify failed (see log)"
|
||||
|
||||
@@ -4,28 +4,20 @@ resources:
|
||||
- snapshot-readonly-storageclass.yaml
|
||||
- node-agent-config.yaml
|
||||
- helmrelease.yaml
|
||||
- restore-test-rbac.yaml
|
||||
- restore-test-cronjob.yaml
|
||||
- backup-verify-rbac.yaml
|
||||
- backup-verify-cronjob.yaml
|
||||
# Velero Schedule CRs live in kubernetes/infrastructure/post-crds/
|
||||
# (velero-schedules.yaml) so they reconcile after the Velero HelmRelease
|
||||
# finishes installing velero.io CRDs.
|
||||
|
||||
# Bake restore-test.sh into a ConfigMap mounted by the CronJob. Hash
|
||||
# suffix is disabled because Kustomize's NameReference transformer does
|
||||
# not rewrite configMap references inside the deeply nested CronJob
|
||||
# Bake the backup-verify scripts into a ConfigMap mounted by the CronJob.
|
||||
# Hash suffix is disabled because Kustomize's NameReference transformer
|
||||
# does not rewrite configMap references inside the deeply nested CronJob
|
||||
# pod template (spec.jobTemplate.spec.template.spec.volumes), so the
|
||||
# CronJob would otherwise dangle pointing at a stale name. CronJobs
|
||||
# already read the latest ConfigMap content at each pod start, so the
|
||||
# hash buys nothing here.
|
||||
configMapGenerator:
|
||||
- name: restore-test-script
|
||||
namespace: velero
|
||||
options:
|
||||
disableNameSuffixHash: true
|
||||
files:
|
||||
- restore-test.sh
|
||||
- name: backup-verify-scripts
|
||||
namespace: velero
|
||||
options:
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
---
|
||||
# Monthly Velero restore-test CronJob — 08:00 UTC on the 1st.
|
||||
#
|
||||
# The sweep restores every discovered schedule, immich's ~180 GiB library
|
||||
# included. At `size: 2` that is ~360 GiB raw, ~120 GiB per OSD, against a
|
||||
# ~65 GB/day weekday baseline on three QLC NVMe at 81-87% endurance used —
|
||||
# ~47% of each drive's weekly write budget. Monthly keeps every restore path
|
||||
# exercised and cuts that share ~4x.
|
||||
#
|
||||
# 08:00 — *after* that day's own daily backups have finished (paperless
|
||||
# 03:15 through apps 07:15, each normally ~1 min). The previous 02:00 slot
|
||||
# was chosen as "well before the next day's backups", which missed that the
|
||||
# daily schedules fire on the same day: a sweep starting at 02:00 and
|
||||
# running ~5 h sat on top of the entire 03:15-07:15 backup window and
|
||||
# starved it of node-agent DataUpload concurrency. Observed effect on
|
||||
# 2026-07-19 and 2026-07-26 — audiobookshelf-daily took 2 h 18 m and
|
||||
# 2 h 33 m respectively against a ~1 min baseline, with the books
|
||||
# DataUpload not starting until 06:18. Both are Sundays, the only day the
|
||||
# sweep ran then; no other day shows it.
|
||||
#
|
||||
# 08:00 + the 6 h activeDeadlineSeconds cap still lands well clear of the
|
||||
# next day's 03:15.
|
||||
#
|
||||
# Day-of-month rather than a fixed weekday: Kubernetes parses cron with
|
||||
# robfig/cron's standard parser, which has no `#` nth-weekday operator, and
|
||||
# restricting both day fields (`0 8 1-7 * 0`) ORs them — every Sunday plus
|
||||
# the 1st through 7th.
|
||||
#
|
||||
# Concurrency policy Forbid: the immich restore (~180 GiB, millions of
|
||||
# small files) alone can run multiple hours, so the full sweep is sized
|
||||
# at up to 6 h. If a run overlaps with the next firing it is silently
|
||||
# replaced. Failed runs are kept (history > 0) so the failure context is
|
||||
# inspectable in `kubectl get jobs -n velero -l job-name~restore-test`.
|
||||
#
|
||||
# The script lives at restore-test.sh in this directory and is mounted
|
||||
# from a configmap built by configMapGenerator (see kustomization.yaml).
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: restore-test
|
||||
namespace: velero
|
||||
spec:
|
||||
schedule: "0 8 1 * *"
|
||||
timeZone: Etc/UTC
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 2
|
||||
failedJobsHistoryLimit: 5
|
||||
startingDeadlineSeconds: 3600
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 21600 # 6 h hard cap (immich alone can take ~3 h)
|
||||
ttlSecondsAfterFinished: 3456000 # 40 d — enough to inspect after the next monthly run
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
# Matched by monitoring-ns.yaml: the capacity gate reads OSD
|
||||
# usage from Prometheus :9090.
|
||||
app.kubernetes.io/name: restore-test
|
||||
spec:
|
||||
serviceAccountName: restore-test
|
||||
restartPolicy: Never
|
||||
# On TERM (deadline or deletion) the script cancels the restore
|
||||
# in flight: up to 2 min for DataDownloads to stop, up to 5 min
|
||||
# for the namespace to go, then the PV purge (REN-451).
|
||||
terminationGracePeriodSeconds: 600
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: restore-test
|
||||
image: alpine/k8s:1.37.1@sha256:712dc5385d1c1a3f83b747fa9e40f64d433a97f72ff42f9755ff7dd1f62846e4
|
||||
command: ["bash", "/script/restore-test.sh"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
env:
|
||||
- name: RESTORE_PHASE_TIMEOUT_S
|
||||
value: "14400"
|
||||
# Skip a restore projected to take the fullest OSD past
|
||||
# this (SKIP, not FAIL). See restore-test.sh.
|
||||
- name: OSD_MAX_PCT
|
||||
value: "80"
|
||||
# Cancel a running restore when the fullest OSD reaches
|
||||
# this (below Ceph nearfull 85%) and stop the sweep.
|
||||
- name: OSD_ABORT_PCT
|
||||
value: "83"
|
||||
# Keep equal to activeDeadlineSeconds above: no restore
|
||||
# starts that its timeout would carry past it.
|
||||
- name: JOB_DEADLINE_S
|
||||
value: "21600"
|
||||
# readOnlyRootFilesystem=true blocks kubectl from writing
|
||||
# discovery cache to ~/.kube. Point HOME at the writable
|
||||
# tmp emptyDir so kubectl works.
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
volumeMounts:
|
||||
- name: script
|
||||
mountPath: /script
|
||||
readOnly: true
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 256Mi
|
||||
volumes:
|
||||
- name: script
|
||||
configMap:
|
||||
name: restore-test-script
|
||||
defaultMode: 0755
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 16Mi
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
# RBAC for the monthly restore-test CronJob.
|
||||
#
|
||||
# Permissions required by restore-test.sh:
|
||||
# - velero.io: read schedules/backups/BSL, create+delete restores,
|
||||
# cancel (patch) the DataDownloads of a restore it aborts
|
||||
# - core: create+delete ephemeral <ns>-restore-test namespaces, the
|
||||
# PVCs Velero restores into them, and the probe pods that mount
|
||||
# those PVCs
|
||||
# - core: pods/exec to run `find` inside the probe pod
|
||||
# - core: pods watch for `kubectl wait --for=condition=ready`
|
||||
# - apps: read DaemonSet status (node-agent readiness check)
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: restore-test
|
||||
namespace: velero
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: restore-test
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["namespaces"]
|
||||
verbs: ["get", "list", "create", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumeclaims"]
|
||||
verbs: ["get", "list", "create", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumes"]
|
||||
# patch needed to flip reclaimPolicy from Retain → Delete on test residue
|
||||
# before deleting, so CSI purges the backing CephFS subvolume / RBD image.
|
||||
verbs: ["get", "list", "delete", "patch"]
|
||||
- apiGroups: ["snapshot.storage.k8s.io"]
|
||||
resources: ["volumesnapshots"]
|
||||
verbs: ["get", "list", "create", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list", "watch", "create", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/exec", "pods/log"]
|
||||
verbs: ["get", "create"]
|
||||
- apiGroups: [""]
|
||||
resources: ["events"]
|
||||
verbs: ["get", "list"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["daemonsets"]
|
||||
verbs: ["get", "list"]
|
||||
- apiGroups: ["velero.io"]
|
||||
resources: ["schedules", "backups", "backupstoragelocations", "datauploads"]
|
||||
verbs: ["get", "list"]
|
||||
- apiGroups: ["velero.io"]
|
||||
# patch sets spec.cancel to stop a restore that is filling Ceph or
|
||||
# outlives the Job; deleting the Restore does not stop it (REN-451).
|
||||
resources: ["datadownloads"]
|
||||
verbs: ["get", "list", "patch"]
|
||||
- apiGroups: ["velero.io"]
|
||||
resources: ["restores"]
|
||||
verbs: ["get", "list", "watch", "create", "delete"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: restore-test
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: restore-test
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: restore-test
|
||||
namespace: velero
|
||||
@@ -1,801 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Monthly verification that Velero PVC backups are actually restorable.
|
||||
#
|
||||
# What this exercises (per service):
|
||||
# 1. Velero Restore reaches phase=Completed against yesterday's
|
||||
# scheduled Backup (real artifact, not a synthetic one).
|
||||
# 2. Each restored PVC is mountable and its file count is within ±10%
|
||||
# (or ±ABS_PARITY_SLACK files, whichever is wider) of the live
|
||||
# source volume, ignoring the transient subtrees listed in
|
||||
# volatile_paths_for().
|
||||
#
|
||||
# What this does NOT exercise:
|
||||
# - CNPG database restore (barman-cloud, separate code path). CNPG
|
||||
# volumes are never restored, cloned or counted here, even when a
|
||||
# backup holds one (see CNPG_EXCLUDE).
|
||||
# - Application-level boot against the restored data. Add per-service
|
||||
# hooks below if/when needed; for now, file-presence is the bar.
|
||||
#
|
||||
# Discovery: every velero.io Schedule labelled
|
||||
# app.kubernetes.io/part-of=homelab-backup
|
||||
# is picked up automatically, so new services don't need a code change
|
||||
# here.
|
||||
#
|
||||
# Cost: each service is restored sequentially and torn down before the
|
||||
# next, so peak storage cost is the size of the largest single service
|
||||
# (immich media, immich-library-bulk, ~250 GiB at time of writing). Immich
|
||||
# alone can take multiple hours (millions of small files), so the full
|
||||
# sweep is sized at up to 6 h via the cronjob's activeDeadlineSeconds.
|
||||
#
|
||||
# Capacity gate: before each restore, the raw size of everything the run
|
||||
# writes for it (restored PVCs plus source-side probe clones) is projected
|
||||
# onto the fullest OSD (see preflight_capacity). A schedule whose
|
||||
# projected peak is above OSD_MAX_PCT (80%) is not restored and is reported
|
||||
# as SKIP, not FAIL. Skips are listed in the summary so an untested backup
|
||||
# stays visible.
|
||||
#
|
||||
# Mid-run guard (REN-451): the gate is a projection made once. While a
|
||||
# restore runs, every poll reads the fullest OSD; at OSD_ABORT_PCT, or with
|
||||
# no reading from Prometheus for PROM_MISS_MAX polls, the restore is
|
||||
# cancelled, torn down and the sweep stops (FAIL). A restore that times
|
||||
# out is cancelled the same way, and so is the one in flight when the Job
|
||||
# is killed (TERM/EXIT trap). A restore that cannot finish its
|
||||
# RESTORE_PHASE_TIMEOUT_S before the Job deadline is not started (SKIP).
|
||||
#
|
||||
# Exit non-zero if any check fails. Prints a summary at the end.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
FAILURES=()
|
||||
SKIPS=()
|
||||
RUN_ID=$(date -u +%s)
|
||||
RUN_START=$RUN_ID
|
||||
RESTORE_PHASE_TIMEOUT_S=${RESTORE_PHASE_TIMEOUT_S:-14400} # 4 h — immich media (immich-library-bulk, ~250 GiB, millions of small files) does not finish in 2 h
|
||||
# Must equal the Job's activeDeadlineSeconds. A restore is started only if
|
||||
# its RESTORE_PHASE_TIMEOUT_S plus CLEANUP_RESERVE_S (parity probes and
|
||||
# teardown) ends before it.
|
||||
JOB_DEADLINE_S=${JOB_DEADLINE_S:-21600}
|
||||
CLEANUP_RESERVE_S=${CLEANUP_RESERVE_S:-900}
|
||||
|
||||
# CNPG volumes are out of scope (REN-451). Without this filter a backup
|
||||
# that holds a CNPG PVC is restored as a second copy of the database, and
|
||||
# the parity probe snapshots and clones the live one next to production.
|
||||
# CNPG puts cnpg.io/cluster on every PVC it owns, and Velero keeps PVC
|
||||
# labels on restore. Used by the Restore, the capacity gate and the
|
||||
# parity loop.
|
||||
CNPG_LABEL=cnpg.io/cluster
|
||||
CNPG_EXCLUDE="!${CNPG_LABEL}"
|
||||
|
||||
log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*"; }
|
||||
|
||||
check() {
|
||||
local name="$1"; shift
|
||||
printf ' %-72s ' "$name"
|
||||
if "$@" >/tmp/check.out 2>&1; then
|
||||
echo "OK"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "FAIL"
|
||||
FAILURES+=("$name -- $(tail -4 /tmp/check.out | tr '\n' ' ')")
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
skip() {
|
||||
local name="$1" reason="$2"
|
||||
printf ' %-72s SKIP\n' "$name"
|
||||
SKIPS+=("$name -- $reason")
|
||||
SKIP=$((SKIP + 1))
|
||||
}
|
||||
|
||||
# Capacity gate (REN-441, REN-442). Every restore lands on the production
|
||||
# Ceph pools, so a large one pushes the OSDs toward nearfull while it is
|
||||
# alive. On 2026-10-01 audiobookshelf-books (87 GB, EC 2+1) took the
|
||||
# fullest OSD from 74.1% to 78.6%, and the immich library (~198 GB)
|
||||
# projected to ~85-86%, Ceph's nearfull line.
|
||||
#
|
||||
# The projection: raw bytes of the restore spread evenly over all OSDs,
|
||||
# added to every OSD's usage now; the fullest result is the peak. Raw
|
||||
# bytes = sum of DataUpload totalBytes x the pool's raw/usable factor,
|
||||
# once for the restored PVC and once more for the source-side probe clone
|
||||
# (count_files_in_source_pvc). CNPG volumes are left out: they are neither
|
||||
# restored nor cloned.
|
||||
# Even spread is exact for this cluster (three OSDs, one per host, every
|
||||
# pool spans all three); it would understate a skewed layout.
|
||||
OSD_MAX_PCT=${OSD_MAX_PCT:-80}
|
||||
# Mid-run abort line. Below mon_osd_nearfull_ratio 0.85 so the restore is
|
||||
# cancelled before Ceph warns; on 2026-10-01 the immich restore climbed
|
||||
# ~5 points an hour, so one 15 s poll overshoots by far less than the gap.
|
||||
OSD_ABORT_PCT=${OSD_ABORT_PCT:-83}
|
||||
# Prometheus keeps its TSDB on Ceph and stopped scraping at 95% on
|
||||
# 2026-10-01. No reading for this many polls in a row (15 s each) counts
|
||||
# as full.
|
||||
PROM_MISS_MAX=${PROM_MISS_MAX:-4}
|
||||
PROMETHEUS_URL=${PROMETHEUS_URL:-http://kube-prometheus-stack-prometheus.monitoring.svc:9090}
|
||||
|
||||
# Raw bytes written per usable byte, by StorageClass. Source of truth is
|
||||
# kubernetes/infrastructure/rook-ceph-cluster/ (storageclass.yaml,
|
||||
# cephfs-ci.yaml): cephfs-bulk is EC 2+1, cephfs-ci-data0 is size 2,
|
||||
# replicapool and cephfs-data0 are size 3. Any class not listed counts as
|
||||
# 3, the largest factor in the cluster.
|
||||
RAW_FACTORS='{"ceph-filesystem-bulk": 1.5, "ceph-filesystem-ci": 2}'
|
||||
|
||||
# Raw factor of the probe clone, by the source PVC's StorageClass. Mirrors
|
||||
# sc_for_snapshot_probe: ceph-block clones to ceph-block-snapshot-readonly
|
||||
# (replicapool) and ceph-filesystem to ceph-filesystem-ephemeral
|
||||
# (cephfs-data0), both size 3. Any other class gets no clone. The clones
|
||||
# are counted as if all were alive at once: each is deleted with
|
||||
# --wait=false and CephFS purges asynchronously, so the sum is the safe
|
||||
# bound.
|
||||
CLONE_FACTORS='{"ceph-block": 3, "ceph-filesystem": 3}'
|
||||
|
||||
# Prometheus instant query; echoes the first sample value.
|
||||
prom_scalar() {
|
||||
curl -sf -m 30 -G --data-urlencode "query=$1" "${PROMETHEUS_URL}/api/v1/query" \
|
||||
| jq -er '.data.result[0].value[1]'
|
||||
}
|
||||
|
||||
# Echo "<raw_bytes> <now_pct> <peak_pct>" for a backup of src_ns, or echo
|
||||
# a reason and return non-zero. The CNPG volumes to leave out are the
|
||||
# source namespace's CNPG PVCs now; a CNPG PVC that has gone since the
|
||||
# backup is still counted, which only errs toward skipping. Usage is read
|
||||
# fresh per schedule, after the previous schedule's teardown; deletion lag
|
||||
# can only make it read higher, so it errs on the side of skipping.
|
||||
preflight_capacity() {
|
||||
local backup="$1" src_ns="$2" cnpg raw now peak
|
||||
cnpg=$(kubectl get pvc -n "$src_ns" -l "$CNPG_LABEL" -o json \
|
||||
| jq -ec '[.items[].metadata.name]') || {
|
||||
echo "cannot list CNPG PVCs in ${src_ns}"
|
||||
return 1
|
||||
}
|
||||
raw=$(kubectl get dataupload -n velero -l "velero.io/backup-name=${backup}" -o json \
|
||||
| jq -er --argjson f "$RAW_FACTORS" --argjson c "$CLONE_FACTORS" --argjson cnpg "$cnpg" '
|
||||
[.items[]
|
||||
| select(.spec.sourcePVC as $p | $cnpg | index([$p]) | not)
|
||||
| (.spec.csiSnapshot.storageClass // "") as $sc
|
||||
| {b: .status.progress.totalBytes, k: (($f[$sc] // 3) + ($c[$sc] // 0))}]
|
||||
| if any(.b == null) then error("no totalBytes") else map(.b * .k) | add // 0 | floor end') || {
|
||||
echo "cannot size backup ${backup} (DataUpload without totalBytes?)"
|
||||
return 1
|
||||
}
|
||||
now=$(prom_scalar 'max(ceph_osd_stat_bytes_used / ceph_osd_stat_bytes) * 100') \
|
||||
&& peak=$(prom_scalar "max((ceph_osd_stat_bytes_used + ${raw} / scalar(count(ceph_osd_stat_bytes))) / ceph_osd_stat_bytes) * 100") || {
|
||||
echo "Prometheus query failed (${PROMETHEUS_URL})"
|
||||
return 1
|
||||
}
|
||||
# NaN or an empty vector must not read as "below the gate".
|
||||
if ! [[ "$now" =~ ^[0-9]+(\.[0-9]+)?$ && "$peak" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
|
||||
echo "no usable OSD usage from Prometheus (now=${now} peak=${peak})"
|
||||
return 1
|
||||
fi
|
||||
echo "$raw $now $peak"
|
||||
}
|
||||
|
||||
# Force-delete Released PVs whose claimRef matches a pattern. The ceph-block
|
||||
# and ceph-filesystem StorageClasses ship with reclaimPolicy: Retain so that
|
||||
# production PVC deletions can be undone manually. The restore-test has the
|
||||
# opposite need: every PVC it creates is throwaway and MUST not survive the
|
||||
# iteration. Without this, each run leaks O(100 GiB) of CephFS
|
||||
# subvolumes and the cluster eventually goes OSD_FULL (incident 2026-05-17).
|
||||
#
|
||||
# Why this still exists after ceph-*-ephemeral landed. Three categories of
|
||||
# throwaway PVC are created per iteration, and only one is ours to choose a
|
||||
# StorageClass for:
|
||||
#
|
||||
# 1. source-side snapshot-probe PVCs (rt-src-*, this script creates them)
|
||||
# -> FIXED at source: sc_for_snapshot_probe now returns only Delete
|
||||
# classes, so these no longer reach Released at all.
|
||||
# 2. destination PVCs in *-restore-test namespaces (Velero creates them)
|
||||
# -> NOT fixable here. Velero restores the PVC spec from the backup,
|
||||
# so the SC is whatever production uses (ceph-block/ceph-filesystem,
|
||||
# both Retain). The only remap knob is the change-storage-class
|
||||
# RestoreItemAction ConfigMap, which Velero applies to *every*
|
||||
# restore in the installation with no per-restore scoping — using it
|
||||
# would silently strip Retain from a real disaster-recovery restore,
|
||||
# which is precisely when the safety net matters. Not worth it to
|
||||
# save a cleanup loop here.
|
||||
# 3. Velero data-mover staging PVCs in velero/ (*-rtest-*)
|
||||
# -> NOT fixable here either. These derive from the target PVC, and the
|
||||
# node-agent restorePVC config supports only `ignoreDelayBinding`;
|
||||
# unlike backupPVC there is no storageClass field (Velero v1.18).
|
||||
#
|
||||
# So categories 2 and 3 keep this function load-bearing. Verified against
|
||||
# the Velero v1.18 restore-reference and data-movement-restore-pvc docs,
|
||||
# and against a live run: the destination PVCs and velero/*-rtest-* staging
|
||||
# PVCs both provision on the Retain classes.
|
||||
purge_released_pvs() {
|
||||
local pattern="$1" # regex applied to .spec.claimRef.namespace + "/" + .spec.claimRef.name
|
||||
local pvs pv
|
||||
pvs=$(kubectl get pv -o json 2>/dev/null \
|
||||
| jq -r --arg p "$pattern" '
|
||||
.items[]
|
||||
| select(.status.phase == "Released")
|
||||
| select(((.spec.claimRef.namespace // "") + "/" + (.spec.claimRef.name // "")) | test($p))
|
||||
| .metadata.name')
|
||||
for pv in $pvs; do
|
||||
kubectl patch pv "$pv" --type=merge \
|
||||
-p '{"spec":{"persistentVolumeReclaimPolicy":"Delete"}}' >/dev/null 2>&1 || true
|
||||
kubectl delete pv "$pv" --wait=false --ignore-not-found >/dev/null 2>&1 || true
|
||||
done
|
||||
}
|
||||
|
||||
cleanup_service() {
|
||||
local dst_ns="$1" restore="$2"
|
||||
[[ -n "$restore" ]] && kubectl delete restore.velero.io "$restore" \
|
||||
-n velero --ignore-not-found --wait=false >/dev/null 2>&1 || true
|
||||
kubectl delete ns "$dst_ns" --ignore-not-found --wait=false >/dev/null 2>&1 || true
|
||||
# Block until namespace is actually gone, otherwise the next service's
|
||||
# pre-cleanup races with leftover terminating pods.
|
||||
local ns_gone=1
|
||||
for _ in $(seq 1 60); do
|
||||
if ! kubectl get ns "$dst_ns" >/dev/null 2>&1; then
|
||||
ns_gone=0; break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
# Reclaim PVs left behind in Released state for this iteration:
|
||||
# - destination PVCs in dst_ns
|
||||
# - Velero data-mover backup PVCs in velero/ named *-rtest-${RUN_ID}-*
|
||||
# - source-side snapshot-probe PVCs in any ns named rt-src-*-${RUN_ID}
|
||||
purge_released_pvs "^(${dst_ns}/.*|velero/.*-rtest-${RUN_ID}-.*|.*/rt-src-.*-${RUN_ID})$"
|
||||
return $ns_gone
|
||||
}
|
||||
|
||||
# The restore in flight, for cancel_restore and the TERM/EXIT trap. Empty
|
||||
# once it is torn down.
|
||||
CUR_RESTORE=""
|
||||
CUR_DST_NS=""
|
||||
ABORT_SWEEP=""
|
||||
|
||||
# DataDownloads of a restore that are not finished. Matched by owner
|
||||
# reference as well as label: Velero hashes a label value over 63 chars.
|
||||
running_datadownloads() {
|
||||
kubectl get datadownload -n velero -o json \
|
||||
| jq -r --arg r "$1" '
|
||||
.items[]
|
||||
| select(.metadata.labels["velero.io/restore-name"] == $r
|
||||
or any(.metadata.ownerReferences[]?; .kind == "Restore" and .name == $r))
|
||||
| select(.status.phase as $p | ["Completed", "Failed", "Canceled"] | index([$p]) | not)
|
||||
| .metadata.name'
|
||||
}
|
||||
|
||||
# Stop a restore that may still be writing, then tear it down. Deleting
|
||||
# the Restore, or killing this Job, does not stop Velero: on 2026-10-01 the
|
||||
# immich DataDownloads kept writing after the Job died at 14:00 until Ceph
|
||||
# went OSD_FULL at 15:10. spec.cancel on each DataDownload is the stop.
|
||||
cancel_restore() {
|
||||
local restore="$1" dst_ns="$2" dd left=""
|
||||
for dd in $(running_datadownloads "$restore"); do
|
||||
kubectl patch datadownload -n velero "$dd" --type=merge \
|
||||
-p '{"spec":{"cancel":true}}' >/dev/null 2>&1 || true
|
||||
done
|
||||
# Up to 2 min for them to stop, so their staging PVs are Released
|
||||
# before the purge in cleanup_service.
|
||||
for _ in $(seq 1 24); do
|
||||
left=$(running_datadownloads "$restore" 2>/dev/null) || left="?"
|
||||
[[ -z "$left" ]] && break
|
||||
sleep 5
|
||||
done
|
||||
[[ -n "$left" ]] && echo "DataDownloads not stopped after 2 min: ${left//$'\n'/ }"
|
||||
cleanup_service "$dst_ns" "$restore"
|
||||
CUR_RESTORE=""
|
||||
CUR_DST_NS=""
|
||||
}
|
||||
|
||||
# Wait for the Restore to finish while watching the fullest OSD (see the
|
||||
# header: mid-run guard). Returns 0 on Completed. Any abort or timeout
|
||||
# cancels and tears the restore down before returning 1; an abort also
|
||||
# sets ABORT_SWEEP so no further restore starts.
|
||||
wait_for_restore() {
|
||||
local restore="$1" dst_ns="$2" deadline p last="" osd misses=0
|
||||
deadline=$(( $(date +%s) + RESTORE_PHASE_TIMEOUT_S ))
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
p=$(kubectl -n velero get restore.velero.io "$restore" \
|
||||
-o jsonpath='{.status.phase}' 2>/dev/null)
|
||||
[[ "$p" = Completed ]] && return 0
|
||||
# Failed, PartiallyFailed, FailedValidation: Velero has stopped.
|
||||
if [[ "$p" == *Failed* ]]; then
|
||||
kubectl -n velero get restore.velero.io "$restore" -o yaml | tail -60
|
||||
return 1
|
||||
fi
|
||||
if [[ "$p" != "$last" ]]; then
|
||||
echo "$(date -u +%H:%M:%S) restore phase: $p"
|
||||
last="$p"
|
||||
fi
|
||||
osd=$(prom_scalar 'max(ceph_osd_stat_bytes_used / ceph_osd_stat_bytes) * 100' 2>/dev/null)
|
||||
if [[ "$osd" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
|
||||
misses=0
|
||||
if awk -v o="$osd" -v m="$OSD_ABORT_PCT" 'BEGIN { exit !(o >= m) }'; then
|
||||
ABORT_SWEEP="fullest OSD $(printf '%.1f' "$osd")% reached ${OSD_ABORT_PCT}% during ${restore}"
|
||||
fi
|
||||
else
|
||||
misses=$((misses + 1))
|
||||
(( misses >= PROM_MISS_MAX )) \
|
||||
&& ABORT_SWEEP="no OSD usage from Prometheus for ${misses} polls during ${restore}"
|
||||
fi
|
||||
if [[ -n "$ABORT_SWEEP" ]]; then
|
||||
echo "ABORT: ${ABORT_SWEEP}; cancelling"
|
||||
cancel_restore "$restore" "$dst_ns"
|
||||
return 1
|
||||
fi
|
||||
# Background sleep so a TERM runs the trap at once, not after it.
|
||||
sleep 15 & wait $!
|
||||
done
|
||||
echo "TIMEOUT after ${RESTORE_PHASE_TIMEOUT_S}s; cancelling"
|
||||
kubectl -n velero get restore.velero.io "$restore" -o yaml | tail -60
|
||||
cancel_restore "$restore" "$dst_ns"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Transient subtrees that must not count toward file-count parity, keyed
|
||||
# by "<source-namespace>/<pvc>". Values are whole directories relative to
|
||||
# the volume root.
|
||||
#
|
||||
# Why this exists: parity compares a backup taken up to ~24 h ago against
|
||||
# the live volume *now*. Any regenerable cache that churns faster than the
|
||||
# ±10% tolerance produces a spurious FAIL even when the backup is
|
||||
# byte-for-byte complete. On 2026-07-26 one Audiobookshelf playback session
|
||||
# had written 1531 HLS transcode files (~153 MiB) into metadata/streams/
|
||||
# after the backup ran, against 808 persistent files — a 3x inflation that
|
||||
# failed the run for no real reason. A verification layer that cries wolf
|
||||
# gets ignored, which is worse than not having one.
|
||||
#
|
||||
# Keep this list minimal and justified: every entry is a blind spot. Only
|
||||
# add paths whose loss is genuinely a non-event on restore.
|
||||
volatile_paths_for() {
|
||||
case "$1" in
|
||||
# streams: HLS transcode segments for in-flight playback sessions.
|
||||
# tmp: scratch space for downloads/imports.
|
||||
# cache/ is deliberately NOT excluded: it is regenerable, but it is
|
||||
# stable enough (190 files) not to threaten the tolerance, and
|
||||
# keeping it counted preserves real coverage on this volume.
|
||||
audiobookshelf/audiobookshelf-metadata) echo "streams tmp" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Emit the `find` command run inside the probe pod, pruning the given
|
||||
# space-separated volume-root-relative paths. Paths are single-quoted for
|
||||
# the remote shell; parens are backslash-escaped so the remote sh hands
|
||||
# literal ones to find rather than opening a subshell.
|
||||
#
|
||||
# The -prune form was verified in the probe image itself (alpine:3.21,
|
||||
# BusyBox v1.37.0) against the live audiobookshelf-metadata volume:
|
||||
# unfiltered 2339, pruned 775 == 2339 - 1531 (streams) - 33 (tmp). A path
|
||||
# listed here that does not exist on the volume is a no-op, not an error
|
||||
# (exit 0), so a stale entry degrades gracefully rather than zeroing the
|
||||
# count. An empty exclusion list reproduces the original command verbatim.
|
||||
build_count_cmd() {
|
||||
local excludes="$1" find_expr="" p
|
||||
for p in $excludes; do
|
||||
find_expr="${find_expr:+$find_expr -o }-path '/d/$p'"
|
||||
done
|
||||
if [[ -n "$find_expr" ]]; then
|
||||
echo "find /d -xdev \\( $find_expr \\) -prune -o -type f -print 2>/dev/null | wc -l"
|
||||
else
|
||||
echo "find /d -xdev -type f 2>/dev/null | wc -l"
|
||||
fi
|
||||
}
|
||||
|
||||
# Mount the named PVC in a transient probe pod and emit the count of
|
||||
# regular files, excluding the optional space-separated volatile paths in
|
||||
# $4. Echoes "" and returns non-zero on any failure (pod scheduling,
|
||||
# mount, exec). Pod is RO-mounted so we never disturb the underlying
|
||||
# volume; cleanup is best-effort but unconditional.
|
||||
count_files_in_pvc() {
|
||||
local ns="$1" pvc="$2" pod_suffix="$3" excludes="${4:-}"
|
||||
local pod="probe-${pod_suffix:0:50}"
|
||||
cat <<EOF | kubectl apply -f - >/dev/null
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: $pod
|
||||
namespace: $ns
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: restore-test
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
fsGroup: 65532
|
||||
seccompProfile: { type: RuntimeDefault }
|
||||
containers:
|
||||
- name: probe
|
||||
image: alpine:3.21
|
||||
command: ["sh", "-c", "sleep 1800"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities: { drop: ["ALL"] }
|
||||
volumeMounts:
|
||||
- { name: d, mountPath: /d, readOnly: true }
|
||||
volumes:
|
||||
- name: d
|
||||
persistentVolumeClaim:
|
||||
claimName: $pvc
|
||||
readOnly: true
|
||||
EOF
|
||||
if ! kubectl wait -n "$ns" --for=condition=ready "pod/$pod" --timeout=300s >/dev/null 2>&1; then
|
||||
kubectl describe -n "$ns" "pod/$pod" 2>&1 | tail -25 >&2
|
||||
kubectl delete -n "$ns" "pod/$pod" --ignore-not-found --wait=false >/dev/null 2>&1 || true
|
||||
return 1
|
||||
fi
|
||||
local n
|
||||
n=$(kubectl exec -n "$ns" "$pod" -- sh -c "$(build_count_cmd "$excludes")" 2>/dev/null)
|
||||
kubectl delete -n "$ns" "pod/$pod" --ignore-not-found --wait=false >/dev/null 2>&1 || true
|
||||
[[ "$n" =~ ^[0-9]+$ ]] || return 1
|
||||
echo "$n"
|
||||
}
|
||||
|
||||
# Map a source PVC's storage class to (snapshotClass, tmp PVC SC) used
|
||||
# for the source-side count probe. ext4 RBD snapshots need the
|
||||
# noload-mounting SC; CephFS has no dirty-journal failure mode and only
|
||||
# needs a plain CephFS class.
|
||||
#
|
||||
# BOTH tmp classes must be reclaimPolicy: Delete — these probe PVCs are
|
||||
# throwaway and are the one leak category this script fully controls
|
||||
# (see purge_released_pvs). ceph-block-snapshot-readonly is already
|
||||
# Delete, and its `noload` mountOption is load-bearing (skips ext4
|
||||
# journal replay on the RO snapshot mount), so it must NOT be swapped
|
||||
# for ceph-block-ephemeral, which lacks that option.
|
||||
sc_for_snapshot_probe() {
|
||||
local source_sc="$1"
|
||||
case "$source_sc" in
|
||||
ceph-block) echo "ceph-block-snapclass ceph-block-snapshot-readonly" ;;
|
||||
ceph-filesystem) echo "ceph-filesystem-snapclass ceph-filesystem-ephemeral" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Snapshot the source PVC (PVC name preserved across restore — same
|
||||
# in src and dst), clone to a tmp PVC, count files excluding the optional
|
||||
# space-separated volatile paths in $3. Cleans up its own artifacts in
|
||||
# src_ns regardless of outcome.
|
||||
count_files_in_source_pvc() {
|
||||
local src_ns="$1" pvc="$2" excludes="${3:-}"
|
||||
local source_sc capacity sc_pair snap_class tmp_sc
|
||||
source_sc=$(kubectl get pvc -n "$src_ns" "$pvc" -o jsonpath='{.spec.storageClassName}' 2>/dev/null)
|
||||
capacity=$(kubectl get pvc -n "$src_ns" "$pvc" -o jsonpath='{.spec.resources.requests.storage}' 2>/dev/null)
|
||||
if ! sc_pair=$(sc_for_snapshot_probe "$source_sc"); then
|
||||
echo "unsupported source SC: $source_sc" >&2
|
||||
return 1
|
||||
fi
|
||||
snap_class=${sc_pair% *}
|
||||
tmp_sc=${sc_pair#* }
|
||||
|
||||
local snap="rt-src-${pvc:0:40}-${RUN_ID}"
|
||||
local tmp_pvc="rt-src-${pvc:0:40}-${RUN_ID}"
|
||||
|
||||
# Best-effort cleanup hook — runs on every return path.
|
||||
cleanup_source_probe() {
|
||||
kubectl delete pvc -n "$src_ns" "$tmp_pvc" \
|
||||
--ignore-not-found --wait=false >/dev/null 2>&1 || true
|
||||
kubectl delete volumesnapshot -n "$src_ns" "$snap" \
|
||||
--ignore-not-found --wait=false >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup_source_probe RETURN
|
||||
|
||||
cat <<EOF | kubectl apply -f - >/dev/null
|
||||
apiVersion: snapshot.storage.k8s.io/v1
|
||||
kind: VolumeSnapshot
|
||||
metadata:
|
||||
name: $snap
|
||||
namespace: $src_ns
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: restore-test
|
||||
spec:
|
||||
volumeSnapshotClassName: $snap_class
|
||||
source:
|
||||
persistentVolumeClaimName: $pvc
|
||||
EOF
|
||||
# Wait ReadyToUse — RBD CSI provisioner is fast (seconds); allow 5 min.
|
||||
local ready=""
|
||||
for _ in $(seq 1 60); do
|
||||
ready=$(kubectl get volumesnapshot -n "$src_ns" "$snap" \
|
||||
-o jsonpath='{.status.readyToUse}' 2>/dev/null)
|
||||
[ "$ready" = "true" ] && break
|
||||
sleep 5
|
||||
done
|
||||
if [ "$ready" != "true" ]; then
|
||||
echo "snapshot not ReadyToUse after 5m" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
cat <<EOF | kubectl apply -f - >/dev/null
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: $tmp_pvc
|
||||
namespace: $src_ns
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: restore-test
|
||||
spec:
|
||||
accessModes: [ReadOnlyMany]
|
||||
storageClassName: $tmp_sc
|
||||
resources:
|
||||
requests:
|
||||
storage: $capacity
|
||||
dataSource:
|
||||
apiGroup: snapshot.storage.k8s.io
|
||||
kind: VolumeSnapshot
|
||||
name: $snap
|
||||
EOF
|
||||
|
||||
count_files_in_pvc "$src_ns" "$tmp_pvc" "src-${pvc:0:40}-${RUN_ID}" "$excludes"
|
||||
}
|
||||
|
||||
# Restored count must be within max(±10%, ±ABS_PARITY_SLACK files) of the
|
||||
# source's current file count. That tolerance absorbs day-of drift since
|
||||
# the backup ran without letting "lost half the data" pass silently.
|
||||
# Volatile subtrees are pruned from BOTH sides — keyed off the source
|
||||
# identity, since the PVC name is preserved across the restore but the
|
||||
# destination namespace is not.
|
||||
#
|
||||
# Why the absolute floor exists. A purely relative window is structurally
|
||||
# unable to pass a small volume: ±10% of 9 files is ±0.9, i.e. a budget of
|
||||
# ZERO files, and ±10% of 2 files is ±0.2. The 2026-09-01 run failed on
|
||||
# exactly that — audiobookshelf/libation-config source=9 restored=8 and
|
||||
# trmnl/trmnl-renderer-data source=2 restored=3, both off by one file and
|
||||
# in opposite directions, with all 12 restores at phase=Completed and the
|
||||
# other 46 checks green. Parity compares a backup taken up to ~24 h ago
|
||||
# against the live volume *now*, so a single config write on a tiny volume
|
||||
# between those two moments is expected, not a defect in the backup.
|
||||
#
|
||||
# The floor only governs volumes below ~10*ABS_PARITY_SLACK files; above
|
||||
# that the 10% window still binds, so real data loss on the volumes that
|
||||
# carry data keeps failing loudly. Two files of slack rather than one
|
||||
# because a single logical write on a config volume routinely moves more
|
||||
# than one file (an app writing a settings file plus a transient sidecar),
|
||||
# and a floor that buys exactly one month is not worth the change.
|
||||
#
|
||||
# What the floor must NOT swallow, and does not: a non-empty source that
|
||||
# restored to an *empty* volume fails unconditionally below, before any
|
||||
# tolerance is applied. The old relative-only check caught that case as a
|
||||
# side effect (delta = 1.0 > 0.10); it is now explicit so the floor cannot
|
||||
# erode it on a volume of ABS_PARITY_SLACK files or fewer.
|
||||
ABS_PARITY_SLACK=${ABS_PARITY_SLACK:-2}
|
||||
|
||||
verify_pvc_file_count_parity() {
|
||||
local src_ns="$1" dst_ns="$2" pvc="$3"
|
||||
local restored source excludes
|
||||
excludes=$(volatile_paths_for "${src_ns}/${pvc}")
|
||||
restored=$(count_files_in_pvc "$dst_ns" "$pvc" "dst-${pvc:0:40}-${RUN_ID}" "$excludes") || {
|
||||
echo "restored-count probe failed"
|
||||
return 1
|
||||
}
|
||||
source=$(count_files_in_source_pvc "$src_ns" "$pvc" "$excludes") || {
|
||||
echo "source-count probe failed (restored=$restored)"
|
||||
return 1
|
||||
}
|
||||
echo "source=$source restored=$restored${excludes:+ (excluded: $excludes)}"
|
||||
# Both zero is a degenerate but legal state (empty volume).
|
||||
if [[ "$source" -eq 0 && "$restored" -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
# Empty on either side while the other has data: never tolerable.
|
||||
if [[ "$source" -eq 0 || "$restored" -eq 0 ]]; then
|
||||
return 1
|
||||
fi
|
||||
# Use awk to compute the window without bc.
|
||||
awk -v s="$source" -v r="$restored" -v slack="$ABS_PARITY_SLACK" 'BEGIN {
|
||||
delta = r - s; if (delta < 0) delta = -delta
|
||||
tol = 0.10 * s; if (tol < slack) tol = slack
|
||||
exit !(delta <= tol)
|
||||
}'
|
||||
}
|
||||
|
||||
echo "=== Velero PVC backup restore verification ==="
|
||||
log "run_id=$RUN_ID"
|
||||
|
||||
# The Job deadline (or a deleted Job) sends TERM; bash is PID 1 and only
|
||||
# acts on it with a trap. Stop the restore in flight before the pod goes:
|
||||
# terminationGracePeriodSeconds in the CronJob covers cancel_restore.
|
||||
# fd 3 is the pod log, so the trap logs even inside check's redirect.
|
||||
exec 3>&1
|
||||
on_exit() {
|
||||
[[ -n "$CUR_RESTORE" ]] || return 0
|
||||
log "stopping in-flight restore ${CUR_RESTORE}" >&3
|
||||
cancel_restore "$CUR_RESTORE" "$CUR_DST_NS" >&3 2>&1
|
||||
}
|
||||
trap on_exit EXIT
|
||||
trap 'log "SIGTERM (Job deadline or deletion)" >&3; exit 143' TERM
|
||||
|
||||
check "BSL default Available" bash -c \
|
||||
'kubectl -n velero get backupstoragelocation default -o jsonpath="{.status.phase}" | grep -q Available'
|
||||
|
||||
check "node-agent DaemonSet has all pods ready" bash -c '
|
||||
desired=$(kubectl -n velero get ds node-agent -o jsonpath="{.status.desiredNumberScheduled}")
|
||||
ready=$(kubectl -n velero get ds node-agent -o jsonpath="{.status.numberReady}")
|
||||
test "$ready" -ge "$desired" -a "$desired" -ge 1
|
||||
'
|
||||
|
||||
# Sweep residue from *earlier* runs before starting this one.
|
||||
#
|
||||
# cleanup_service's purge is scoped to ${RUN_ID} for the velero/*-rtest-*
|
||||
# and rt-src-* patterns, which is correct while a run is in progress (it
|
||||
# must not touch a concurrent run's artifacts) but means a run that dies
|
||||
# before its cleanup — activeDeadlineSeconds, OOM, node eviction, a killed
|
||||
# Job — strands its PVs forever: no later run's pattern matches a previous
|
||||
# RUN_ID. That is the residual leak path behind incident 2026-05-17, and it
|
||||
# survives the ceph-*-ephemeral fix because categories 2 and 3 above are on
|
||||
# Retain classes regardless.
|
||||
#
|
||||
# Safe to run unscoped here: concurrencyPolicy Forbid guarantees no other
|
||||
# restore-test Job is active, so every Released PV matching these
|
||||
# restore-test-only naming patterns is by definition abandoned. Scoped to
|
||||
# Released PVs only, so nothing Bound to a live workload is ever touched.
|
||||
log "sweeping Released PVs left by earlier runs"
|
||||
purge_released_pvs '^(.*-restore-test/.*|velero/.*-rtest-.*|.*/rt-src-.*)$'
|
||||
|
||||
mapfile -t SCHEDULES < <(
|
||||
kubectl get schedule.velero.io -n velero \
|
||||
-l app.kubernetes.io/part-of=homelab-backup \
|
||||
-o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}'
|
||||
)
|
||||
|
||||
if [[ ${#SCHEDULES[@]} -eq 0 ]]; then
|
||||
echo "FAIL: no Schedules with label app.kubernetes.io/part-of=homelab-backup"
|
||||
exit 1
|
||||
fi
|
||||
log "discovered ${#SCHEDULES[@]} schedules: ${SCHEDULES[*]}"
|
||||
|
||||
for schedule in "${SCHEDULES[@]}"; do
|
||||
src_ns=$(kubectl get schedule.velero.io -n velero "$schedule" \
|
||||
-o jsonpath='{.spec.template.includedNamespaces[0]}')
|
||||
dst_ns="${src_ns}-restore-test"
|
||||
|
||||
echo
|
||||
echo "=== service: ${src_ns} (schedule=${schedule}) ==="
|
||||
|
||||
if [[ -n "$ABORT_SWEEP" ]]; then
|
||||
skip "${src_ns}: restore (sweep stopped)" "$ABORT_SWEEP"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Newest terminal Backup wins. PartiallyFailed is intentionally
|
||||
# included: Velero only restores DataUploads that completed, so
|
||||
# the partial source still gives a valid restore for the PVCs that
|
||||
# did upload — and excluding them would silently let the test miss
|
||||
# weeks where one DataUpload kept failing (e.g. the ext4-noload
|
||||
# window before that fix).
|
||||
#
|
||||
# Sort happens in jq, not kubectl: `kubectl --sort-by` is silently
|
||||
# ignored when output is `-o jsonpath`, so the previous form picked a
|
||||
# non-deterministic backup and could choose a snapshot already pruned
|
||||
# by kopia maintenance ("Unable to load snapshot : snapshot not found").
|
||||
backup=$(kubectl get backup.velero.io -n velero \
|
||||
-l "velero.io/schedule-name=${schedule}" -o json \
|
||||
| jq -r '
|
||||
[.items[]
|
||||
| select(.status.phase == "Completed"
|
||||
or .status.phase == "PartiallyFailed")]
|
||||
| sort_by(.status.startTimestamp)
|
||||
| last
|
||||
| .metadata.name // empty')
|
||||
|
||||
if [[ -z "$backup" ]]; then
|
||||
check "${src_ns}: a Completed/PartiallyFailed Backup exists" false
|
||||
continue
|
||||
fi
|
||||
log "${src_ns}: using backup ${backup}"
|
||||
|
||||
# No DataUploads = the schedule is a no-op for this service (e.g.
|
||||
# authentik has no PVCs). Skip with PASS. csiVolumeSnapshots* status
|
||||
# fields aren't populated on Velero v1.18, so count DataUpload
|
||||
# children directly instead. Fail loudly if the list itself errors,
|
||||
# so an RBAC regression doesn't silently pass every service.
|
||||
if ! du_out=$(kubectl get dataupload -n velero \
|
||||
-l "velero.io/backup-name=${backup}" -o name 2>&1); then
|
||||
check "${src_ns}: list DataUploads (RBAC?)" bash -c "echo '$du_out'; false"
|
||||
continue
|
||||
fi
|
||||
du_count=$(echo "$du_out" | grep -c . || true)
|
||||
if [[ "${du_count:-0}" -eq 0 ]]; then
|
||||
check "${src_ns}: no-PVC schedule (skip restore)" true
|
||||
continue
|
||||
fi
|
||||
|
||||
# Capacity gate. If the projection cannot be made, do not restore and
|
||||
# count a FAIL: an unsized restore is the risk the gate exists for.
|
||||
if ! cap=$(preflight_capacity "$backup" "$src_ns"); then
|
||||
check "${src_ns}: capacity pre-flight" bash -c "echo '$cap'; false"
|
||||
continue
|
||||
fi
|
||||
read -r raw now peak <<<"$cap"
|
||||
raw_gib=$(awk -v b="$raw" 'BEGIN { printf "%.0f", b / 2^30 }')
|
||||
printf -v now '%.1f' "$now"
|
||||
printf -v peak '%.1f' "$peak"
|
||||
if awk -v p="$peak" -v m="$OSD_MAX_PCT" 'BEGIN { exit !(p > m) }'; then
|
||||
skip "${src_ns}: restore (capacity gate)" \
|
||||
"projected fullest OSD ${peak}% > ${OSD_MAX_PCT}% (now ${now}%, restore ~${raw_gib} GiB raw)"
|
||||
continue
|
||||
fi
|
||||
log "${src_ns}: capacity ok, fullest OSD ${now}% -> projected ${peak}% (~${raw_gib} GiB raw)"
|
||||
|
||||
# Time budget: a restore the Job deadline would cut off is the one
|
||||
# nothing watches once the Job dies.
|
||||
left_s=$(( RUN_START + JOB_DEADLINE_S - $(date +%s) ))
|
||||
if (( RESTORE_PHASE_TIMEOUT_S + CLEANUP_RESERVE_S > left_s )); then
|
||||
skip "${src_ns}: restore (time budget)" \
|
||||
"${left_s}s left before the Job deadline < ${RESTORE_PHASE_TIMEOUT_S}s restore timeout + ${CLEANUP_RESERVE_S}s reserve"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Pre-cleanup in case a prior run left residue.
|
||||
cleanup_service "$dst_ns" "" >/dev/null 2>&1 || true
|
||||
|
||||
restore_name="${schedule}-rtest-${RUN_ID}"
|
||||
CUR_RESTORE=$restore_name
|
||||
CUR_DST_NS=$dst_ns
|
||||
cat <<EOF | kubectl apply -f - >/dev/null
|
||||
apiVersion: velero.io/v1
|
||||
kind: Restore
|
||||
metadata:
|
||||
name: ${restore_name}
|
||||
namespace: velero
|
||||
spec:
|
||||
backupName: ${backup}
|
||||
includedNamespaces: [${src_ns}]
|
||||
namespaceMapping:
|
||||
${src_ns}: ${dst_ns}
|
||||
# Only the data — Flux owns workload manifests.
|
||||
includedResources:
|
||||
- persistentvolumeclaims
|
||||
- persistentvolumes
|
||||
# No CNPG volume, whatever the backup holds (CNPG_EXCLUDE).
|
||||
labelSelector:
|
||||
matchExpressions:
|
||||
- key: ${CNPG_LABEL}
|
||||
operator: DoesNotExist
|
||||
EOF
|
||||
|
||||
# A function, not `bash -c`: it must run in this shell so the TERM trap
|
||||
# fires during the wait and ABORT_SWEEP/CUR_RESTORE stick.
|
||||
check "${src_ns}: restore reaches phase=Completed" \
|
||||
wait_for_restore "$restore_name" "$dst_ns"
|
||||
# Cancelled and torn down (abort or timeout): nothing left to probe.
|
||||
[[ -z "$CUR_RESTORE" ]] && continue
|
||||
|
||||
# Probe each restored PVC, never a CNPG one (CNPG_EXCLUDE): its source
|
||||
# probe would snapshot and clone the live database.
|
||||
mapfile -t PVCS < <(
|
||||
kubectl get pvc -n "$dst_ns" -l "$CNPG_EXCLUDE" \
|
||||
-o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null
|
||||
)
|
||||
if [[ ${#PVCS[@]} -eq 0 ]]; then
|
||||
check "${src_ns}: at least one PVC was restored" false
|
||||
else
|
||||
for pvc in "${PVCS[@]}"; do
|
||||
check "${src_ns}: PVC ${pvc} file count parity (±10% / ±${ABS_PARITY_SLACK})" \
|
||||
verify_pvc_file_count_parity "$src_ns" "$dst_ns" "$pvc"
|
||||
done
|
||||
fi
|
||||
|
||||
cleanup_service "$dst_ns" "$restore_name"
|
||||
CUR_RESTORE=""
|
||||
CUR_DST_NS=""
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== Summary ==="
|
||||
echo "PASS: $PASS"
|
||||
echo "FAIL: $FAIL"
|
||||
echo "SKIP: $SKIP"
|
||||
# A skipped service has no restore evidence this month. Listed on every
|
||||
# run so it is not mistaken for a pass; it does not fail the Job.
|
||||
if [[ $SKIP -gt 0 ]]; then
|
||||
echo
|
||||
echo "Skipped (backup NOT restore-tested):"
|
||||
printf ' - %s\n' "${SKIPS[@]}"
|
||||
fi
|
||||
if [[ $FAIL -gt 0 ]]; then
|
||||
echo
|
||||
echo "Failures:"
|
||||
printf ' - %s\n' "${FAILURES[@]}"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Leave-one-out nearest-neighbour benchmark for an embedding model.
|
||||
|
||||
Embeds a labelled corpus of Paperless documents and, for each document, predicts
|
||||
its correspondent and document type from the label of its nearest neighbour
|
||||
(cosine similarity, the document itself excluded). Accuracy is the share of
|
||||
documents whose predicted label equals the filed one.
|
||||
|
||||
Corpus: JSON lines {"id", "correspondent", "doctype", "text"}, exported
|
||||
read-only from the paperless database, for example on the CNPG primary:
|
||||
|
||||
select json_build_object('id', d.id, 'correspondent', d.correspondent_id,
|
||||
'doctype', d.document_type_id,
|
||||
'text', left(regexp_replace(d.content, '\\s+', ' ', 'g'), 3000))
|
||||
from documents_document d
|
||||
where d.correspondent_id is not null and d.document_type_id is not null
|
||||
and length(d.content) > 200
|
||||
order by md5(d.id::text || '<seed>') limit 400
|
||||
|
||||
Ceiling is the share of documents whose label occurs on at least one other
|
||||
document; no nearest-neighbour method can exceed it. Floor is the share of the
|
||||
most common label.
|
||||
|
||||
Usage:
|
||||
knn-bench.py --corpus corpus.jsonl -e https://api.mistral.ai/v1/embeddings \\
|
||||
-m mistral-embed --key-env MISTRAL_API_KEY --batch 8
|
||||
knn-bench.py --corpus corpus.jsonl -m bge-m3 \\
|
||||
-e http://llama-embed.ai.svc.cluster.local:8080/v1/embeddings \\
|
||||
--via-pod "deploy/llama-server -n ai"
|
||||
|
||||
Standard library only. Vectors are cached per model next to the corpus.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
|
||||
|
||||
def post(endpoint, payload, key, via_pod):
|
||||
body = json.dumps(payload).encode()
|
||||
if via_pod:
|
||||
cmd = ["kubectl", "exec", "-i", *shlex.split(via_pod), "--", "curl", "-sS", "-m", "300",
|
||||
"-H", "Content-Type: application/json", "-d", "@-", endpoint]
|
||||
proc = subprocess.run(cmd, input=body, capture_output=True, check=False)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"kubectl exec failed: {proc.stderr.decode()}")
|
||||
return json.loads(proc.stdout)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if key:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
req = urllib.request.Request(endpoint, data=body, headers=headers)
|
||||
for attempt in range(6):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||
return json.load(resp)
|
||||
except urllib.error.HTTPError as err:
|
||||
if err.code == 429 and attempt < 5:
|
||||
time.sleep(2 ** attempt)
|
||||
continue
|
||||
raise RuntimeError(f"HTTP {err.code}: {err.read().decode()}") from err
|
||||
raise RuntimeError("retries exhausted")
|
||||
|
||||
|
||||
def embed_all(docs, args, key):
|
||||
cache = f"{args.corpus}.{args.model}.{args.max_chars}.vecs.json"
|
||||
if os.path.exists(cache):
|
||||
with open(cache) as fh:
|
||||
vecs = json.load(fh)
|
||||
if len(vecs) == len(docs):
|
||||
print(f"vectors loaded from {cache}")
|
||||
return vecs
|
||||
vecs = []
|
||||
shrunk = 0
|
||||
start = time.time()
|
||||
for i in range(0, len(docs), args.batch):
|
||||
chunk = [d["text"] for d in docs[i:i + args.batch]]
|
||||
for _ in range(6):
|
||||
try:
|
||||
out = post(args.endpoint, {"model": args.model, "input": chunk}, key, args.via_pod)
|
||||
break
|
||||
except RuntimeError as err:
|
||||
# Mistral reports "Input id N has T tokens, exceeding max M tokens": cut that text to fit.
|
||||
hit = re.search(r"Input id (\d+) has (\d+) tokens, exceeding max (\d+)", str(err))
|
||||
if not hit:
|
||||
raise
|
||||
idx, have, limit = (int(g) for g in hit.groups())
|
||||
chunk[idx] = chunk[idx][:int(len(chunk[idx]) * limit / have * 0.95)]
|
||||
shrunk += 1
|
||||
else:
|
||||
raise RuntimeError(f"documents {i}-{i + len(chunk) - 1} still over the token limit")
|
||||
if "data" not in out:
|
||||
raise RuntimeError(f"no embeddings in response for documents {i}-{i + len(chunk) - 1}: {json.dumps(out)[:500]}")
|
||||
data = sorted(out["data"], key=lambda item: item["index"])
|
||||
if len(data) != len(chunk):
|
||||
raise RuntimeError(f"asked for {len(chunk)} vectors, got {len(data)}")
|
||||
vecs.extend(item["embedding"] for item in data)
|
||||
done = len(vecs)
|
||||
if done % 50 < args.batch:
|
||||
print(f" embedded {done}/{len(docs)} ({time.time() - start:.0f}s)", flush=True)
|
||||
if shrunk:
|
||||
print(f" {shrunk} over-limit texts were shortened to fit the model's token limit")
|
||||
with open(cache, "w") as fh:
|
||||
json.dump(vecs, fh)
|
||||
return vecs
|
||||
|
||||
|
||||
def normalise(vec):
|
||||
norm = math.sqrt(sum(x * x for x in vec)) or 1.0
|
||||
return [x / norm for x in vec]
|
||||
|
||||
|
||||
def nearest_neighbours(vecs):
|
||||
n = len(vecs)
|
||||
best = [(-2.0, -1)] * n
|
||||
for i in range(n):
|
||||
vi = vecs[i]
|
||||
for j in range(i + 1, n):
|
||||
s = sum(a * b for a, b in zip(vi, vecs[j]))
|
||||
if s > best[i][0]:
|
||||
best[i] = (s, j)
|
||||
if s > best[j][0]:
|
||||
best[j] = (s, i)
|
||||
return [j for _, j in best]
|
||||
|
||||
|
||||
def score(docs, neighbours, field):
|
||||
labels = [d[field] for d in docs]
|
||||
counts = Counter(labels)
|
||||
hits = sum(labels[i] == labels[neighbours[i]] for i in range(len(docs)))
|
||||
ceiling = sum(counts[label] > 1 for label in labels)
|
||||
floor = max(counts.values())
|
||||
n = len(docs)
|
||||
return 100 * hits / n, 100 * ceiling / n, 100 * floor / n, len(counts)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--corpus", required=True)
|
||||
ap.add_argument("-e", "--endpoint", required=True)
|
||||
ap.add_argument("-m", "--model", required=True)
|
||||
ap.add_argument("--key-env", default="", help="environment variable holding the API key")
|
||||
ap.add_argument("--via-pod", default="", help='run curl in this pod, e.g. "deploy/llama-server -n ai"')
|
||||
ap.add_argument("--batch", type=int, default=1)
|
||||
ap.add_argument("--max-chars", type=int, default=0, help="truncate each text to this many characters (0 = as exported)")
|
||||
args = ap.parse_args()
|
||||
|
||||
key = os.environ.get(args.key_env, "") if args.key_env else ""
|
||||
if args.key_env and not key:
|
||||
sys.exit(f"{args.key_env} is not set")
|
||||
|
||||
with open(args.corpus) as fh:
|
||||
docs = [json.loads(line) for line in fh if line.strip()]
|
||||
if args.max_chars:
|
||||
for d in docs:
|
||||
d["text"] = d["text"][:args.max_chars]
|
||||
print(f"model={args.model} documents={len(docs)} max_chars={args.max_chars} endpoint={args.endpoint}")
|
||||
|
||||
start = time.time()
|
||||
vecs = [normalise(v) for v in embed_all(docs, args, key)]
|
||||
embed_s = time.time() - start
|
||||
neighbours = nearest_neighbours(vecs)
|
||||
|
||||
print(f"dimensions={len(vecs[0])} embed+load={embed_s:.0f}s")
|
||||
for field in ("correspondent", "doctype"):
|
||||
acc, ceiling, floor, classes = score(docs, neighbours, field)
|
||||
print(f"{field:<14} accuracy={acc:5.1f}% ceiling={ceiling:5.1f}% floor={floor:5.1f}% labels={classes}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Paired comparison of two embedding models scored by knn-bench.py.
|
||||
|
||||
Both runs must cover the same documents in the same order. For each label field
|
||||
it reports how the per-document outcomes overlap, an exact two-sided McNemar
|
||||
test on the documents where exactly one model is right, and how often the two
|
||||
models pick the same nearest neighbour.
|
||||
|
||||
Usage:
|
||||
knn-compare.py --corpus-a a.jsonl --vecs-a a.vecs.json \\
|
||||
--corpus-b b.jsonl --vecs-b b.vecs.json
|
||||
|
||||
The vectors files are the caches knn-bench.py writes next to its corpus.
|
||||
Standard library only.
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
spec = importlib.util.spec_from_file_location("knn_bench", os.path.join(HERE, "knn-bench.py"))
|
||||
bench = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(bench)
|
||||
|
||||
|
||||
def load(corpus, vecs):
|
||||
with open(corpus) as fh:
|
||||
docs = [json.loads(line) for line in fh if line.strip()]
|
||||
with open(vecs) as fh:
|
||||
vectors = [bench.normalise(v) for v in json.load(fh)]
|
||||
if len(docs) != len(vectors):
|
||||
raise SystemExit(f"{vecs}: {len(vectors)} vectors for {len(docs)} documents")
|
||||
return docs, bench.nearest_neighbours(vectors)
|
||||
|
||||
|
||||
def mcnemar_exact(only_a, only_b):
|
||||
"""Two-sided exact p-value for a fair coin over the discordant pairs."""
|
||||
n = only_a + only_b
|
||||
if n == 0:
|
||||
return 1.0
|
||||
k = min(only_a, only_b)
|
||||
tail = sum(math.comb(n, i) for i in range(k + 1)) / 2 ** n
|
||||
return min(1.0, 2 * tail)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
for side in "ab":
|
||||
ap.add_argument(f"--corpus-{side}", required=True)
|
||||
ap.add_argument(f"--vecs-{side}", required=True)
|
||||
args = ap.parse_args()
|
||||
|
||||
docs_a, nn_a = load(args.corpus_a, args.vecs_a)
|
||||
docs_b, nn_b = load(args.corpus_b, args.vecs_b)
|
||||
if [d["id"] for d in docs_a] != [d["id"] for d in docs_b]:
|
||||
raise SystemExit("the two corpora do not hold the same documents in the same order")
|
||||
|
||||
n = len(docs_a)
|
||||
same_nn = sum(a == b for a, b in zip(nn_a, nn_b))
|
||||
print(f"documents={n} same nearest neighbour in both models: {same_nn} ({100 * same_nn / n:.1f}%)")
|
||||
for field in ("correspondent", "doctype"):
|
||||
ok_a = [docs_a[i][field] == docs_a[nn_a[i]][field] for i in range(n)]
|
||||
ok_b = [docs_b[i][field] == docs_b[nn_b[i]][field] for i in range(n)]
|
||||
both = sum(a and b for a, b in zip(ok_a, ok_b))
|
||||
only_a = sum(a and not b for a, b in zip(ok_a, ok_b))
|
||||
only_b = sum(b and not a for a, b in zip(ok_a, ok_b))
|
||||
neither = n - both - only_a - only_b
|
||||
p = mcnemar_exact(only_a, only_b)
|
||||
print(f"{field:<14} both right={both} only A={only_a} only B={only_b} both wrong={neither} "
|
||||
f"McNemar p={p:.3f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user