Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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.
212 lines
7.6 KiB
YAML
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 }
|