signing-service: Dockerfile, K8s manifests (agent-trust), image CI, secret convention
Some checks failed
validate / JSON Schema (ajv) (push) Successful in 20s
validate / Rego (opa fmt + opa test) (push) Successful in 14s
validate / Go (vet + test) (push) Successful in 21s
build-images / signing-service image (push) Failing after 13s

- Dockerfile: multi-stage (golang:1.23-alpine3.21 → distroless/static:nonroot),
  CGO off, trimpath, stripped, UID/GID 65532, read-only rootfs friendly.
- deploy/signing-service/: ServiceAccount, Deployment (2 replicas, probes,
  tight SecurityContext), ClusterIP Service on :8081, NetworkPolicy
  (ingress from agent-trust, DNS egress), example Secret, and README with
  the full dev + prod secret convention.
- .forgejo/workflows/build-images.yml: builds image on push + PR,
  pushes :<git-sha> + :main to harbor.basicstack.de on main pushes only.
  Clone pattern matches validate.yml (sha256 repo, manual git clone).
- README updates: service README gains Deployment section; repo README
  layout now mentions services/signing-service, services/transparency-log,
  deploy/, and the new workflow.

Scope matches DEV-605. OpenBao, OPA and Pocket-ID JWKS stay stubs and move
in their own follow-up subtasks; the prod secret shape is documented so
those subtasks can plug in without reshaping the Deployment.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO 2026-10-25 18:18:28 +00:00 • committed by CTO (Paperclip)
parent 5d338dd725
commit b52936ce9d
11 changed files with 555 additions and 2 deletions

View file

@ -0,0 +1,75 @@
name: build-images
# Build + push container images for ProofedMesh services on every push to
# main. PRs run the build to catch Dockerfile breakage but do not push.
#
# The repository is sha256 — same gotchas as validate.yml apply: no
# actions/checkout, clone manually from a trixie/alpine container with
# git ≥ 2.42.
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
signing-service:
name: signing-service image
runs-on: ubuntu-latest
container:
image: debian:trixie-slim
options: --user 0:0
env:
REGISTRY: harbor.basicstack.de
IMAGE_REPO: harbor.basicstack.de/basicstack/signing-service
steps:
- name: Install git + buildx client + ca-certs
run: |
set -euo pipefail
apt-get update
apt-get install -y --no-install-recommends \
git curl ca-certificates docker.io
git --version
docker --version
- name: Clone repository (sha256-aware)
run: |
set -euo pipefail
git clone --depth=1 --branch="${GITHUB_REF_NAME}" \
"https://x-access-token:${GITHUB_TOKEN}@forgejo.basicstack.de/${GITHUB_REPOSITORY}.git" \
"${GITHUB_WORKSPACE}"
cd "${GITHUB_WORKSPACE}"
git log -1 --oneline
- name: Resolve tags
id: tags
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
SHA="$(git rev-parse --short HEAD)"
echo "sha=${SHA}" >> "${GITHUB_OUTPUT}"
echo "image_sha=${IMAGE_REPO}:${SHA}" >> "${GITHUB_OUTPUT}"
echo "image_main=${IMAGE_REPO}:main" >> "${GITHUB_OUTPUT}"
- name: Build image
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
docker build \
-f services/signing-service/Dockerfile \
-t "${{ steps.tags.outputs.image_sha }}" \
-t "${{ steps.tags.outputs.image_main }}" \
.
- name: Login to Harbor (push jobs only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
set -eu
echo "${{ secrets.HARBOR_PASSWORD }}" | \
docker login "${REGISTRY}" --username "${{ secrets.HARBOR_USERNAME }}" --password-stdin
- name: Push image (main only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
set -eu
docker push "${{ steps.tags.outputs.image_sha }}"
docker push "${{ steps.tags.outputs.image_main }}"

View file

@ -75,8 +75,14 @@ services/
per-diem-calculator/ # deterministic calculator (Go) per-diem-calculator/ # deterministic calculator (Go)
admin-api/ # admin surface for per-diem rate management (Go) admin-api/ # admin surface for per-diem rate management (Go)
attest-reconciler/ # periodic worker: re-signs per_diem_rates rows without rate_change_attestation_ref (Go) attest-reconciler/ # periodic worker: re-signs per_diem_rates rows without rate_change_attestation_ref (Go)
signing-service/ # DSSE signing service (Go, ADR-0005) + Dockerfile
transparency-log/ # Trillian-backed transparency log (Go, ADR-0005)
deploy/
trillian/ # Trillian MySQL + log server/signer (namespace agent-trust)
signing-service/ # signing-service Deployment/Service/NetworkPolicy + Secret convention
.forgejo/workflows/ .forgejo/workflows/
validate.yml # JSON-Schema (ajv) + Rego (opa fmt + opa test) + Go (vet + test) validate.yml # JSON-Schema (ajv) + Rego (opa fmt + opa test) + Go (vet + test)
build-images.yml # Container-Images für ProofedMesh-Services → harbor.basicstack.de
``` ```
Weitere Verzeichnisse (weitere Services, Deployment-Manifeste, Senken-/Datenquellen-Adapter) folgen im Zuge der Architekturentwicklung. Weitere Verzeichnisse (weitere Services, Deployment-Manifeste, Senken-/Datenquellen-Adapter) folgen im Zuge der Architekturentwicklung.

View file

@ -0,0 +1,10 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: signing-service
namespace: agent-trust
labels:
app.kubernetes.io/name: signing-service
app.kubernetes.io/part-of: proofedmesh
app.kubernetes.io/component: trust-core
automountServiceAccountToken: true

View file

@ -0,0 +1,30 @@
# signing-service dev secret — EXAMPLE ONLY.
#
# This file is a template. The real cluster secret MUST be created either as
# a SealedSecret (preferred, see deploy/signing-service/README.md) or applied
# imperatively with values that never land in the git repo.
#
# Secret shape (dev mode):
# SIGNING_BEARERS — comma-separated bearer=subject pairs for the static
# token validator (-bearers flag)
# KEY_<SLUG> — hex-encoded 64-byte Ed25519 private key per key_ref
# ("/" "-" "." → "_", upper-case; see service README)
#
# Generate a dev key:
# go run ./cmd/signing-service -gen-dev-key
#
# For key_ref "trust-api/rate-change-signer-2026-q4" the env name is
# KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4.
apiVersion: v1
kind: Secret
metadata:
name: signing-service-dev
namespace: agent-trust
labels:
app.kubernetes.io/name: signing-service
app.kubernetes.io/part-of: proofedmesh
proofedmesh.basicstack.de/mode: dev
type: Opaque
stringData:
SIGNING_BEARERS: "dev-token=agent-admin-api"
KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4: "REPLACE_WITH_128_HEX_CHARS_FROM_gen-dev-key"

View file

@ -0,0 +1,97 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: signing-service
namespace: agent-trust
labels:
app.kubernetes.io/name: signing-service
app.kubernetes.io/part-of: proofedmesh
app.kubernetes.io/component: trust-core
spec:
replicas: 2
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: signing-service
template:
metadata:
labels:
app.kubernetes.io/name: signing-service
app.kubernetes.io/part-of: proofedmesh
app.kubernetes.io/component: trust-core
spec:
serviceAccountName: signing-service
automountServiceAccountToken: true
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: signing-service
image: harbor.basicstack.de/basicstack/signing-service:dev
imagePullPolicy: IfNotPresent
args:
- "-listen=:8081"
- "-backend=memory"
- "-keys=trust-api/rate-change-signer-2026-q4"
- "-keys-from-env"
- "-bearers=$(SIGNING_BEARERS)"
env:
# Pulled from the dev Secret below; see deploy/signing-service/README.md
# for the full secret shape and the SealedSecret recipe.
- name: SIGNING_BEARERS
valueFrom:
secretKeyRef:
name: signing-service-dev
key: SIGNING_BEARERS
- name: KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4
valueFrom:
secretKeyRef:
name: signing-service-dev
key: KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4
ports:
- name: http
containerPort: 8081
protocol: TCP
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 2
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 20
failureThreshold: 3
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "500m"
memory: "256Mi"
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: signing-service

View file

@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: signing-service
namespace: agent-trust
labels:
app.kubernetes.io/name: signing-service
app.kubernetes.io/part-of: proofedmesh
app.kubernetes.io/component: trust-core
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: signing-service
ports:
- name: http
port: 8081
targetPort: http
protocol: TCP

View file

@ -0,0 +1,51 @@
# NetworkPolicy for signing-service.
#
# Cluster-wide NetworkPolicy enforcement is already active on BasicStack
# (see cluster memory on NetworkPolicy activation). Any pod in agent-trust
# without a policy is isolated by default; this file is the explicit
# allow-list for signing-service.
#
# Ingress: anyone in agent-trust (admin-api, attest-reconciler, transparency-log)
# plus future callers reaching through an in-cluster ingress controller.
# Egress: DNS (kube-system/coredns), and later the OpenBao Transit namespace
# and Pocket ID (JWKS) when those backends are wired. For now the
# service only needs DNS egress — the memory backend and static
# validator are self-contained.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: signing-service
namespace: agent-trust
labels:
app.kubernetes.io/name: signing-service
app.kubernetes.io/part-of: proofedmesh
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: signing-service
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: agent-trust
ports:
- port: 8081
protocol: TCP
# Future: add admin-api / app namespaces here once they are deployed and
# labelled. Keep the list conservative — add on demand, not speculatively.
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP

View file

@ -0,0 +1,165 @@
# signing-service — deployment
Kubernetes manifests for the ADR-0005 signing-service in the `agent-trust`
namespace. The namespace itself is created by
[`deploy/trillian/00-namespace.yaml`](../trillian/00-namespace.yaml); this
directory assumes that file (or an equivalent) is already applied.
## Layout
```
deploy/signing-service/
├── 00-serviceaccount.yaml — ServiceAccount with projected SA token
├── 10-secret-dev.example.yaml — TEMPLATE for the dev-mode Secret
├── 20-deployment.yaml — Deployment (non-root, read-only rootfs, distroless)
├── 30-service.yaml — ClusterIP Service on :8081
├── 40-networkpolicy.yaml — ingress from agent-trust, DNS egress
└── README.md — this file
```
## Apply order
```bash
kubectl apply -f deploy/trillian/00-namespace.yaml # if not yet applied
kubectl apply -f deploy/signing-service/00-serviceaccount.yaml
# Create the dev Secret separately (see "Secret convention" below) — DO NOT
# commit the real values. The .example file is a template, not a secret.
kubectl apply -f deploy/signing-service/20-deployment.yaml
kubectl apply -f deploy/signing-service/30-service.yaml
kubectl apply -f deploy/signing-service/40-networkpolicy.yaml
```
Reach the service in-cluster at:
```
http://signing-service.agent-trust.svc.cluster.local:8081
```
Health check (also used by Deployment probes):
```
GET /healthz → 200 {"status":"ok"}
```
Sign endpoint (per ADR-0005):
```
POST /api/v1/sign/dsse
Authorization: Bearer <token from SIGNING_BEARERS>
```
## K8s secret convention
The signing-service never pulls secrets from the pod filesystem or a config
map. Every secret value is an **environment variable sourced from one
Kubernetes Secret** named `signing-service-<mode>`. This keeps the SealedSecret
rotation path uniform and lets the Deployment stay identical across modes.
### Dev mode (`-backend=memory`)
Secret: `signing-service-dev`
| Env key | Purpose |
| ------------------------------------------- | ----------------------------------------------------------------------- |
| `SIGNING_BEARERS` | `-bearers` value, comma-separated `bearer=subject` pairs |
| `KEY_<SLUG>` | one entry per `key_ref`; value is a hex-encoded 64-byte Ed25519 private key |
Slug rule: upper-case the `key_ref`, then replace `/`, `-`, `.` with `_`. For
`trust-api/rate-change-signer-2026-q4` the env name is
`KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4`.
Mint a dev key:
```bash
cd services/signing-service
go run ./cmd/signing-service -gen-dev-key
# → prints 128 hex chars (64 bytes raw Ed25519 private key)
```
Create the Secret for the cluster (never commit the raw value):
```bash
BEARER_TOKEN="$(openssl rand -hex 32)"
HEX_KEY="$(cd services/signing-service && go run ./cmd/signing-service -gen-dev-key)"
kubectl create secret generic signing-service-dev \
-n agent-trust \
--from-literal="SIGNING_BEARERS=${BEARER_TOKEN}=agent-admin-api" \
--from-literal="KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4=${HEX_KEY}"
```
For the GitOps path, convert the above to a SealedSecret with `kubeseal`
and commit the sealed form under the same filename as the example:
```bash
kubectl create secret generic signing-service-dev -n agent-trust \
--from-literal="SIGNING_BEARERS=${BEARER_TOKEN}=agent-admin-api" \
--from-literal="KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4=${HEX_KEY}" \
--dry-run=client -o yaml | \
kubeseal --controller-namespace=sealed-secrets --format=yaml \
> deploy/signing-service/10-secret-dev.sealed.yaml
```
The `10-secret-dev.example.yaml` file is kept as the shape reference; the
real sealed form is `10-secret-dev.sealed.yaml` (not yet committed — ships
when the first real deployment lands).
### Prod mode (future, out of scope for DEV-605)
Secret: `signing-service-prod`
Planned env keys — kept here so future subtasks don't invent a new shape:
| Env key | Purpose | Subtask |
| ---------------------------- | ----------------------------------------------------------------- | ------------------------ |
| `OPENBAO_ADDR` | OpenBao server URL (`https://openbao.agent-trust.svc:8200`) | OpenBao Transit signer |
| `OPENBAO_ROLE` | AppRole / Kubernetes auth role name for the signing-service pod | OpenBao Transit signer |
| `OPENBAO_TOKEN` (bootstrap) | optional bootstrap token; prefer SA-token-based login once wired | OpenBao Transit signer |
| `POCKET_ID_JWKS_URL` | `https://auth.basicstack.de/.well-known/jwks.json` | Pocket-ID JWKS validator |
| `POCKET_ID_ISSUER` | `https://auth.basicstack.de` | Pocket-ID JWKS validator |
| `POCKET_ID_AUDIENCE` | expected `aud` claim (e.g. `trust-api`) | Pocket-ID JWKS validator |
| `OPA_URL` | OPA sidecar / service URL (`http://opa.agent-trust.svc:8181`) | OPA policy evaluator |
The matching command-line flags (`-backend=openbao`, `-policy=opa`,
`-validator=pocket-id`) land with the respective subtasks. Until then the
Deployment stays on `-backend=memory` with the dev Secret above; switching
is a Deployment patch and a Secret swap — no manifest reshape.
## Verifying the deployment
```bash
# Pod comes up, probe says OK:
kubectl -n agent-trust rollout status deploy/signing-service
# In-cluster reachability from a throwaway pod:
kubectl -n agent-trust run curl --image=curlimages/curl:8 -it --rm --restart=Never -- \
curl -sS http://signing-service:8081/healthz
```
End-to-end sign check (adjust `BEARER_TOKEN` to the one you stored in the
Secret):
```bash
kubectl -n agent-trust run curl --image=curlimages/curl:8 -it --rm --restart=Never -- \
curl -sS -X POST http://signing-service:8081/api/v1/sign/dsse \
-H "Authorization: Bearer ${BEARER_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"key_ref":"trust-api/rate-change-signer-2026-q4",
"statement":{"_type":"https://in-toto.io/Statement/v1",
"subject":[{"name":"per_diem_rate/PDR-1","digest":{"sha256":"aaaa"}}],
"predicateType":"https://basicstack.de/attestations/rate_change/v1",
"predicate":{"change_kind":"create","rate_id":"PDR-1"}}}'
```
## Image
Images are built by `.forgejo/workflows/build-images.yml` on every push to
`main` and pushed to:
```
harbor.basicstack.de/basicstack/signing-service:<git-sha>
harbor.basicstack.de/basicstack/signing-service:main (floating)
```
The Deployment currently references `:dev`; a GitOps apply will pin it to a
`:<git-sha>` once the image build pipeline has produced one.

View file

@ -0,0 +1,4 @@
**/*_test.go.bak
**/.DS_Store
**/*.swp
**/*.swo

View file

@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1.7
# ---------- build ----------
FROM golang:1.23-alpine3.21 AS build
ENV CGO_ENABLED=0 \
GOOS=linux \
GOFLAGS="-trimpath"
WORKDIR /src
# Only the signing-service module is needed. Copy the module files first so
# the dependency download layer is cacheable independently of source edits.
COPY services/signing-service/go.mod ./
# go.sum will be copied in when the module gains external deps; today the
# module is stdlib-only.
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go mod download || true
COPY services/signing-service/ ./
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go vet ./... && \
go test ./... && \
go build -ldflags="-s -w" -o /out/signing-service ./cmd/signing-service
# ---------- runtime ----------
# Distroless static+nonroot: no shell, no package manager, UID/GID 65532.
# gcr.io/distroless/static-debian12:nonroot pins to the latest nonroot
# variant; CI tags images by git sha so this is reproducible at the point
# of build.
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/signing-service /signing-service
USER 65532:65532
EXPOSE 8081
ENTRYPOINT ["/signing-service"]
CMD ["-listen=:8081", "-backend=memory"]

View file

@ -19,7 +19,9 @@ admin-api and other clients use as the single attestation reference.
- **Pocket ID JWKS verifier**: stub. Dev uses the static `-bearers` table. - **Pocket ID JWKS verifier**: stub. Dev uses the static `-bearers` table.
Separate subtask. Separate subtask.
- **OPA policy decider**: stub. Dev uses AllowAll. Separate subtask. - **OPA policy decider**: stub. Dev uses AllowAll. Separate subtask.
- **Deployment manifest**: separate subtask. - **Deployment manifest**: Kubernetes manifests under
[`deploy/signing-service/`](../../deploy/signing-service/) + Dockerfile
next to this README. See **Deployment** below.
## Layout ## Layout
@ -111,3 +113,57 @@ Prod wiring in the OpenBao-Transit subtask: Signer backend becomes
backend becomes `policy.OPA` with the Rego bundle under `policies/signing/` backend becomes `policy.OPA` with the Rego bundle under `policies/signing/`
(not yet created), token validator becomes `token.PocketID` with JWKS from (not yet created), token validator becomes `token.PocketID` with JWKS from
`https://auth.basicstack.de/.well-known/jwks.json`. `https://auth.basicstack.de/.well-known/jwks.json`.
## Deployment
### Container image
Multi-stage build in `services/signing-service/Dockerfile`:
- build stage: `golang:1.23-alpine3.21`, runs `go vet` + `go test` before `go build`
- runtime stage: `gcr.io/distroless/static-debian12:nonroot` (UID/GID 65532, no shell)
- the final binary is static (`CGO_ENABLED=0`), stripped (`-ldflags="-s -w"`),
reproducible (`-trimpath`)
Build locally from the repo root:
```bash
docker build -f services/signing-service/Dockerfile \
-t harbor.basicstack.de/basicstack/signing-service:dev .
```
CI (`.forgejo/workflows/build-images.yml`) builds on every push + PR to
`main` and pushes `:<git-sha>` + `:main` to Harbor on main pushes only.
### Kubernetes
Manifests live under
[`deploy/signing-service/`](../../deploy/signing-service/) and target the
`agent-trust` namespace. See
[`deploy/signing-service/README.md`](../../deploy/signing-service/README.md)
for the full secret convention (dev + prod layout), the SealedSecret
recipe, and the apply order.
Minimal example (dev mode, in-memory key, static bearer):
```bash
BEARER_TOKEN="$(openssl rand -hex 32)"
HEX_KEY="$(go run ./cmd/signing-service -gen-dev-key)"
kubectl apply -f ../../deploy/trillian/00-namespace.yaml
kubectl apply -f ../../deploy/signing-service/00-serviceaccount.yaml
kubectl create secret generic signing-service-dev -n agent-trust \
--from-literal="SIGNING_BEARERS=${BEARER_TOKEN}=agent-admin-api" \
--from-literal="KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4=${HEX_KEY}"
kubectl apply -f ../../deploy/signing-service/20-deployment.yaml
kubectl apply -f ../../deploy/signing-service/30-service.yaml
kubectl apply -f ../../deploy/signing-service/40-networkpolicy.yaml
kubectl -n agent-trust rollout status deploy/signing-service
```
In-cluster the service is reachable at
`http://signing-service.agent-trust.svc.cluster.local:8081` (health check
`/healthz`).