Written after the service was live, so every command and every number here was
run against the real deployment rather than assumed:
* DEPLOY.md records the things a rebuild needs and git does not hold — the
Redis ACL user (and why +getdel is the one to notice), the GCP Secret
Manager entry, the DNS record, and the three coordinated edits vmalert
needs because it has no ConfigMap auto-discovery.
* It also records two blockers rather than hiding them: Woodpecker is NOT
activated (the token in rdev-credentials returns 401), so pushes do not
deploy yet and the Kaniko Job is the interim path; and the host is
hush.threesix.ai rather than hush.orchard9.ai because orchard9.ai is on
GoDaddy and no GoDaddy credential exists anywhere I can reach.
* OPERATIONS.md is one section per alert, plus the failure modes that are not
alerts — chiefly that "gone" cannot distinguish already-revealed from
expired from LRU-evicted, on purpose, so the operator's default reading of
an unexpected "gone" is that the secret is compromised and should be
rotated.
* scripts/logs.sh and alerts-check.sh verify rather than assert:
alerts-check asks vmalert what it actually loaded AND checks each rule's
series exists, because a rule reading a metric nothing exports can never
fire and looks exactly like a healthy service.
* scripts/smoke.sh is a real client — it generates a key, encrypts, posts only
ciphertext, reveals, decrypts, then asserts the second reveal is 410, that
three GETs did not consume the secret, that missing and malformed ids are
indistinguishable, and that a plaintext field is refused.
install-mcp.sh proves the MCP handshake before writing any config, backs up
mcp.json, and rewrites only hush's entry — a config pointing at a broken server
surfaces as an opaque host-side connect failure, which is worth one extra check
to avoid.
198 lines
7.4 KiB
Markdown
198 lines
7.4 KiB
Markdown
# Deploying hush
|
|
|
|
Live at <https://hush.threesix.ai>, one replica in the `projects` namespace on
|
|
the orchard9 k3s cluster.
|
|
|
|
```
|
|
git push origin main → Gitea webhook → Woodpecker → Kaniko (amd64, in-cluster)
|
|
→ Zot registry → kubectl set image → projects/hush
|
|
```
|
|
|
|
`origin` **must** be Gitea (`git.threesix.ai`); that remote carries the webhook.
|
|
The GitHub mirror is a backup and pushing there deploys nothing.
|
|
|
|
## Never build the image locally
|
|
|
|
Two reasons, both of which cost real time to discover:
|
|
|
|
1. A laptop build on Apple Silicon produces **arm64**; the cluster runs amd64.
|
|
2. `registry.threesix.ai` accepts **only OCI image manifests** — not Docker
|
|
schema2, and not an OCI *index*. `docker push` and `crane push` both fail
|
|
`MANIFEST_INVALID`, and buildx wraps even a single-platform build in an index.
|
|
|
|
Kaniko sidesteps both. If you must build outside the pipeline, use a Job — see
|
|
"Bootstrap" below, which is exactly what the first deploy did.
|
|
|
|
## Dependencies are vendored, deliberately
|
|
|
|
`github.com/orchard9/go-chassis` is a **private** module. Neither the Woodpecker
|
|
test container nor the Kaniko build holds a git credential, so a build that
|
|
resolved dependencies from the network would fail:
|
|
|
|
```
|
|
$ curl https://proxy.golang.org/github.com/orchard9/go-chassis/@v/list
|
|
404 ... could not read Username for 'https://github.com'
|
|
```
|
|
|
|
So `vendor/` is committed and both CI and the Dockerfile run `-mod=vendor` with
|
|
`GOPROXY=off`. `GOPROXY=off` is the important half: it turns "silently fetched
|
|
from somewhere" into a hard failure. `make vendor` is the only way versions
|
|
move, and `make verify` proves the tree still builds with no network.
|
|
|
|
## One-time setup, already done
|
|
|
|
Recorded because it is what a rebuild would need, and none of it is in git.
|
|
|
|
### 1. Redis ACL user
|
|
|
|
hush connects as its own ACL user, scoped to `~hush:*`, on **db 5** (0 and 3 and
|
|
4 are taken by pantheon/rdev, reel, and jit):
|
|
|
|
```bash
|
|
redis-cli ACL SETUSER hush on '>PASSWORD' '~hush:*' resetchannels \
|
|
-@all +ping +set +getdel +incr +pexpire +pttl +select
|
|
redis-cli ACL SAVE # persists to /data/users.acl
|
|
```
|
|
|
|
**`+getdel` is the one to notice.** No other service's ACL user has it, because
|
|
no other service needs an atomic read-and-destroy. Omit it and creates keep
|
|
working while every reveal fails `NOPERM` — a service that accepts secrets and
|
|
cannot deliver them. `+incr +pexpire +pttl` are the rate limiter.
|
|
|
|
### 2. The credential
|
|
|
|
A JSON object in GCP Secret Manager, pulled into the namespace by ESO:
|
|
|
|
```bash
|
|
gcloud secrets create k3sf-hush-credentials --project orchard9 \
|
|
--replication-policy=automatic --data-file=- <<< \
|
|
'{"REDIS_URL":"redis://hush:PASSWORD@redis.databases.svc.cluster.local:6379/5"}'
|
|
```
|
|
|
|
`ExternalSecret/hush-credentials` (in `deployments/k8s/hush.yaml`) syncs it to a
|
|
Secret of the same name. Confirm with:
|
|
|
|
```bash
|
|
kubectl -n projects get externalsecret hush-credentials \
|
|
-o jsonpath='{.status.conditions[0].reason}' # want SecretSynced
|
|
```
|
|
|
|
### 3. DNS
|
|
|
|
`hush.threesix.ai` → `208.122.204.172`, A record, **DNS-only** (not proxied),
|
|
TTL 120 — matching every other `*.threesix.ai` service. There is no wildcard on
|
|
the zone, so each host needs its own record. cert-manager then issues TLS over
|
|
HTTP-01 with no DNS credential needed.
|
|
|
|
> `hush.orchard9.ai` was the original intent and is **not** what shipped.
|
|
> `orchard9.ai` is on GoDaddy and no GoDaddy credential exists in rdev, the
|
|
> cluster, or `~/.squiddy-dns`. Moving the host there needs that credential;
|
|
> everything else is a one-line Ingress change plus a new record.
|
|
|
|
### 4. Alert rules
|
|
|
|
vmalert has **no ConfigMap auto-discovery**. Three coordinated edits in
|
|
`orchard9-k3sf`, and missing any one leaves the rules silently absent:
|
|
|
|
1. `observability/hush-alerting-rules.yaml` — the ConfigMap
|
|
2. `observability/kustomization.yaml` — list it under `resources:`
|
|
3. `observability/victoria-metrics.yaml` — vmalert needs a matching
|
|
`-rule=/etc/rules-hush/*.yaml`, `volumeMount` and `volume`
|
|
|
|
`make alerts-check` asks vmalert what it actually loaded, and separately checks
|
|
that every series the rules reference exists — a rule reading a metric nothing
|
|
exports can never fire and looks exactly like a healthy service.
|
|
|
|
## Bootstrap (what the first deploy did)
|
|
|
|
The pipeline's deploy step runs `kubectl set image`, so a Deployment must exist
|
|
first. But the committed image tag cannot be `:latest`: the cluster's
|
|
`stable-controller-images.orchard9.ai` admission policy refuses
|
|
`latest|main|master|dev|edge|canary|nightly|snapshot`, because a floating tag
|
|
cannot pin a rollback.
|
|
|
|
So the manifest carries `:bootstrap`, which is policy-legal and does not exist.
|
|
Apply it, then build once by hand:
|
|
|
|
```bash
|
|
make deploy-manifests # pod sits in ImagePullBackOff — expected
|
|
|
|
SHA=$(git rev-parse --short=8 HEAD)
|
|
kubectl -n projects create job hush-build-$SHA --dry-run=client -o yaml ... # see below
|
|
kubectl -n projects set image deployment/hush hushd=registry.threesix.ai/hush/api:$SHA
|
|
```
|
|
|
|
The build Job, which is what Woodpecker's Kaniko step does by hand:
|
|
|
|
```yaml
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata: { name: hush-build, namespace: projects }
|
|
spec:
|
|
backoffLimit: 1
|
|
ttlSecondsAfterFinished: 3600
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
containers:
|
|
- name: kaniko
|
|
image: gcr.io/kaniko-project/executor:v1.23.2
|
|
args:
|
|
- --context=git://git.threesix.ai/jordan/hush.git#refs/heads/main
|
|
- --dockerfile=Dockerfile
|
|
- --destination=registry.threesix.ai/hush/api:SHA
|
|
- --skip-tls-verify
|
|
- --skip-tls-verify-pull
|
|
- --single-snapshot
|
|
resources:
|
|
requests: { cpu: 500m, memory: 1Gi }
|
|
limits: { cpu: "2", memory: 3Gi }
|
|
```
|
|
|
|
The git context needs no credential because the Gitea repo is public.
|
|
|
|
## Woodpecker is NOT yet activated
|
|
|
|
The repo exists on Gitea and `.woodpecker.yml` is committed, but activation
|
|
failed: the `WOODPECKER_API_TOKEN` in `rdev/rdev-credentials` returns
|
|
`401 User not authorized`.
|
|
|
|
Until a valid token replaces it, **pushes do not deploy** — use the Kaniko Job
|
|
above and `kubectl set image`. To finish it:
|
|
|
|
```bash
|
|
WP=$(curl -s -H "X-API-Key: $RDEV_API_KEY" "$RDEV_API_URL/credentials/WOODPECKER_API_TOKEN" | jq -r '.data.value')
|
|
curl -X POST "https://ci.threesix.ai/api/repos?forge_remote_id=jordan/hush" -H "Authorization: Bearer $WP"
|
|
```
|
|
|
|
A fresh token comes from Woodpecker → User Settings → Token, and belongs back in
|
|
rdev rather than anywhere else.
|
|
|
|
## Rollback
|
|
|
|
Every build is SHA-tagged, so rollback is naming the previous one:
|
|
|
|
```bash
|
|
kubectl -n projects rollout undo deployment/hush
|
|
# or explicitly
|
|
kubectl -n projects set image deployment/hush hushd=registry.threesix.ai/hush/api:<older-sha>
|
|
```
|
|
|
|
Nothing to migrate and no schema: Redis holds only TTL'd ciphertext, and a
|
|
rollback cannot invalidate an outstanding link because the id and the wire
|
|
format are stable.
|
|
|
|
## Verifying a deploy
|
|
|
|
```bash
|
|
make deploy-status # rollout, pods, ingress, certificate
|
|
BASE=https://hush.threesix.ai make smoke # real crypto, create → reveal → gone
|
|
make logs # the lifecycle in VictoriaLogs
|
|
make alerts-check # rules loaded, series present
|
|
```
|
|
|
|
`make smoke` is the one that matters. It encrypts with a real AES-256-GCM key,
|
|
posts only ciphertext, reveals once, decrypts, and then asserts the second
|
|
reveal is `410`, that three `GET`s did not consume the secret, that missing and
|
|
malformed ids are indistinguishable, and that a plaintext field is refused.
|