Documentation for v2.7.0. Read the latest, v2.12.0.
Operator
Deployment
Sanshain is one binary, one database and one port. This chapter is how to run it: the image, Docker and Compose for a single host, Kustomize or the Helm chart for Kubernetes, the choice between SQLite and PostgreSQL, and what an upgrade does.
What changed in 2.6.0
-
The image carries the platform CA bundle (
ca-certificates), soldaps://against a directory with a publicly trusted certificate works without a certificate mount. Private CAs still go throughEXTRA_CA_CERTS_DIR. -
The Helm chart renders an optional backup CronJob (
backup.*): a SQLite snapshot fetched from the service, orpg_dump. See Backup and restore. - Every release is signed with cosign and ships CycloneDX SBOMs; the weekly security run scans the released image. See Verifying a release.
- Every data cache expires its entries after 10 s, which bounds what a second replica can serve stale. See Scaling.
The image#
ghcr.io/paxel/sanshain-service:<tag>, built by the release workflow from the
repository's Dockerfile. Release tags carry a v (v2.6.0);
latest follows the newest release. The image is Alpine with sqlite-libs,
tini as PID 1 and ca-certificates; the binary is
/usr/local/bin/sanshain, the working directory /app, the static files
/app/static. It sets one variable:
DATABASE_URL=sqlite:/data/sanshain.db?mode=rwc, so the database lives under
/data. It exposes port 3000 and declares a Docker HEALTHCHECK on
GET /health. The Kubernetes manifests run it as UID 1000; the image itself does not
switch user.
On every start the service runs its SQL migrations, creates the initial admin when the user table
is empty (Getting started), claims the
configured root names, and logs the instance id. A failed migration is fatal: the container exits
with ERROR: Can't run SQLite migrations: … (or PostgreSQL) on stderr.
Docker and Compose#
docker run -p 3000:3000 -v sanshain-data:/data ghcr.io/paxel/sanshain-service:v2.6.0
docker-compose.release.template.yaml in the repository is the same as a Compose
service: the volume on /data, init: true,
restart: unless-stopped, and
RUST_LOG=sanshain_service=info,tower_http=info. Replace __VERSION__ with
the tag. For PostgreSQL add DATABASE_URL to its environment and drop the
volume:
services:
sanshain:
image: ghcr.io/paxel/sanshain-service:v2.6.0
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/sanshain
init: true
restart: unless-stopped
Every setting is an environment variable; the complete list is Configuration.
Kubernetes with Kustomize#
deploy/kubernetes holds base manifests for kubectl apply -k:
| File | What it creates |
|---|---|
configmap.yaml |
sanshain-config: BIND_ADDRESS, RUST_LOG, the pool
sizes, cleanup interval, log buffer, INITIAL_ADMIN_USERNAME and friends, with a
commented EXTRA_CA_CERTS_DIR.
|
secret.yaml |
sanshain-secrets: DATABASE_URL and
INITIAL_ADMIN_PASSWORD, base64-encoded, both commented out in the template. Add
the ones you set. A key left out, or set to an empty value, counts as unset: the image's
SQLite default under /data, and a random initial admin password printed to the
log on the first start.
|
pvc.yaml |
A 1 Gi ReadWriteOnce claim mounted at /data for SQLite. |
deployment.yaml |
One replica of ghcr.io/paxel/sanshain-service:v2.6.0, envFrom the
ConfigMap and the Secret, UID 1000, 100m/128Mi requests and
500m/512Mi limits, liveness on /health and readiness
on /ready every 10 s.
|
service.yaml, ingress.yaml |
A ClusterIP service and an Ingress with TLS; set your host and TLS secret. |
backup-cronjob-sqlite.yaml, backup-cronjob-postgres.yaml |
Commented examples, not in kustomization.yaml. See
Backup and restore.
|
kubectl create namespace sanshain
echo -n "postgres://user:pass@db:5432/sanshain" | base64 # into secret.yaml
kubectl apply -k deploy/kubernetes -n sanshain
The Helm chart#
deploy/helm/sanshain, chart version 2.6.0, appVersion 2.6.0. The image
tag defaults to v plus the appVersion.
helm install sanshain deploy/helm/sanshain -n sanshain --create-namespace
With the default values the pod gets SQLite on the data volume and a random initial admin password
in the log: the Secret renders DATABASE_URL and INITIAL_ADMIN_PASSWORD
only when the value is non-empty, and the service treats an empty value as unset. To choose the
password:
# my-values.yaml
secrets:
databaseUrl: "sqlite:/data/sanshain.db?mode=rwc"
initialAdminPassword: "…"
With an external PostgreSQL:
# my-values.yaml
persistence:
enabled: false
secrets:
databaseUrl: "postgres://user:password@postgres-host:5432/sanshain"
initialAdminPassword: "…"
helm install sanshain deploy/helm/sanshain -f my-values.yaml -n sanshain
The values that matter, with their defaults:
| Value | Default | Effect |
|---|---|---|
replicaCount |
1 |
Pods. Stay at 1 on SQLite; see Scaling. |
image.repository, image.tag |
ghcr.io/paxel/sanshain-service, "" |
An empty tag means v<appVersion>. |
service.type, service.port |
ClusterIP, 80 |
The service in front of container port 3000. |
ingress.enabled |
false |
Renders an Ingress; ingress.hosts (default sanshain.local),
ingress.tls, ingress.className and
ingress.annotations shape it.
|
resources |
100m/128Mi requests, 500m/512Mi limits
|
The container's resources. |
autoscaling.enabled |
false |
Renders an autoscaling/v2 HorizontalPodAutoscaler on the Deployment from
minReplicas (1), maxReplicas (10),
targetCPUUtilizationPercentage (80) and, when set,
targetMemoryUtilizationPercentage; the Deployment then omits
replicas:. PostgreSQL only, see Scaling.
|
config.* |
the service defaults |
Rendered into the ConfigMap:
rustLog (info,sanshain_service=debug), bindAddress,
maxPostgresConnections, maxSqliteConnections,
sqliteBusyTimeoutMs, cleanupIntervalSecs,
csrfMaxAgeHours, logBufferSize,
specUpdatedChannelSize, captureLogFilter, staticDir,
initialAdminUsername, extraCaCertsDir.
|
secrets.databaseUrl |
"" |
DATABASE_URL in the chart's Secret, rendered only when non-empty. Empty keeps
the image's sqlite:/data/sanshain.db?mode=rwc on the data volume; set it for
PostgreSQL.
|
secrets.initialAdminPassword |
"" |
INITIAL_ADMIN_PASSWORD, rendered only when non-empty. Empty, the service prints
a random password to the log on the first start.
|
persistence.enabled |
true |
Mounts a claim at persistence.mountPath (/data).
persistence.createPvc (true) creates it with
persistence.size (1Gi), storageClass and
accessMode (ReadWriteOnce); with createPvc: false the
template mounts persistence.existingClaim, which you add yourself. Required for
SQLite to survive a pod restart; set enabled: false on PostgreSQL.
|
extraVolumes, extraVolumeMounts |
[] |
Anything else to mount, typically a CA bundle for config.extraCaCertsDir; the
values file carries a commented example.
|
extraEnv |
[] |
Any other environment variable of the
service, as a list of name/value or name/valueFrom
entries appended to the container's env; see below.
|
podSecurityContext, securityContext |
fsGroup: 1000; runAsNonRoot, UID 1000 |
Applied to the pod and the container, and to the backup job. |
backup.* |
enabled: false |
The backup CronJob: schedule ("0 2 * * *"), engine,
retain (7), existingClaim,
storage.size (5Gi),
tokenSecret.name/key, images.curl,
images.postgres. Documented in
Backup and restore.
|
The chart's deployment uses /health for both the liveness and the readiness probe;
the Kustomize deployment uses /ready for readiness. If you want traffic withheld
while the database is unreachable, point the chart's readiness probe at /ready with a
post-renderer or a fork of the template. tests/helm_render_test.rs renders the chart
in CI, so the values above are checked against the templates on every build.
The ConfigMap template renders the config.* keys above and the Secret the two
secrets.* keys. Every other variable the service reads (PUBLIC_BASE_URL,
SANSHAIN_ROOT_USERS, the OTEL_*, MCP_* and
WEBHOOK_* settings, CACHE_MEMORY_MB, LOG_FORMAT,
LDAP_OPERATION_TIMEOUT_SECS, …) goes into extraEnv, which is appended to
the container's env as given, so a valueFrom reference to your own Secret works too:
# my-values.yaml
extraEnv:
- name: PUBLIC_BASE_URL
value: https://sanshain.corp
- name: SANSHAIN_ROOT_USERS
value: root,breakglass
- name: OTEL_EXPORTER_OTLP_HEADERS
valueFrom:
secretKeyRef:
name: otel-headers
key: headers
helm uninstall sanshain -n sanshain
SQLite or PostgreSQL#
The backend is chosen from DATABASE_URL: postgres:// or
postgresql:// selects PostgreSQL, anything else (or the variable unset) is SQLite.
Nothing else differs in configuration; both run the same migrations.
-
SQLite opens the file in WAL mode with
synchronous=NORMAL, a pool ofMAX_SQLITE_CONNECTIONS(5) and a busy timeout ofSQLITE_BUSY_TIMEOUT_MS(5000). Writers queue on the busy timeout. Keep the file on a volume that only one process mounts. -
PostgreSQL uses a pool of
MAX_POSTGRES_CONNECTIONS(20) per replica. It is the backend for more than one replica and for backups with the database's own tooling.
The admin page shows the backend and the connection URL with the password removed (Database Configuration); switching means changing the variable and restarting. There is no migration between the two engines.
Scaling#
The default deployments run one replica because the SQLite file sits on a
ReadWriteOnce claim and concurrent writers would corrupt it. To run more, switch to
PostgreSQL, disable the data volume and raise replicas (Kustomize) or
replicaCount (Helm).
Each replica keeps its own in-memory caches (CACHE_MEMORY_MB). A write invalidates
the caches of the replica that handled it at once; every other replica sees it within
10 seconds, when its entries expire. A build that provides a
version on one replica and requires it on another
inside that window can be answered with the previous content, "version not found" or the previous
report. Two ways to close the window: session affinity on the ingress for the API routes
(/provide*, /require*, /require-bundle,
/catalog/*, /producers/*), so one build talks to one replica, or a 10 s
pause in the pipeline between the provide and the first require.
The webhook worker runs in every replica; on PostgreSQL the outbox
claim (FOR UPDATE SKIP LOCKED) keeps two replicas from sending one delivery twice.
Health, readiness and version#
| Route | Answer |
|---|---|
GET /health |
200 with the text OK, without touching the database. Liveness: a
database outage must not restart the pod.
|
GET /ready |
Pings the database. 200 {"status":"ready"}, or
503 {"status":"unavailable"} while it is unreachable.
|
GET /version |
{"version":"2.6.0","instance_id":"…"}; the id is INSTANCE_ID.
|
All three are unauthenticated. Metrics are in Observability.
Upgrades and migrations#
- Take a backup (Backup and restore).
- Read the changelog for the release; the "What changed" callout on each chapter of this track names what an operator configures.
- Change the image tag and roll. Migrations run on the first start of the new version, before the listener binds; on PostgreSQL with several replicas the migrator takes an advisory lock, so the first pod runs them and the others wait.
Migrations are forward-only: a database touched by a newer version cannot be used by an older
binary. Rolling back means restoring the backup from before the upgrade. Since 2.4.0 the start
also rewrites, once, every stored spec text whose line endings or BOM predate ADR-0008; like a
migration it is fatal on failure (ERROR: Can't normalize stored spec text); success
is recorded in a setting, a failed run is repeated on the next start. Environment changes need a
restart; certificates under EXTRA_CA_CERTS_DIR are read once at startup.
Security#
-
Both deployments run the container as UID 1000 with
runAsNonRoot: trueandfsGroup: 1000(Helm:podSecurityContext,securityContext, also applied to the backup job). Neither setsreadOnlyRootFilesystemor drops capabilities; add them in your own overlay if your policy requires them, the service writes only underDATABASE_URL's directory. -
TLS terminates at the Ingress: the Kustomize
ingress.yamlreferences thesanshain-tlsSecret, the chart'singress.tlslist takes yours. The service itself speaks plain HTTP on port 3000, so the session cookie and the API tokens must not cross the cluster boundary unencrypted. -
/metrics,/health,/ready,/versionand the free validatorPOST /validateare unauthenticated; restrict/metricsat the ingress when the instance is public. - The Secret holds the database URL with its password and the initial admin password; the database holds the LDAP bind password, the OIDC client secret and the webhook secrets (Backup and restore).
Outbound TLS trust#
LDAPS connections verify the directory's certificate against the platform bundle plus, when set,
every certificate under EXTRA_CA_CERTS_DIR. Mount the corporate CA
(extraVolumes/extraVolumeMounts in Helm, the commented
ca-certs volume in Kustomize) and point the variable at the mount path. Details and
the startup failures are in
Configuration.
Verifying a release#
The release workflow (.github/workflows/release.yml) produces three supply-chain
artifacts for every tag:
- The image is signed keyless with cosign through the workflow's GitHub OIDC identity on the public Sigstore instance; the certificate names the workflow.
-
A CycloneDX SBOM of the image, attached to the image as a cosign attestation and to the GitHub
release as
sanshain-service-<tag>.cdx.json. -
A CycloneDX SBOM of the dependencies compiled into the binaries (from
Cargo.lockandpackage-lock.json), on the release assanshain-service-<tag>-source.cdx.json.
Verify the signature (the tag or the @sha256:… digest you deploy):
cosign verify ghcr.io/paxel/sanshain-service:v2.6.0 \
--certificate-identity-regexp '^https://github.com/paxel/sanshain-service/.github/workflows/release.yml@refs/tags/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Verify the SBOM attestation the same way; the attested SBOM is the predicate of the
printed in-toto statement, so this also extracts it without the GitHub release:
cosign verify-attestation --type cyclonedx ghcr.io/paxel/sanshain-service:v2.6.0 \
--certificate-identity-regexp '^https://github.com/paxel/sanshain-service/.github/workflows/release.yml@refs/tags/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
| jq -r .payload | base64 -d | jq .predicate > sanshain-service.cdx.json
A signature whose certificate names any other identity or issuer fails, so an admission policy can
require exactly this one. Only a release built from a pushed tag verifies with the regexp above: a
run started by hand through workflow_dispatch is signed with
@refs/heads/<branch> instead.
The weekly security workflow (.github/workflows/security.yml, Mondays 05:17 UTC)
scans the latest released image with grype. It fails only on high or critical findings that have a
fix, and attaches two reports as workflow artifacts: the fixable findings and the full list.
Checked against v2.6.1.