hush/deployments/k8s/hush.yaml
jx12n d7cd57f330
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
serve the MCP install instructions at /mcp
Using hush from an agent needed a clone and docs/MCP.md. It now needs one
command, and the instructions are served by the deployment itself.

`go install github.com/orchard9/hush/cmd/hush-mcp@latest` is the whole
install: cmd/hush-mcp imports only the standard library, so module graph
pruning never reaches the private go-chassis dependency cmd/hushd needs.
Verified against an empty module cache and the public proxy, then create ->
reveal end to end against production with the resulting binary.

The page carries the per-client configuration for Claude Code, Codex CLI,
Gemini CLI, VS Code, Claude Desktop, Cursor and omp. Each command was run
against the installed client rather than copied from documentation, which is
how the differences on it are there at all: VS Code's wrapper key is
`servers`, not `mcpServers`; gemini defaults to project scope, not user;
Claude Code rejects `--env` immediately before the server name.

The shared browser crypto moves from base.html into templates/crypto.html,
which the two pages that encrypt parse and this one does not. An empty
`{{define}}` cannot replace a non-empty one — text/template reads an empty
body as no definition — so the shell holds the call and the partial holds the
code, and the docs page ships no script at all.

Three things this exposed, fixed here:

- The public Ingress enumerates paths, so a handler without one 404s at the
  edge while working in `make dev`. The Ingress is now its own manifest:
  hush.yaml pins a `:bootstrap` image that does not exist, so re-applying it
  to publish a path would roll the workload onto an unpullable image.
  `make deploy-ingress` applies the route alone.
- release.sh guarded HEAD against `@{upstream}`, which is the GitHub mirror
  here, while Kaniko clones Gitea. A commit pushed to one and not the other
  would have built the previous commit silently. It now fetches and compares
  the branch that actually gets built.
- smoke.sh checks that /mcp serves the install command, so a stale rollout or
  an unexecutable template fails the release instead of being found later.
  Confirmed it fails: against production before this deploy it reported 404.
2026-09-05 14:03:34 -06:00

212 lines
7.6 KiB
YAML

# hush on the orchard9 k3s cluster. Apply this BEFORE the first push, because
# the pipeline's deploy step runs `kubectl set image` and needs a Deployment to
# set it on.
#
# KUBECONFIG=~/.kube/orchard9-k3sf.yaml kubectl apply -f deployments/k8s/
#
# The credential, the workload, the Service and the network boundary. The public
# route is deployments/k8s/ingress.yaml, kept separate because the Deployment
# below pins a `:bootstrap` image that does not exist — re-applying this file to
# publish a new path would roll the workload onto an unpullable image.
---
# The Redis credential. hush connects as its OWN Redis ACL user, scoped to
# `~hush:*` with a minimal command set (+ping +set +getdel +incr +pexpire
# +select), so a bug in hush cannot read or write another tenant's keys and a
# compromise of hush cannot enumerate the keyspace.
#
# `+getdel` is the one that needs saying out loud: no other service's ACL user
# has it, because no other service needs an atomic read-and-destroy. Omitting it
# makes every reveal fail NOPERM while creates keep working — a service that
# accepts secrets and cannot deliver them.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: hush-credentials
namespace: projects
spec:
refreshInterval: 1h
secretStoreRef:
name: gcp-secret-manager
kind: ClusterSecretStore
target:
name: hush-credentials
creationPolicy: Owner
dataFrom:
- extract:
key: k3sf-hush-credentials
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hush
namespace: projects
labels:
app: hush
spec:
# One replica is sufficient and not a bottleneck: every request is a single
# Redis round trip and all state lives in Redis, so this scales horizontally
# whenever it needs to. The rate limiter is already Redis-backed for exactly
# that reason.
replicas: 1
selector:
matchLabels:
app: hush
strategy:
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
app: hush
annotations:
# Metrics scraping. All three are required, and the port must EQUAL a
# declared containerPort as a string — vmagent's relabel config uses
# `keepequal`, so a mismatch silently drops the target with no error
# and no up=0 to alert on.
prometheus.io/scrape: "true"
prometheus.io/port: "18500"
prometheus.io/path: /metrics
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
# The chassis drains in two phases — flip readiness to 503, wait 5s for
# the load balancer to notice, then shut down with a 25s deadline. That is
# 30s, so the grace period must exceed it or the kubelet SIGKILLs mid-drain
# and in-flight reveals are lost.
terminationGracePeriodSeconds: 45
containers:
- name: hushd
# `bootstrap` is a placeholder, replaced by CI's `kubectl set image`
# with the commit-SHA tag on the first push. It is deliberately NOT
# `:latest`: the cluster's stable-controller-images admission policy
# refuses latest/main/master/dev/edge/canary/nightly/snapshot, because
# a floating tag cannot pin a rollback. Until the first pipeline run
# this image does not exist and the pod sits in ImagePullBackOff,
# which is the expected bootstrap state.
image: registry.threesix.ai/hush/api:bootstrap
ports:
- name: http
containerPort: 18500
env:
- name: APP_ENV
value: prod
- name: HUSH_PORT
value: "18500"
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: hush-credentials
key: REDIS_URL
# One Traefik hop sits in front, so the client IP is the last entry
# of X-Forwarded-For. Too high lets a caller spoof their IP past the
# rate limit; too low rate-limits the ingress itself and throttles
# every user together.
- name: HUSH_TRUSTED_PROXY_HOPS
value: "1"
- name: HUSH_RATE_LIMIT_CREATES
value: "30"
- name: HUSH_RATE_LIMIT_WINDOW
value: 10m
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
# Liveness stays 200 during the drain by design, so this restarts the
# pod only when the process is genuinely wedged — never merely because
# it is shutting down or because Redis is down.
livenessProbe:
httpGet: { path: /healthz, port: http }
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
# Readiness pings Redis. A Redis outage takes hush out of the Service
# rather than leaving it to serve 500s from a pod the load balancer
# still trusts.
readinessProbe:
httpGet: { path: /readyz, port: http }
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
resources:
requests: { memory: 32Mi, cpu: 20m }
# 128Mi is generous for a service whose largest allocation is a
# 64 KiB ciphertext, and leaves headroom for Go's GC pacing.
limits: { memory: 128Mi, cpu: 500m }
---
apiVersion: v1
kind: Service
metadata:
name: hush
namespace: projects
labels:
app: hush
spec:
selector:
app: hush
ports:
- name: http
port: 80
targetPort: http
---
# Default-deny both directions, then open exactly what hush needs. Written as
# one policy because the ingress and egress rules are a single statement about
# this pod: Traefik in, DNS and Redis out, nothing else.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: hush
namespace: projects
spec:
podSelector:
matchLabels:
app: hush
policyTypes: [Ingress, Egress]
ingress:
# Public traffic, via Traefik only.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
app.kubernetes.io/name: traefik
ports:
- { protocol: TCP, port: 18500 }
# vmagent scrapes /metrics on the pod IP directly. Without this rule the
# target is discovered and every scrape is connection-refused, which shows
# up as up=0 and fires ScrapeTargetDown rather than as a policy error.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: observability
ports:
- { protocol: TCP, port: 18500 }
egress:
# DNS. Redis is a headless Service, so its name resolves to a pod IP that
# changes when Redis restarts — this must be re-resolvable, not cached.
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
# Redis, and nothing else. hush makes no other outbound connection: it does
# not fetch, does not call a provider, and does not ship its own logs
# (Vector reads stdout off the node filesystem).
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: databases
ports:
- { protocol: TCP, port: 6379 }