feat: add Go workload deployment artifacts and split Ingress routing

This commit is contained in:
2026-08-14 09:50:47 +08:00
parent ca019abb14
commit 4a8f2d56e2
13 changed files with 452 additions and 26 deletions

View File

@@ -0,0 +1,52 @@
# Task: Build first-deployment artifacts for the Go stack
## Identity
- Task ID: 20260814-go-deploy-artifacts-2a5f8e1d
- Mode: Feature
- Branch: main
- Worktree: /Users/brother7/Documents/AI/NianAIGC
- Base commit: ca019abb14859f7413214b2b6a11a8a07cebf5f7
- Owner: dsh
- Status: Ready for Integration
## Scope
- Build the deployment artifacts for the first production deployment of the ADR-003 split topology: Go container image build, ACK Deployment/Service for the Go API workload, split-path Ingress routing, and workload configuration updates.
- Human direction (2026-08-14): first production deployment runs Next.js (pages/static/SSR) plus Go (backend paths) directly; no Node Worker and no migration Job pod in production.
## Intent And Constraints
- Production Web workload holds no RDS/provider credentials and only needs the shared session secret for local cookie verification (its middleware already verifies the cookie with HMAC locally, no database access).
- Go workload runs non-root, root filesystem read-only, with writable emptyDir mounts for runtime/logs/settings/temp.
- Keep the manifest contract checker (`check-ack-manifests.mjs`) authoritative for the new topology.
- Keep deprecated manifests (worker, migration Job) on disk with header comments.
## Outcome
- Added `backend/Dockerfile` (multi-stage `golang:1.21-alpine` → `alpine:3.20`, static `CGO_ENABLED=0` build, non-root uid/gid 10001, ca-certificates + tzdata) and `backend/.dockerignore`.
- Added `deploy/ack/go-api.yaml`: Deployment `zhinian-go-api` (1 replica, `/api/ready` database-aware readiness, runAsNonRoot, readOnlyRootFilesystem, RDS CA + data + tmp volumes, bootstrap/provider/webhook secrets, embedded WorkerLoop config) plus ClusterIP Service `zhinian-go-api:8080`.
- Added `zhinian-go-runtime` ConfigMap to `deploy/ack/configmap.yaml` with the full Go runtime surface (DB/TLS settings, auth, embedded worker, billing, runtime/log/settings dirs).
- Updated `deploy/ack/web.yaml`: removed RDS credentials, worker token, and RDS CA mount; readiness switched to process-level `/api/health` (Web is database-free in production).
- Updated `deploy/ack/ingress.yaml`: `/api`, `/uploads`, `/generated-results` → `zhinian-go-api`; `/api/internal/worker` still → selectorless deny Service; pages/static → Web.
- Updated `deploy/ack/secrets.example.yaml` with `zhinian-go-db`, `zhinian-go-bootstrap`, `zhinian-go-providers`, `zhinian-go-secrets` and notes that the session secret must match across workloads; marked local-only secrets.
- Marked `deploy/ack/worker.yaml` deprecated (production uses the embedded WorkerLoop).
- Updated `scripts/check-ack-manifests.mjs` assertions for the split topology (Web database-free, Go API non-root/database-aware readiness/bootstrap config, Ingress split routing).
- Updated `docs/DEPLOYMENT.md`, `README.zh-CN.md`, and `README.md` deployment/tech-stack guidance (Go image build command, apply order, split topology).
## Verification
- `npm run deploy:check` — PASS (9 manifest files, new assertions).
- All `deploy/ack/*.yaml` parse as valid multi-document YAML.
- `CGO_ENABLED=0 go build ./cmd/zhinian-api` — PASS.
- Docker image build itself must run on a machine with Docker; the Dockerfile is static-checked against the build steps in `scripts/run-go-command.mjs` conventions.
## Follow-ups
- Build and push the `zhinian-go-api` image, then validate the manifests with `kubectl apply --dry-run=server` on the target ACK cluster.
- Validate the full stack against non-production RDS/OSS/provider/Webhook dependencies before the first rollout.
- Decide whether to delete the deprecated `worker.yaml` and `migration-job.yaml`.
## Promotion Candidates
- Canonical memory (current-state Next Steps, commitments) still lists "build the Go workload deployment artifacts" as open; promote completion there in the next integration pass.

View File

@@ -314,9 +314,12 @@ ZHINIAN_INTERNAL_WORKER_TOKEN=change-me-worker-token
- 分镜提示词与 `@素材` 引用编排
- 火山 Visual 签名 canonical request
- Next.js 生产构建
- Go 后端全量测试与构建(`backend/`,契约 fixture 同步)
```bash
npm test
npm run build
npm run health
npm run go:test
npm run go:build
```

View File

@@ -29,11 +29,12 @@
## 技术栈
- Next.js 15
- Next.js 15(前端:页面、静态资源、SSR)
- Go 1.21(生产后端:独占 `/api`、`/uploads`、`/generated-results`,内嵌 WorkerLoop,见 `backend/` 与 [`docs/DEPLOYMENT.md`](./docs/DEPLOYMENT.md))
- React 19
- TypeScript
- GSAP
- PostgreSQL(生产)/本地 JSON(开发)
- PostgreSQL(生产)/本地 JSON(开发)
- Aliyun OSS 可选
- Vitest

4
backend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
zhinian-api
coverage.out
*.test
.runtime/

22
backend/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1
# Build context: the repository backend/ directory.
# docker build -f backend/Dockerfile -t REGISTRY/PROJECT/zhinian-go-api:TAG backend/
FROM golang:1.21-alpine AS build
WORKDIR /src
ENV CGO_ENABLED=0 GOOS=linux
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -trimpath -ldflags="-s -w" -o /out/zhinian-api ./cmd/zhinian-api
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S -g 10001 zhinian \
&& adduser -S -D -H -u 10001 -G zhinian zhinian \
&& mkdir -p /var/lib/zhinian \
&& chown -R zhinian:zhinian /var/lib/zhinian
COPY --from=build /out/zhinian-api /usr/local/bin/zhinian-api
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["zhinian-api"]

View File

@@ -16,3 +16,31 @@ data:
DATABASE_IDLE_TIMEOUT_MS: "30000"
DATABASE_STATEMENT_TIMEOUT_MS: "30000"
DATABASE_APPLICATION_NAME: zhinian-web
---
# Go API runtime settings. Provider endpoints/models use code defaults unless
# overridden here; credentials always come from zhinian-go-providers.
apiVersion: v1
kind: ConfigMap
metadata:
name: zhinian-go-runtime
namespace: zhinian
data:
NODE_ENV: production
GO_BACKEND_PORT: "8080"
ZHINIAN_DATA_BACKEND: postgres
ZHINIAN_AUTH_REQUIRED: "true"
ZHINIAN_AUTH_COOKIE_SECURE: "true"
ZHINIAN_PUBLIC_BASE_URL: https://REPLACE_WITH_PUBLIC_HOST
DATABASE_SSL_MODE: verify-full
DATABASE_CA_CERT_PATH: /etc/zhinian/rds/ca.pem
DATABASE_POOL_MAX: "10"
DATABASE_CONNECTION_TIMEOUT_MS: "5000"
DATABASE_IDLE_TIMEOUT_MS: "30000"
DATABASE_STATEMENT_TIMEOUT_MS: "30000"
DATABASE_APPLICATION_NAME: zhinian-go-api
ZHINIAN_GO_EMBEDDED_WORKER: "true"
ZHINIAN_WORKER_ID: zhinian-go-api-embedded
ZHINIAN_BILLING_REQUIRED: "1"
ZHINIAN_RUNTIME_DIR: /var/lib/zhinian/runtime
ZHINIAN_LOG_DIR: /var/lib/zhinian/logs
ZHINIAN_SETTINGS_FILE: /var/lib/zhinian/settings.env

207
deploy/ack/go-api.yaml Normal file
View File

@@ -0,0 +1,207 @@
# Go API workload: owns /api, /uploads, and /generated-results from the first
# production deployment. The embedded WorkerLoop replaces the Node Worker; do
# not deploy zhinian-worker in production.
apiVersion: apps/v1
kind: Deployment
metadata:
name: zhinian-go-api
namespace: zhinian
spec:
# Keep one replica until generated assets live in shared OSS storage and the
# RDS/provider connection budget is measured for more.
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: zhinian
app.kubernetes.io/component: go-api
template:
metadata:
labels:
app.kubernetes.io/name: zhinian
app.kubernetes.io/component: go-api
spec:
automountServiceAccountToken: false
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: go-api
image: REGISTRY/PROJECT/zhinian-go-api:REPLACE_TAG
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
envFrom:
- configMapRef:
name: zhinian-go-runtime
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: zhinian-go-db
key: DATABASE_URL
# The session secret must be the same value Next.js uses so the
# frontend middleware and the Go backend verify the same cookies.
- name: ZHINIAN_AUTH_SESSION_SECRET
valueFrom:
secretKeyRef:
name: zhinian-web-auth
key: ZHINIAN_AUTH_SESSION_SECRET
- name: ZHINIAN_BOOTSTRAP_ADMIN_PHONE
valueFrom:
secretKeyRef:
name: zhinian-go-bootstrap
key: ZHINIAN_BOOTSTRAP_ADMIN_PHONE
- name: ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: zhinian-go-bootstrap
key: ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD
- name: ZHINIAN_BOOTSTRAP_ADMIN_NAME
valueFrom:
secretKeyRef:
name: zhinian-go-bootstrap
key: ZHINIAN_BOOTSTRAP_ADMIN_NAME
optional: true
- name: VOLCENGINE_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: zhinian-go-providers
key: VOLCENGINE_ACCESS_KEY_ID
optional: true
- name: VOLCENGINE_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: zhinian-go-providers
key: VOLCENGINE_SECRET_ACCESS_KEY
optional: true
- name: JIMENG_IMAGE_GENERATE_46_REQ_KEY
valueFrom:
secretKeyRef:
name: zhinian-go-providers
key: JIMENG_IMAGE_GENERATE_46_REQ_KEY
optional: true
- name: EVOLINK_API_KEY
valueFrom:
secretKeyRef:
name: zhinian-go-providers
key: EVOLINK_API_KEY
optional: true
- name: SEEDANCE_API_KEY
valueFrom:
secretKeyRef:
name: zhinian-go-providers
key: SEEDANCE_API_KEY
optional: true
- name: BAILIAN_API_KEY
valueFrom:
secretKeyRef:
name: zhinian-go-providers
key: BAILIAN_API_KEY
optional: true
- name: DASHSCOPE_API_KEY
valueFrom:
secretKeyRef:
name: zhinian-go-providers
key: DASHSCOPE_API_KEY
optional: true
- name: ALI_OSS_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: zhinian-go-providers
key: ALI_OSS_ACCESS_KEY_ID
optional: true
- name: ALI_OSS_ACCESS_KEY_SECRET
valueFrom:
secretKeyRef:
name: zhinian-go-providers
key: ALI_OSS_ACCESS_KEY_SECRET
optional: true
- name: ZHINIAN_WEBHOOK_SECRET
valueFrom:
secretKeyRef:
name: zhinian-go-secrets
key: ZHINIAN_WEBHOOK_SECRET
- name: ZHINIAN_API_KEYS
valueFrom:
secretKeyRef:
name: zhinian-go-secrets
key: ZHINIAN_API_KEYS
optional: true
- name: ZHINIAN_INTERNAL_WORKER_TOKEN
valueFrom:
secretKeyRef:
name: zhinian-go-secrets
key: ZHINIAN_INTERNAL_WORKER_TOKEN
optional: true
volumeMounts:
- name: rds-ca
mountPath: /etc/zhinian/rds
readOnly: true
- name: data
mountPath: /var/lib/zhinian
- name: tmp
mountPath: /tmp
startupProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 5
failureThreshold: 24
readinessProbe:
httpGet:
path: /api/ready
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 20
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
readOnlyRootFilesystem: true
volumes:
- name: rds-ca
secret:
secretName: zhinian-rds-ca
- name: data
emptyDir: {}
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: zhinian-go-api
namespace: zhinian
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: zhinian
app.kubernetes.io/component: go-api
ports:
- name: http
port: 8080
targetPort: 8080

View File

@@ -12,7 +12,7 @@ spec:
http:
paths:
# Longest-prefix matching sends public internal-API traffic to the
# selectorless deny Service instead of the Web workload.
# selectorless deny Service instead of any workload.
- path: /api/internal/worker
pathType: Prefix
backend:
@@ -20,6 +20,30 @@ spec:
name: zhinian-public-deny
port:
number: 80
# Backend paths belong to the Go API workload from the first
# production deployment.
- path: /api
pathType: Prefix
backend:
service:
name: zhinian-go-api
port:
number: 8080
- path: /uploads
pathType: Prefix
backend:
service:
name: zhinian-go-api
port:
number: 8080
- path: /generated-results
pathType: Prefix
backend:
service:
name: zhinian-go-api
port:
number: 8080
# Pages and static assets stay with Next.js.
- path: /
pathType: Prefix
backend:

View File

@@ -1,4 +1,6 @@
# Example only. Replace every placeholder and keep the populated file out of Git.
# Secrets marked "(local development only)" are not referenced by the first
# production deployment; keep or drop them as your local workflow requires.
apiVersion: v1
kind: Secret
metadata:
@@ -6,6 +8,7 @@ metadata:
namespace: zhinian
type: Opaque
stringData:
# Local development only: the production Web workload holds no RDS credentials.
DATABASE_URL: postgresql://APP_USER:APP_PASSWORD@RDS_INTERNAL_HOST:5432/APP_DATABASE
---
apiVersion: v1
@@ -15,6 +18,7 @@ metadata:
namespace: zhinian
type: Opaque
stringData:
# Manual schema execution only: run database/migrations/*.sql with this role.
DATABASE_URL: postgresql://MIGRATION_USER:MIGRATION_PASSWORD@RDS_INTERNAL_HOST:5432/APP_DATABASE
---
apiVersion: v1
@@ -24,6 +28,7 @@ metadata:
namespace: zhinian
type: Opaque
stringData:
# Local development only: the Node Worker is not deployed in production.
ZHINIAN_INTERNAL_WORKER_TOKEN: REPLACE_WITH_A_LONG_RANDOM_VALUE
---
apiVersion: v1
@@ -33,4 +38,60 @@ metadata:
namespace: zhinian
type: Opaque
stringData:
# Shared by the Next.js middleware and the Go backend so both verify the
# same session cookies. Keep identical across workloads.
ZHINIAN_AUTH_SESSION_SECRET: REPLACE_WITH_A_DIFFERENT_LONG_RANDOM_VALUE
---
apiVersion: v1
kind: Secret
metadata:
name: zhinian-go-db
namespace: zhinian
type: Opaque
stringData:
# Application role (least privilege): grants applied manually after the SQL.
DATABASE_URL: postgresql://APP_USER:APP_PASSWORD@RDS_INTERNAL_HOST:5432/APP_DATABASE
---
apiVersion: v1
kind: Secret
metadata:
name: zhinian-go-bootstrap
namespace: zhinian
type: Opaque
stringData:
# First super administrator, created once at Go startup when no super
# administrator exists. Password must be at least 8 characters.
ZHINIAN_BOOTSTRAP_ADMIN_PHONE: REPLACE_WITH_ADMIN_PHONE
ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD: REPLACE_WITH_STRONG_PASSWORD
ZHINIAN_BOOTSTRAP_ADMIN_NAME: 平台超级管理员
---
apiVersion: v1
kind: Secret
metadata:
name: zhinian-go-providers
namespace: zhinian
type: Opaque
stringData:
# Provider credentials. Delete keys for providers you do not use; the Go
# workload tolerates missing optional keys and fails closed when an enabled
# engine has no credentials.
VOLCENGINE_ACCESS_KEY_ID: REPLACE_OR_REMOVE
VOLCENGINE_SECRET_ACCESS_KEY: REPLACE_OR_REMOVE
JIMENG_IMAGE_GENERATE_46_REQ_KEY: REPLACE_OR_REMOVE
EVOLINK_API_KEY: REPLACE_OR_REMOVE
SEEDANCE_API_KEY: REPLACE_OR_REMOVE
BAILIAN_API_KEY: REPLACE_OR_REMOVE
DASHSCOPE_API_KEY: REPLACE_OR_REMOVE
ALI_OSS_ACCESS_KEY_ID: REPLACE_OR_REMOVE
ALI_OSS_ACCESS_KEY_SECRET: REPLACE_OR_REMOVE
---
apiVersion: v1
kind: Secret
metadata:
name: zhinian-go-secrets
namespace: zhinian
type: Opaque
stringData:
ZHINIAN_WEBHOOK_SECRET: REPLACE_WITH_A_LONG_RANDOM_VALUE
ZHINIAN_API_KEYS: REPLACE_WITH_PUBLIC_API_KEYS
ZHINIAN_INTERNAL_WORKER_TOKEN: REPLACE_OR_REMOVE

View File

@@ -1,3 +1,8 @@
# Next.js frontend workload for the first production deployment: serves pages,
# static assets, and SSR only. All /api, /uploads, and /generated-results
# traffic is routed to zhinian-go-api by the Ingress, so this workload holds no
# RDS credentials and needs only the shared session secret for local cookie
# verification in the middleware.
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -10,7 +15,7 @@ spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # Budget RDS connections for (replicas + maxSurge) * DATABASE_POOL_MAX.
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
@@ -37,25 +42,13 @@ spec:
- configMapRef:
name: zhinian-runtime
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: zhinian-web-db
key: DATABASE_URL
- name: ZHINIAN_INTERNAL_WORKER_TOKEN
valueFrom:
secretKeyRef:
name: zhinian-worker-auth
key: ZHINIAN_INTERNAL_WORKER_TOKEN
# The session secret must be the same value the Go backend uses so
# the frontend middleware and the Go backend verify the same cookies.
- name: ZHINIAN_AUTH_SESSION_SECRET
valueFrom:
secretKeyRef:
name: zhinian-web-auth
key: ZHINIAN_AUTH_SESSION_SECRET
volumeMounts:
- name: rds-ca
mountPath: /etc/zhinian/rds
readOnly: true
startupProbe:
httpGet:
path: /api/health
@@ -64,7 +57,7 @@ spec:
failureThreshold: 24
readinessProbe:
httpGet:
path: /api/ready
path: /api/health
port: http
periodSeconds: 10
timeoutSeconds: 5
@@ -89,7 +82,3 @@ spec:
drop: ["ALL"]
# The current image runs as root. Add a fixed non-root image user and
# verify /app/.runtime permissions before enabling runAsNonRoot.
volumes:
- name: rds-ca
secret:
secretName: zhinian-rds-ca

View File

@@ -1,3 +1,6 @@
# DEPRECATED (2026-08-14): production deploys the Go API workload with the
# embedded WorkerLoop (ZHINIAN_GO_EMBEDDED_WORKER=true). The Node Worker is
# local-development only; do not apply this manifest in production.
apiVersion: apps/v1
kind: Deployment
metadata:

View File

@@ -15,10 +15,25 @@ kubectl -n zhinian create secret generic zhinian-rds-ca \
--from-file=ca.pem=./path/to/downloaded-rds-ca.pem
kubectl apply -f deploy/ack/configmap.yaml
kubectl apply -f deploy/ack/secrets.example.yaml # 仅作模板;先替换全部占位值
kubectl apply -f deploy/ack/web.yaml -f deploy/ack/worker.yaml \
kubectl apply -f deploy/ack/web.yaml -f deploy/ack/go-api.yaml \
-f deploy/ack/service.yaml -f deploy/ack/ingress.yaml
```
生产拓扑为 ADR-003 双工作负载:Next.js 只服务页面/静态资源/SSR(不持有任何
RDS/服务商凭据,仅共享会话密钥);Go 工作负载 `zhinian-go-api` 独占
`/api`、`/uploads`、`/generated-results`,内嵌 WorkerLoop(生产**不部署**
Node Worker,`worker.yaml` 已弃用保留)。Ingress 按路径分流:页面 → Web,
后端路径 → Go,`/api/internal/worker` → 无端点 deny Service。
Go 镜像构建:
```bash
docker build -f backend/Dockerfile -t REGISTRY/PROJECT/zhinian-go-api:TAG backend/
```
Go 首次启动时从 `zhinian-go-bootstrap` Secret 读取
`ZHINIAN_BOOTSTRAP_ADMIN_*`,仅当不存在任何超级管理员时创建一次。
数据库 schema 由部署负责人在发布前手工执行,**不部署迁移 Job Pod**(清单
`deploy/ack/migration-job.yaml` 已弃用保留):使用迁移角色账号依次执行
`database/migrations/0001_initial_schema.sql`、`0002_generation_lifecycle_fencing.sql`,

View File

@@ -17,11 +17,28 @@ assert(migrationJob.includes("secretName: zhinian-rds-ca"), "migration Job must
const web = read("web.yaml");
assert(/^\s*replicas: 1\s*$/m.test(web), "Web must default to one replica until object storage is shared");
assert(web.includes("path: /api/ready"), "Web must use database-aware readiness");
assert(web.includes("path: /api/health"), "Web must use process-level readiness (it is database-free in production)");
assert(!web.includes("zhinian-web-db"), "Web must not hold RDS credentials in production");
assert(!web.includes("rds-ca"), "Web must not mount the RDS CA in production");
const goApi = read("go-api.yaml");
assert(/^\s*replicas: 1\s*$/m.test(goApi), "Go API must default to one replica until object storage is shared");
assert(goApi.includes("path: /api/ready"), "Go API must use database-aware readiness");
assert(goApi.includes("runAsNonRoot: true"), "Go API must run as a non-root user");
assert(goApi.includes("name: zhinian-go-runtime"), "Go API must consume the Go runtime ConfigMap");
assert(goApi.includes("name: zhinian-go-bootstrap"), "Go API must receive bootstrap administrator credentials");
assert(goApi.includes("secretName: zhinian-rds-ca"), "Go API must mount the RDS CA");
const configMap = read("configmap.yaml");
assert(configMap.includes("ZHINIAN_GO_EMBEDDED_WORKER: \"true\""), "Go runtime ConfigMap must embed the WorkerLoop");
const ingress = read("ingress.yaml");
assert(ingress.includes("path: /api/internal/worker"), "Ingress must intercept the internal worker prefix");
assert(ingress.includes("name: zhinian-public-deny"), "Ingress must route the internal prefix away from Web");
assert(ingress.includes("name: zhinian-public-deny"), "Ingress must route the internal prefix away from the workloads");
assert(ingress.includes("name: zhinian-go-api"), "Ingress must route backend paths to the Go API Service");
assert(ingress.includes("path: /uploads"), "Ingress must route /uploads to the Go API");
assert(ingress.includes("path: /generated-results"), "Ingress must route /generated-results to the Go API");
assert(ingress.includes("name: zhinian-web"), "Ingress must route pages/static paths to Web");
const service = read("service.yaml");
assert(service.includes("name: zhinian-public-deny"), "selectorless deny Service is required");