Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

deploy/kubernetes/gunnar.yaml

# gunnar in Kubernetes (roadmap phase 5).
#
# Plain manifests, and NOT a Helm chart. A chart would be a second description
# of the same deployment in a second language, and this repository keeps
# one authority for a thing. `kubectl apply -k deploy/kubernetes` is the door; the two knobs
# an operator actually turns — the image digest and the storage class — are
# kustomize patches, which is data, not templating.
#
# ── Three things here are load-bearing, and `cargo xtask deploy-check` fails
#    the build if any of them regresses ────────────────────────────────────────
#
#  1. THE IMAGE IS PINNED BY DIGEST. A product whose entire thesis is "you can
#     prove what this binary was built from" cannot deploy a floating tag. The
#     digest below is a placeholder and the check refuses a `:tag` reference.
#
#  2. THE DATA IS A PERSISTENT VOLUME. The transparency log and the forge log
#     live under the data root. On an `emptyDir` they would be
#     forgotten on every reschedule, and a log that forgets is not a log — it is
#     a cache with a signature on it.
#
#  3. THE KEYS COME FROM A SECRET AND ARE NEVER IN A CONFIGMAP. The KEK unseals
#     the transparency key. A ConfigMap is world-readable to
#     anything with `get configmaps` in the namespace.
#
# ── What is deliberately NOT here ────────────────────────────────────────────
#
#  * No `Ingress`. Git over SSH is not HTTP and does not go through one; the
#    `Service` below is what a `LoadBalancer` or a `NodePort` attaches to, and
#    which of those is right is a fact about the cluster, not about gunnar.
#  * No `HorizontalPodAutoscaler`. gunnar is a `StatefulSet` of ONE: the object
#    store is a znippy archive with a lock on it, and a second replica would be
#    a second writer. Replication is `gunnar mirror`, not a second pod.
---
apiVersion: v1
kind: Namespace
metadata:
  name: gunnar
---
# The secrets. **This file ships placeholders and no key material.**
#
# Create the real one out of band, once:
#
#   kubectl -n gunnar create secret generic gunnar-keys \
#     --from-literal=GUNNAR_SECRET_KEK="$(openssl rand -hex 32)"
#
# and delete this document from the kustomization. It is here so the shape is
# documented and so the check below has something to check.
apiVersion: v1
kind: Secret
metadata:
  name: gunnar-keys
  namespace: gunnar
type: Opaque
stringData:
  # 32 bytes, hex. Everything gunnar seals is sealed under this; losing it means
  # losing the transparency and provenance keys, which means the log can no
  # longer be headed and the chain stops at the last signed head.
  GUNNAR_SECRET_KEK: "REPLACE-ME-WITH-32-HEX-BYTES"
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: gunnar-config
  namespace: gunnar
data:
  GUNNAR_DATA_ROOT: "/var/lib/gunnar"
  RUST_LOG: "info"
---
apiVersion: v1
kind: Service
metadata:
  name: gunnar
  namespace: gunnar
spec:
  # A headless service, because the StatefulSet is one pod with a stable name
  # and a client that needs to reach *that* pod should say so.
  clusterIP: None
  selector:
    app.kubernetes.io/name: gunnar
  ports:
    - name: ssh
      port: 2222
      targetPort: ssh
    - name: http
      # Git over smart HTTP (and LFS), one listener in the server.
      port: 8080
      targetPort: http
    - name: grpc
      port: 50051
      targetPort: grpc
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: gunnar
  namespace: gunnar
spec:
  serviceName: gunnar
  # ONE. See the header: the store has a lock and a second writer is corruption,
  # not capacity.
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: gunnar
  template:
    metadata:
      labels:
        app.kubernetes.io/name: gunnar
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        runAsGroup: 65532
        fsGroup: 65532
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: gunnar
          # PINNED BY DIGEST. Replace the digest, never the form.
          image: ghcr.io/example/gunnar@sha256:0000000000000000000000000000000000000000000000000000000000000000
          args:
            - serve
            - --ssh-bind=0.0.0.0:2222
            - --http-bind=0.0.0.0:8080
            - --grpc-bind=0.0.0.0:50051
            - --metrics-bind=0.0.0.0:9090
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop:
                - ALL
          envFrom:
            - configMapRef:
                name: gunnar-config
            - secretRef:
                name: gunnar-keys
          ports:
            - name: ssh
              containerPort: 2222
            - name: http
              containerPort: 8080
            - name: grpc
              containerPort: 50051
            - name: metrics
              containerPort: 9090
          # `/healthz` answers without a credential once the HTTP listener is up.
          readinessProbe:
            httpGet:
              path: /healthz
              port: http
            initialDelaySeconds: 2
            periodSeconds: 5
          livenessProbe:
            tcpSocket:
              port: ssh
            initialDelaySeconds: 10
            periodSeconds: 20
          volumeMounts:
            - name: data
              mountPath: /var/lib/gunnar
            - name: tmp
              mountPath: /tmp
      volumes:
        # The root filesystem is read-only, so the one writable scratch path is
        # declared rather than left to a container that would fail on first use.
        - name: tmp
          emptyDir: {}
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes:
          - ReadWriteOnce
        resources:
          requests:
            storage: 50Gi