Snapshot publisher (sketch, a proposal, NOT applied)

This is a design sketch for review. Nothing here has been applied to devata, and it assumes no live credentials. Treat it as the thing you build during the showcase workstream, after you have read it and decided the push target. The YAML is illustrative and will need real names, namespaces, and a built image before it runs.

What it is

A small in-cluster job that reads cluster state read-only, renders it into a public-safe document that matches snapshot.schema.v1.json, validates that document against the schema, and pushes it outbound to a static host. The static portfolio then consumes the document. The cluster never accepts inbound, so this respects CGNAT.

It sits between the two things that already exist in the portfolio repo:

  • It automates **PR 21, which today hand-commits src/data/cluster.ts. After this runs, the snapshot is produced by the cluster on a schedule instead of by hand.
  • It is the **lighter alternative to issue 23, the live cluster-status API behind a Cloudflare Tunnel. The live API keeps an endpoint up and reachable; this pushes a file and keeps nothing open. Same data, far less exposure. Build this first; keep #23 as a later option if you ever want true real-time.

The safety model (read this before the YAML)

  1. Read-only on the cluster side. The ServiceAccount can get and list exactly four resource kinds (nodes, namespaces, services, persistentvolumeclaims). It cannot read secrets, cannot write, cannot touch the apiserver beyond those verbs.
  2. Allowlist transform, never denylist. The public document is built by explicitly selecting safe fields into a new object. It is never built by copying the full Kubernetes object and deleting the unsafe parts. Allowlist is the property that keeps a new, unredacted field from leaking the day Kubernetes adds one. Internal IPs, addresses, real node names, and secret references are simply never selected.
  3. Schema is the gate. The job validates its own output against snapshot.schema.v1.json and refuses to push if it fails. Because the schema has additionalProperties: false, any accidental extra field fails the build instead of shipping.
  4. It is not zero-secret, and that is the one honest caveat. Reading the cluster needs no secret, but pushing outbound needs one write credential to the target (a git token or an object-storage key). That credential is the reason a minimal secret story ships with this job, see “Secrets” below. This is also why, in the roadmap, Sealed Secrets is a soft prerequisite for the automated push (the manual snapshot of PR #21 needs none and can ship first).
  5. Graceful staleness. The CronJob only runs when the cluster is powered on, so a missed run simply leaves the last good snapshot in place. The consumer reads generatedAt and freshness.maxAgeHours and labels an old snapshot as stale rather than breaking. Never overwrite the published file with an empty or partial document.

RBAC (read-only ServiceAccount)

apiVersion: v1
kind: ServiceAccount
metadata:
  name: snapshot-publisher
  namespace: showcase
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: snapshot-reader
rules:
  - apiGroups: [""]
    resources: ["nodes", "namespaces", "services", "persistentvolumeclaims"]
    verbs: ["get", "list"]   # no watch, no write, no secrets
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: snapshot-publisher-can-read
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: snapshot-reader
subjects:
  - kind: ServiceAccount
    name: snapshot-publisher
    namespace: showcase

The CronJob

apiVersion: batch/v1
kind: CronJob
metadata:
  name: snapshot-publisher
  namespace: showcase
spec:
  schedule: "*/30 * * * *"     # every 30 min while the cluster is up; tune to taste
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 2
  failedJobsHistoryLimit: 2
  jobTemplate:
    spec:
      backoffLimit: 1
      template:
        spec:
          serviceAccountName: snapshot-publisher
          restartPolicy: Never
          containers:
            - name: publish
              # A small image you build: kubectl + jq + git + check-jsonschema.
              # Start from bitnami/kubectl and add the rest, or use an alpine + apk add.
              image: ghcr.io/pragalvaxfrez/snapshot-publisher:0.1.0
              command: ["/bin/sh", "/scripts/publish.sh"]
              env:
                - name: PUSH_TARGET
                  value: "git"   # or "r2"
                - name: GIT_TOKEN
                  valueFrom:
                    secretKeyRef:
                      name: snapshot-push-credential   # a SealedSecret in git, see below
                      key: token
              volumeMounts:
                - name: scripts
                  mountPath: /scripts
                - name: schema
                  mountPath: /schema
          volumes:
            - name: scripts
              configMap: { name: snapshot-publisher-scripts }
            - name: schema
              configMap: { name: snapshot-schema-v1 }   # holds snapshot.schema.v1.json

The render-redact-validate-push script (shell, for tracing)

This is deliberately shell-and-jq so you can read every transform. A client-go rewrite is a fine later refactor, but the shell version is the one you can trace line by line the first time.

#!/bin/sh
set -eu
 
# 1. Read state read-only (in-cluster ServiceAccount token is used automatically).
kubectl get nodes  -o json > /tmp/nodes.json
kubectl get ns     -o json > /tmp/ns.json
kubectl get svc -A -o json > /tmp/svc.json
kubectl get pvc -A -o json > /tmp/pvc.json
 
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
 
# 2. Build the public doc by SELECTING safe fields (allowlist). Node names are aliased here,
#    IPs are never read out. Versions come from the node objects, no talosctl needed.
#    (Illustrative jq; node aliasing and service curation get fleshed out when you build it.)
jq -n \
  --arg now "$NOW" \
  --slurpfile nodes /tmp/nodes.json \
  --slurpfile ns /tmp/ns.json \
  '{
     schemaVersion: "1.0.0",
     generatedAt: $now,
     generator: { name: "devata-snapshot-publisher", version: "0.1.0",
                  method: "read-only Kubernetes API via a scoped ServiceAccount" },
     freshness: { clusterPowered: true, maxAgeHours: 24 },
     cluster: {
       name: "devata",
       distribution: "Talos Linux",
       osVersion: ($nodes[0].items[0].status.nodeInfo.osImage),
       kubernetesVersion: ($nodes[0].items[0].status.nodeInfo.kubeletVersion),
       gitops: "manual Helm", ingress: "Gateway API CRDs installed; no controller wired",
       secretsManagement: "none yet", publicExposure: "none; behind CGNAT, outbound-only"
     },
     totals: {
       nodes: ($nodes[0].items | length),
       vcpu:  ([$nodes[0].items[].status.capacity.cpu | tonumber] | add),
       namespaces: ($ns[0].items | length)
     },
     nodes: [ $nodes[0].items | to_entries[] | {
       id: (if (.value.metadata.labels["node-role.kubernetes.io/control-plane"] != null)
            then "cp-\(.key+1)" else "worker-\(.key)" end),
       role: (if (.value.metadata.labels["node-role.kubernetes.io/control-plane"] != null)
              then "control-plane" else "worker" end),
       status: (if ([.value.status.conditions[] | select(.type=="Ready") | .status] | first) == "True"
                then "Ready" else "NotReady" end)
       # vcpu/memoryGiB/ageDays added the same way; IP and real name are never selected
     } ]
     # services / observability / storage / gpu added by the same select-only pattern
   }' > /tmp/snapshot.json
 
# 3. Validate against the schema. Refuse to push on failure.
check-jsonschema --schemafile /schema/snapshot.schema.v1.json /tmp/snapshot.json
 
# 4. Push outbound. Option A, commit to a dedicated data repo (recommended default).
if [ "$PUSH_TARGET" = "git" ]; then
  git clone --depth 1 "https://x-access-token:${GIT_TOKEN}@github.com/PragalvaXFREZ/devata-snapshot.git" /tmp/repo
  cp /tmp/snapshot.json /tmp/repo/snapshot.json
  cd /tmp/repo
  git config user.email "publisher@devata.local"
  git config user.name "devata snapshot publisher"
  git add snapshot.json
  git commit -m "snapshot ${NOW}" || { echo "no change"; exit 0; }   # skip empty commits
  git push origin HEAD:main
fi
 
# Option B, PUT to Cloudflare R2 (S3 API). Sketch only:
# aws s3 cp /tmp/snapshot.json s3://devata-snapshot/snapshot.json --endpoint-url "$R2_ENDPOINT"

Secrets (the one credential)

The push needs a single, tightly scoped write credential:

  • Git option: a GitHub fine-grained personal access token scoped to one dedicated repo (devata-snapshot) with contents:write only, nothing else. The portfolio reads raw.githubusercontent.com/.../devata-snapshot/main/snapshot.json or, better, the static host pulls that repo. A dedicated repo means the blast radius of a leaked token is one file.
  • R2 option: an R2 API token scoped to one bucket, write-only.

Either way, the credential is committed as a SealedSecret (encrypted with the cluster’s public key, safe in git), which is why the roadmap sequences Sealed Secrets just ahead of this automated push. Until then, PR #21’s hand-committed snapshot covers the public showcase with no secret at all.

Why a dedicated devata-snapshot repo, not pushing into the portfolio

Pushing the file into a separate repo keeps a machine token away from the portfolio’s source, keeps the snapshot’s commit history clean and separate, and lets the consumer fetch a single raw file. If you prefer one fewer repo, push to a data/ path in the portfolio behind a token scoped to that path is not possible with GitHub fine-grained tokens (they scope to a repo, not a path), so the dedicated repo is the cleaner least-privilege choice. This is a default, not a hard rule; see the roadmap’s decision list.

Verification before you trust it

  1. Run the render step locally with kubectl pointed at devata and eyeball /tmp/snapshot.json: no IPs, no real node names, no addresses, no secrets.
  2. Run check-jsonschema against it and against a deliberately-broken copy (add a stray field) to prove the gate rejects.
  3. Diff the output against the current cluster.ts values so you trust the numbers match reality before the page swaps its data source.