Skip to content

Commit bc4146a

Browse files
feat(miner-deployment): add Kubernetes StatefulSet + Secret example for AMS fleet-mode (#5258)
Add k8s/ example manifests so an operator can deploy N isolated miner workers with kubectl instead of hand-rolling manifests or being limited to docker run/compose (#5181). Uses a StatefulSet (not a Deployment) with volumeClaimTemplates so each replica gets its OWN PersistentVolumeClaim — the miner's local SQLite ledgers are not safe for concurrent multi-pod access, so per-pod isolated storage is the safety property. Built on the existing Dockerfile image (entrypoint gittensory-miner, continuous 'run' worker, /data/miner state). Ships a Secret template (GITHUB_TOKEN + optional provider keys), a k8s/README.md deploy/scale guide, and a validation test asserting well-formed manifests pass, a malformed one fails, and the per-pod-storage invariant holds (no shared PVC across replicas). Packaging only — no runtime/governor/claim control-flow touched. Closes #5181
1 parent e99c151 commit bc4146a

4 files changed

Lines changed: 293 additions & 0 deletions

File tree

k8s/README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Kubernetes manifests — gittensory-miner (AMS) fleet mode
2+
3+
Example manifests for running **N isolated miner workers** on a small Kubernetes cluster, as an alternative to
4+
`docker run` / docker-compose on a single host. Built on the existing
5+
[`packages/gittensory-miner/Dockerfile`](../packages/gittensory-miner/Dockerfile) image (see
6+
[`DEPLOYMENT.md`](../packages/gittensory-miner/DEPLOYMENT.md) for the fleet-mode overview). These are starting
7+
points — review resource sizing, storage class, and your registry before applying to a real cluster.
8+
9+
## Why a StatefulSet (not a Deployment)
10+
11+
The miner keeps all state in **local SQLite ledgers** (`claim-ledger.sqlite3`, `plan-store.sqlite3`, …) under
12+
`GITTENSORY_MINER_CONFIG_DIR` (`/data/miner`). Those stores are **not safe for concurrent multi-pod access**, so
13+
each worker needs its **own** volume. A Deployment can only mount a single shared PVC across every replica; a
14+
**StatefulSet's `volumeClaimTemplates`** give each replica its own PersistentVolumeClaim — so scaling to N
15+
replicas yields N workers with fully isolated state. That per-pod isolation is the whole reason these manifests
16+
use a StatefulSet.
17+
18+
## Deploy
19+
20+
1. **Build and push the image** from the monorepo root, then set `image:` in `miner-deployment.yaml`:
21+
```sh
22+
docker build -f packages/gittensory-miner/Dockerfile -t <registry>/gittensory-miner:latest .
23+
docker push <registry>/gittensory-miner:latest
24+
```
25+
2. **Create the Secret** (fill in real values first — never commit the filled-in copy):
26+
```sh
27+
cp k8s/miner-secret.example.yaml k8s/miner-secret.yaml # edit in your real GITHUB_TOKEN + provider keys
28+
kubectl apply -f k8s/miner-secret.yaml
29+
```
30+
3. **Deploy the workers:**
31+
```sh
32+
kubectl apply -f k8s/miner-deployment.yaml
33+
```
34+
35+
## Scale
36+
37+
Each replica is one isolated worker with its own volume. Scale the fleet with:
38+
39+
```sh
40+
kubectl scale statefulset/gittensory-miner --replicas=<N>
41+
```
42+
43+
or by editing `replicas:` in `miner-deployment.yaml` and re-applying. New replicas each get a fresh
44+
per-pod PVC from the `volumeClaimTemplate`; scaling down retains the PVCs (Kubernetes does not delete them
45+
automatically), so a scaled-back worker resumes its own state when scaled up again.
46+
47+
## Notes
48+
49+
- **Secrets** are injected at runtime via the Secret — the image contains no credentials. `ANTHROPIC_API_KEY` /
50+
`OPENAI_API_KEY` are marked `optional`, so a worker running only the providers you configure starts cleanly
51+
without the others.
52+
- **Resources** default to a modest CLI-worker baseline (`250m`/`512Mi` request, `1`/`1Gi` limit) with headroom
53+
for a coding-agent subprocess. Tune for your providers and cluster.
54+
- **Storage** uses the cluster's default StorageClass at `2Gi` per pod; uncomment `storageClassName` in the
55+
`volumeClaimTemplate` if you need a specific class.
56+
- **Image tag** — the example uses `:latest` (which defaults to `imagePullPolicy: Always`, re-pulling on every
57+
restart). For production, push and pin an immutable tag (e.g. a version or digest).
58+
- **Probes** — no `livenessProbe`/`readinessProbe` is defined: the worker is a CLI loop, not a served endpoint,
59+
so there's no health port to probe. Add a process-based `livenessProbe` (e.g. an `exec` check) if your
60+
platform expects one.
61+
- **Filesystem ownership**`fsGroup`/`runAsGroup` are set so the non-root user owns its PVC and can write the
62+
SQLite files; without them the worker cannot create state on a root-owned `ReadWriteOnce` volume.

k8s/miner-deployment.yaml

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Kubernetes workload for gittensory-miner (AMS) fleet mode (#5181).
2+
#
3+
# This is a StatefulSet, NOT a Deployment: the miner keeps all of its state in local SQLite ledgers
4+
# (claim-ledger.sqlite3, plan-store.sqlite3, …) under GITTENSORY_MINER_CONFIG_DIR, and those stores are NOT
5+
# safe for concurrent multi-pod access. A Deployment can only mount one shared PVC across every replica; a
6+
# StatefulSet's volumeClaimTemplates give each replica its OWN PersistentVolumeClaim, so N workers run with
7+
# fully isolated state — the safety property this manifest exists to guarantee. See k8s/README.md.
8+
#
9+
# Image: build from packages/gittensory-miner/Dockerfile at the monorepo root and push to your registry:
10+
# docker build -f packages/gittensory-miner/Dockerfile -t <registry>/gittensory-miner:latest .
11+
# Then set `image:` below. Secrets come from k8s/miner-secret.example.yaml (apply that first).
12+
apiVersion: apps/v1
13+
kind: StatefulSet
14+
metadata:
15+
name: gittensory-miner
16+
labels:
17+
app.kubernetes.io/name: gittensory-miner
18+
app.kubernetes.io/component: fleet-worker
19+
spec:
20+
serviceName: gittensory-miner
21+
# Scale the fleet by editing `replicas` (each replica is one isolated worker) or:
22+
# kubectl scale statefulset/gittensory-miner --replicas=<N>
23+
replicas: 2
24+
selector:
25+
matchLabels:
26+
app.kubernetes.io/name: gittensory-miner
27+
template:
28+
metadata:
29+
labels:
30+
app.kubernetes.io/name: gittensory-miner
31+
app.kubernetes.io/component: fleet-worker
32+
spec:
33+
securityContext:
34+
runAsNonRoot: true
35+
runAsUser: 1000
36+
# runAsGroup + fsGroup so the non-root user owns the mounted PVC and can create its SQLite files on a
37+
# root-owned ReadWriteOnce volume; OnRootMismatch avoids re-chowning a large volume on every restart.
38+
runAsGroup: 1000
39+
fsGroup: 1000
40+
fsGroupChangePolicy: OnRootMismatch
41+
containers:
42+
- name: miner
43+
# Replace with the image you built + pushed from packages/gittensory-miner/Dockerfile.
44+
image: gittensory-miner:latest
45+
# ENTRYPOINT is `gittensory-miner`; `run` is the continuous fleet-worker loop.
46+
args: ["run"]
47+
env:
48+
- name: GITTENSORY_MINER_CONFIG_DIR
49+
value: /data/miner
50+
- name: GITHUB_TOKEN
51+
valueFrom:
52+
secretKeyRef:
53+
name: gittensory-miner-secrets
54+
key: GITHUB_TOKEN
55+
# Coding-agent provider credentials — optional; present only for the providers you enable.
56+
- name: ANTHROPIC_API_KEY
57+
valueFrom:
58+
secretKeyRef:
59+
name: gittensory-miner-secrets
60+
key: ANTHROPIC_API_KEY
61+
optional: true
62+
- name: OPENAI_API_KEY
63+
valueFrom:
64+
secretKeyRef:
65+
name: gittensory-miner-secrets
66+
key: OPENAI_API_KEY
67+
optional: true
68+
volumeMounts:
69+
- name: miner-data
70+
mountPath: /data/miner
71+
# A CLI worker, not a server: modest baseline with headroom for a coding-agent subprocess.
72+
resources:
73+
requests:
74+
cpu: 250m
75+
memory: 512Mi
76+
limits:
77+
cpu: "1"
78+
memory: 1Gi
79+
# Per-pod PersistentVolumeClaim — each replica gets its OWN volume, never one shared across replicas.
80+
volumeClaimTemplates:
81+
- metadata:
82+
name: miner-data
83+
spec:
84+
accessModes: ["ReadWriteOnce"]
85+
# storageClassName: fast-ssd # set this if your cluster's default StorageClass isn't what you want
86+
resources:
87+
requests:
88+
storage: 2Gi

k8s/miner-secret.example.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Secret template for gittensory-miner fleet workers (#5181).
2+
#
3+
# Values are intentionally EMPTY — this is a committed example, and a real-looking placeholder string in a
4+
# secret-named field trips secret scanners. Fill them in out-of-band; do NOT commit the filled-in copy.
5+
#
6+
# Recommended (keeps secret values out of any file entirely):
7+
# kubectl create secret generic gittensory-miner-secrets \
8+
# --from-literal=GITHUB_TOKEN=<your-token> \
9+
# --from-literal=ANTHROPIC_API_KEY=<optional> --from-literal=OPENAI_API_KEY=<optional>
10+
#
11+
# Or copy + edit this file, then apply it (stringData takes raw values; Kubernetes base64-encodes them):
12+
# cp k8s/miner-secret.example.yaml k8s/miner-secret.yaml # then fill in the empty values
13+
# kubectl apply -f k8s/miner-secret.yaml
14+
apiVersion: v1
15+
kind: Secret
16+
metadata:
17+
name: gittensory-miner-secrets
18+
labels:
19+
app.kubernetes.io/name: gittensory-miner
20+
type: Opaque
21+
stringData:
22+
# Required: a GitHub token the miner uses to read issues and open PRs.
23+
GITHUB_TOKEN: ""
24+
# Optional: only for the coding-agent providers you enable (claude-cli / codex-cli / agent-sdk).
25+
# Delete a line entirely for any provider you do not use.
26+
ANTHROPIC_API_KEY: ""
27+
OPENAI_API_KEY: ""
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { describe, expect, it } from "vitest";
4+
import { parse } from "yaml";
5+
6+
// Validation for the example K8s manifests (#5181). These are static infra artifacts (not src/** logic), so
7+
// Codecov's patch gate doesn't apply to the YAML — but the safety property the manifests exist to guarantee
8+
// (per-pod isolated SQLite storage, never a shared PVC across replicas) is asserted here as a real test, and the
9+
// structural validator is exercised against both a well-formed manifest (passes) and a malformed one (fails).
10+
11+
const K8S_DIR = join(process.cwd(), "k8s");
12+
const readManifest = (name: string): Record<string, unknown> =>
13+
parse(readFileSync(join(K8S_DIR, name), "utf8")) as Record<string, unknown>;
14+
15+
/** Minimal well-formedness check every Kubernetes resource must satisfy; throws on the first violation. */
16+
function validateK8sResource(doc: unknown): { kind: string } {
17+
if (typeof doc !== "object" || doc === null)
18+
throw new Error("manifest is not a mapping");
19+
const m = doc as Record<string, unknown>;
20+
for (const field of ["apiVersion", "kind", "metadata"] as const) {
21+
if (m[field] == null)
22+
throw new Error(`manifest missing required field: ${field}`);
23+
}
24+
const metadata = m.metadata as Record<string, unknown>;
25+
if (typeof metadata.name !== "string" || metadata.name.length === 0) {
26+
throw new Error("manifest missing metadata.name");
27+
}
28+
return { kind: String(m.kind) };
29+
}
30+
31+
/** The safety property this issue introduces: each replica gets its OWN volume, never one shared claim. */
32+
function assertPerPodStorage(statefulSet: Record<string, unknown>): void {
33+
const spec = statefulSet.spec as Record<string, unknown> | undefined;
34+
const vcts = spec?.volumeClaimTemplates;
35+
if (!Array.isArray(vcts) || vcts.length === 0) {
36+
throw new Error(
37+
"no volumeClaimTemplates: replicas would not get per-pod storage",
38+
);
39+
}
40+
const template = spec?.template as Record<string, unknown> | undefined;
41+
const podSpec = template?.spec as Record<string, unknown> | undefined;
42+
const podVolumes =
43+
(podSpec?.volumes as Array<Record<string, unknown>> | undefined) ?? [];
44+
for (const volume of podVolumes) {
45+
if (volume.persistentVolumeClaim)
46+
throw new Error("a shared PVC across replicas is not allowed");
47+
}
48+
}
49+
50+
describe("k8s miner manifests (#5181)", () => {
51+
const deployment = readManifest("miner-deployment.yaml");
52+
53+
it("miner-deployment.yaml is a well-formed StatefulSet", () => {
54+
expect(validateK8sResource(deployment).kind).toBe("StatefulSet");
55+
});
56+
57+
it("gives each replica its own SQLite volume (per-pod PVC, never a shared claim)", () => {
58+
expect(() => assertPerPodStorage(deployment)).not.toThrow();
59+
const spec = deployment.spec as Record<string, unknown>;
60+
expect((spec.volumeClaimTemplates as unknown[]).length).toBeGreaterThan(0);
61+
expect(typeof spec.replicas).toBe("number");
62+
});
63+
64+
it("runs the continuous worker with config dir, a secret-sourced token, and resource bounds", () => {
65+
const containers = (deployment.spec as any).template.spec
66+
.containers as Array<Record<string, any>>;
67+
const container = containers[0];
68+
if (!container)
69+
throw new Error("no container in the StatefulSet pod template");
70+
expect(container.args).toContain("run");
71+
const env = container.env as Array<Record<string, any>>;
72+
const configDir = env.find((e) => e.name === "GITTENSORY_MINER_CONFIG_DIR");
73+
expect(configDir?.value).toBe("/data/miner");
74+
const token = env.find((e) => e.name === "GITHUB_TOKEN");
75+
expect(token?.valueFrom?.secretKeyRef?.key).toBe("GITHUB_TOKEN");
76+
expect(container.resources.requests).toBeTruthy();
77+
expect(container.resources.limits).toBeTruthy();
78+
});
79+
80+
it("secret template is a well-formed Secret exposing GITHUB_TOKEN", () => {
81+
const secret = readManifest("miner-secret.example.yaml");
82+
expect(validateK8sResource(secret).kind).toBe("Secret");
83+
expect(
84+
(secret.stringData as Record<string, unknown>).GITHUB_TOKEN,
85+
).toBeDefined();
86+
});
87+
88+
it("the structural validator rejects a malformed manifest", () => {
89+
const malformed = parse("apiVersion: apps/v1\nmetadata:\n name: broken\n"); // no `kind`
90+
expect(() => validateK8sResource(malformed)).toThrow(
91+
/missing required field: kind/,
92+
);
93+
});
94+
95+
it("the per-pod-storage check rejects a shared-PVC configuration", () => {
96+
const shared = {
97+
spec: {
98+
volumeClaimTemplates: [{ metadata: { name: "data" } }],
99+
template: {
100+
spec: {
101+
volumes: [
102+
{ name: "data", persistentVolumeClaim: { claimName: "shared" } },
103+
],
104+
},
105+
},
106+
},
107+
};
108+
expect(() => assertPerPodStorage(shared)).toThrow(/shared PVC/);
109+
});
110+
111+
it("the per-pod-storage check rejects a config with no volumeClaimTemplates", () => {
112+
expect(() =>
113+
assertPerPodStorage({ spec: { template: { spec: {} } } }),
114+
).toThrow(/no volumeClaimTemplates/);
115+
});
116+
});

0 commit comments

Comments
 (0)