Serve the Zoo sites from files, not from pods
CI/CD / render (push) Waiting to run

zoo.vote and zoo.industries are static bundles a pod was re-serving on every
request. Both move to the site plane: the `public/` tree of the image digest
each Deployment ran is synced to s3://hanzo-sites/zoo/vote and
s3://hanzo-sites/zoo/industries, and each file now declares the Middleware that
reads the prefix together with the route that names it. zoo-industries' server
generated /public/config.json at boot from three SPA_* variables; the plane has
no boot, so that file travels in the prefix carrying the values the pod served.

zoo-ngo is deleted rather than converted. zoo.ngo and www.zoo.ngo already route
to s3://hanzo-sites/zoo/ngo, and that prefix holds a newer build than the pod
was serving to nobody -- no route in this namespace named it. A site declared
twice is a site that goes stale in one of them.

Co-authored-by: Hanzo AI <[email protected]>
This commit is contained in:
2026-09-08 15:02:38 -07:00
co-authored by Hanzo AI
parent 928163040e
commit 0412e26125
3 changed files with 95 additions and 199 deletions
+47 -70
View File
@@ -1,72 +1,49 @@
# Generated from the live zoo-mainnet/zoo-industries Deployment on do-sfo3-zoo-k8s
# by hack/live2values.py. Live is the source: a repo of hand-applied
# manifests has unknown currency, the running fleet does not.
replicas: 2
selectorLabels:
app.kubernetes.io/name: zoo-industries
containerName: spa
image:
repository: ghcr.io/zooai/zoo-industries
tag: 1.0.5-amd64
digest: sha256:7546cebac2b23f3131ca3b888c42db9c55f5e2b66b69bf0a9263dce1445dc26c
pullPolicy: IfNotPresent
partOf: zoo
component: marketing-site
labels:
app.kubernetes.io/cluster: zoo-k8s
app.kubernetes.io/env: mainnet
env:
- name: SPA_API_HOST
value: https://api.zoo.ngo
- name: SPA_IAM_HOST
value: https://zoo.id
- name: SPA_ENV
value: prod
resources:
limits:
cpu: 500m
memory: 256Mi
requests:
cpu: 50m
memory: 64Mi
livenessProbe:
failureThreshold: 3
httpGet:
path: /health
port: 3000
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 15
successThreshold: 1
timeoutSeconds: 1
readinessProbe:
failureThreshold: 3
httpGet:
path: /health
port: 3000
scheme: HTTP
initialDelaySeconds: 2
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 1
ports:
- containerPort: 3000
name: http
servicePort: 80
imagePullSecrets:
- name: ghcr-secret
- name: hanzo
- name: ghcr-credentials-hanzodev
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
topologySpread:
enabled: false
lifecycle: null
terminationGracePeriodSeconds: 30
# Enforcement switch, read by the zoo-fleet ApplicationSet's templatePatch --
# not by the chart. selfHeal returns a hand-edited object to what is written
# here; prune stays off, so nothing is ever deleted.
# zoo.industries is files: one Middleware that serves them, one route that names
# it, and the endpoint-less Service the route schema requires. No image, so the
# chart renders no Deployment -- a static site is not a pod.
#
# The objects are the `public/` tree of
# ghcr.io/zooai/zoo-industries@sha256:7546cebac2b23f3131ca3b888c42db9c55f5e2b66b69bf0a9263dce1445dc26c,
# the digest the Deployment ran, plus one file it used to generate. That server
# wrote /public/config.json at boot from SPA_API_HOST, SPA_IAM_HOST and SPA_ENV;
# the plane has no boot, so the same three values travel as an object in the
# prefix, byte-for-byte what the running pod served:
#
# {"apiHost":"https://api.zoo.ngo","env":"prod","iamHost":"https://zoo.id","v":1}
#
# A rebuild ships by syncing `public/` to this prefix and keeping that file.
#
# The Deployment carried no route at all, so nothing has reached this bundle from
# inside the cluster. The route below is the first one.
cd:
automated: true
ports:
- name: http
containerPort: 80
middlewares:
- name: industries
spec:
staticFiles:
root: s3://hanzo-sites/zoo/industries
# Next `output: export` writes real HTML per route -- 56 files, one each
# for /about, /capabilities and the rest. A fallback would answer
# 200-with-the-homepage for a missing chunk, which reads exactly like a
# good visit, so a miss stays a miss.
spaMode: false
indexFiles:
- index.html
routes:
- name: industries
spec:
entryPoints:
- websecure
routes:
- match: Host(`zoo.industries`) || Host(`www.zoo.industries`)
kind: Rule
priority: 100
middlewares:
- name: industries
services:
- name: zoo-industries
port: 80
tls: {}
-41
View File
@@ -1,41 +0,0 @@
# Generated from the live zoo-mainnet/zoo-ngo Deployment on do-sfo3-zoo-k8s
# by hack/live2values.py. Live is the source: a repo of hand-applied
# manifests has unknown currency, the running fleet does not.
replicas: 2
selectorLabels:
app: zoo-ngo
containerName: ngo
image:
repository: ghcr.io/zooai/ngo
tag: main-amd64
digest: sha256:7905525af45fd3955d391c0696c286bb7a1f1a8399efe7aaa09a0b3747a1cf83
pullPolicy: IfNotPresent
partOf: zoo
labels:
app.kubernetes.io/cluster: zoo-k8s
app.kubernetes.io/env: mainnet
resources:
limits:
cpu: 100m
memory: 128Mi
requests:
cpu: 50m
memory: 64Mi
ports:
- containerPort: 80
imagePullSecrets:
- name: ghcr-zooai
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
topologySpread:
enabled: false
lifecycle: null
terminationGracePeriodSeconds: 30
defaultReadinessProbe: false
# Enforcement switch, read by the zoo-fleet ApplicationSet's templatePatch --
# not by the chart. selfHeal returns a hand-edited object to what is written
# here; prune stays off, so nothing is ever deleted.
cd:
automated: true
+48 -88
View File
@@ -1,91 +1,51 @@
# zoo.vote — the governance interface, on Zoo's own fleet.
# zoo.vote is files: one Middleware that serves them, one route that names it,
# and the endpoint-less Service the route schema requires. No image, so the
# chart renders no Deployment -- a static site is not a pod.
#
# It was declared in lux/universe deploy/zoo-mainnet/zoo-vote.yaml, which
# lux-fleet reads. zoo-fleet reads THIS repository (hanzo/universe
# infra/k8s/hanzo-cd/project-zoo.yaml, generator path deploy/*/*.yaml), so a
# Zoo service declared in Lux's universe is a second Application for one
# workload. Declared here, deleted there.
replicas: 1
selectorLabels:
app: zoo-vote
containerName: spa
image:
repository: ghcr.io/zooai/vote
# ONE source: luxfi/vote. Its `.hanzo/workflows/image.yml` builds that tree
# three times — lux, zoo, hanzo — each into its own org's registry, so this
# image is Zoo's and is pulled with Zoo's credential. The tenant is a
# build-arg (VITE_VOTE_HOME=zoo) and the site is settled at runtime by the
# host, so the bundle here is Zoo's by both.
#
# TODO: pin the first tag that workflow publishes, with
# `deploy/pin.sh zoo-mainnet/zoo-vote <version>` so the digest is written
# beside it. The tag below predates the workflow: it is what the live
# Deployment is running, kept because naming a tag the registry does not have
# takes zoo.vote down. It could not be verified from a workstation — ghcr.io
# refuses an anonymous pull token for this repository — so it is a tag and
# not a digest.
tag: votesguard-zoo2
pullPolicy: Always
partOf: zoo
labels:
app.kubernetes.io/cluster: zoo-k8s
app.kubernetes.io/env: mainnet
resources:
limits:
cpu: 200m
memory: 256Mi
requests:
cpu: 50m
memory: 32Mi
livenessProbe:
failureThreshold: 3
httpGet:
path: /health
port: 3000
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 30
successThreshold: 1
timeoutSeconds: 1
readinessProbe:
failureThreshold: 3
httpGet:
path: /health
port: 3000
scheme: HTTP
initialDelaySeconds: 2
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 1
ports:
- containerPort: 3000
name: http
servicePort: 80
imagePullSecrets:
- name: ghcr-zooai
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
topologySpread:
enabled: false
lifecycle: null
terminationGracePeriodSeconds: 30
# The host is what tells the bundle it is Zoo — every label of the hostname is
# read against the tenants it knows — so the names routed here are part of the
# app's behaviour and not only of its reachability. www included, because the
# app resolves it to the same tenant and a visitor who types it should not land
# on a certificate error.
ingress:
enabled: true
hosts:
- zoo.vote
- www.zoo.vote
tls: true
className: ingress
# Enforcement switch, read by the zoo-fleet ApplicationSet's templatePatch --
# not by the chart. selfHeal returns a hand-edited object to what is written
# here; prune stays off, so nothing is ever deleted.
# The bundle is Zoo's own build. luxfi/vote is built once per tenant and the Zoo
# build carries VITE_VOTE_HOME=zoo, so the objects in this prefix are the
# `public/` tree of
# ghcr.io/zooai/vote@sha256:22c778f5b14a61116680bf4d1bcfb34d9499099af306df71ef8bae120a65e131,
# the digest the Deployment ran. That server read no SPA_* variable and so wrote
# nothing at boot -- the prefix is the whole site, with nothing a pod added.
#
# The tag it named, votesguard-zoo2, is mutable and no lane publishes here yet. A
# rebuild ships by syncing `public/` to this prefix, the way every other site on
# the plane does.
cd:
automated: true
ports:
- name: http
containerPort: 80
middlewares:
- name: vote
spec:
staticFiles:
root: s3://hanzo-sites/zoo/vote
# Vite emits ONE index.html for the whole app and the client router
# resolves every path, so a deep link has no file behind it and a miss must
# answer with the shell. Contrast zoo-industries, a file per route.
spaMode: true
spaIndex: index.html
indexFiles:
- index.html
routes:
- name: vote
spec:
entryPoints:
- websecure
routes:
- match: Host(`zoo.vote`) || Host(`www.zoo.vote`)
kind: Rule
# Above the Ingress this replaces, so the route is authoritative from the
# moment it exists rather than racing an object prune does not remove.
priority: 100
middlewares:
- name: vote
services:
- name: zoo-vote
port: 80
# The same default certificate the Ingress served: its cert-manager
# Certificate never went Ready, so naming the empty secret would change the
# bytes on the wire for nothing.
tls: {}