refactor: serve static frontend with Go APIs
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
|
||||
const directory = new URL("../deploy/ack/", import.meta.url);
|
||||
const repository = new URL("../", import.meta.url);
|
||||
const files = readdirSync(directory).filter((name) => name.endsWith(".yaml")).sort();
|
||||
|
||||
assert(!files.includes("migration-job.yaml"), "ACK must not ship the removed Node migration Job");
|
||||
assert(!files.includes("worker.yaml"), "ACK must not ship the removed Node worker Deployment");
|
||||
assert(files.includes("namespace.yaml"), "ACK must include the Namespace manifest required by first deployment");
|
||||
|
||||
for (const file of files) {
|
||||
const text = readFileSync(new URL(file, directory), "utf8");
|
||||
assert(text.includes("apiVersion:"), `${file}: missing apiVersion`);
|
||||
@@ -10,41 +15,45 @@ for (const file of files) {
|
||||
assert(!text.includes("server-snippet"), `${file}: must not depend on disabled snippet annotations`);
|
||||
}
|
||||
|
||||
const migrationJob = read("migration-job.yaml");
|
||||
assert(migrationJob.includes("name: ZHINIAN_DATA_BACKEND\n value: postgres"), "migration Job must select postgres");
|
||||
assert(migrationJob.includes("name: DATABASE_APP_ROLE"), "migration Job must provision the Web role");
|
||||
assert(migrationJob.includes("secretName: zhinian-rds-ca"), "migration Job must mount the optional RDS CA used by DATABASE_URL");
|
||||
assert(!migrationJob.includes("DATABASE_SSL_MODE"), "migration Job must keep database TLS inside DATABASE_URL");
|
||||
assert(!migrationJob.includes("DATABASE_CA_CERT_PATH"), "migration Job must keep database TLS inside DATABASE_URL");
|
||||
|
||||
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/health"), "Web must use process-level readiness (it is database-free in production)");
|
||||
assert(web.includes("name: zhinian-runtime"), "Web must consume the Web runtime ConfigMap");
|
||||
assert(/^\s*replicas: 1\s*$/m.test(web), "Web must default to one replica for the first deployment");
|
||||
assert(web.includes("path: /healthz"), "Web must probe the static Nginx health endpoint");
|
||||
assert(!/^\s*env(?:From)?:\s*$/m.test(web), "Web must not receive runtime environment configuration");
|
||||
assert(!web.includes("secretKeyRef:"), "Web must not receive application secrets");
|
||||
assert(web.includes("runAsNonRoot: true"), "Web must run as a non-root user");
|
||||
assert(web.includes("runAsUser: 101"), "Web must run as the unprivileged Nginx user");
|
||||
assert(web.includes("fsGroup: 101"), "Web temporary volume must be writable by unprivileged Nginx");
|
||||
assert(web.includes("readOnlyRootFilesystem: true"), "Web root filesystem must be read-only");
|
||||
assert(!web.includes("ZHINIAN_DATA_BACKEND"), "Web must not select a database backend in production");
|
||||
assert(!web.includes("DATABASE_URL"), "Web must not receive DATABASE_URL 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");
|
||||
assert(!web.includes("ZHINIAN_AUTH_SESSION_SECRET"), "Web must not receive the Go session signing secret");
|
||||
assert(!web.includes("ZHINIAN_GO_INTERNAL_BASE_URL"), "Web must not receive an internal Go URL");
|
||||
|
||||
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-auth"), "Go API must own the browser session signing Secret");
|
||||
assert(!goApi.includes("name: zhinian-web-auth"), "Go API must not reference the removed Web auth Secret");
|
||||
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 optional RDS CA used by DATABASE_URL");
|
||||
assert(!goApi.includes("DATABASE_SSL_MODE"), "Go API must keep database TLS inside DATABASE_URL");
|
||||
assert(!goApi.includes("DATABASE_CA_CERT_PATH"), "Go API must keep database TLS inside DATABASE_URL");
|
||||
|
||||
const secrets = read("secrets.example.yaml");
|
||||
assert(secrets.includes("name: zhinian-go-auth"), "Example secrets must name Go as the session Secret owner");
|
||||
assert(!secrets.includes("name: zhinian-web-auth"), "Example secrets must not retain the removed Web auth Secret");
|
||||
|
||||
const namespace = read("namespace.yaml");
|
||||
assert(namespace.includes("kind: Namespace"), "namespace.yaml must define a Namespace");
|
||||
assert(namespace.includes("name: zhinian"), "namespace.yaml must create the zhinian Namespace");
|
||||
|
||||
const configMap = read("configmap.yaml");
|
||||
const webRuntime = configMap.split("\n---\n", 1)[0];
|
||||
const expectedInternalBaseUrl = "http://zhinian-go-api:8080";
|
||||
const internalBaseUrl = webRuntime.match(/^\s*ZHINIAN_GO_INTERNAL_BASE_URL:\s*(\S+)\s*$/m)?.[1];
|
||||
assert(internalBaseUrl, "Web runtime ConfigMap must define ZHINIAN_GO_INTERNAL_BASE_URL");
|
||||
assert(internalBaseUrl === expectedInternalBaseUrl, "Web runtime ConfigMap must use the cluster-internal Go API URL");
|
||||
assert(!webRuntime.includes("ZHINIAN_DATA_BACKEND"), "Web runtime ConfigMap must not select a database backend");
|
||||
assert(!webRuntime.includes("DATABASE_URL"), "Web runtime ConfigMap must not carry DATABASE_URL");
|
||||
assert(!webRuntime.includes("rds-ca"), "Web runtime ConfigMap must not carry the RDS CA");
|
||||
assert(!configMap.includes("name: zhinian-runtime"), "Static Web must not have a runtime ConfigMap");
|
||||
assert((configMap.match(/^kind: ConfigMap$/gm) ?? []).length === 1, "Only the Go runtime ConfigMap should remain");
|
||||
assert(configMap.includes("ZHINIAN_GO_EMBEDDED_WORKER: \"true\""), "Go runtime ConfigMap must embed the WorkerLoop");
|
||||
assert(configMap.includes("GO_BACKEND_HOST: 0.0.0.0"), "Go runtime ConfigMap must listen on the Pod interface");
|
||||
assert(!configMap.includes("DATABASE_SSL_MODE"), "ConfigMaps must not carry database TLS settings");
|
||||
@@ -66,17 +75,79 @@ const goService = goApi.match(
|
||||
);
|
||||
assert(goService, "Go API Service must declare an HTTP name, port, and targetPort");
|
||||
const [, serviceName, servicePort, targetPort] = goService;
|
||||
const parsedInternalBaseUrl = new URL(internalBaseUrl);
|
||||
assert(parsedInternalBaseUrl.hostname === serviceName, "Web internal Go URL must use the Go API Service name");
|
||||
assert(parsedInternalBaseUrl.port === servicePort, "Web internal Go URL must use the Go API Service port");
|
||||
assert(serviceName === "zhinian-go-api", "Go API Service name must match the Ingress backend");
|
||||
assert(targetPort === servicePort, "Go API Service targetPort must match its port");
|
||||
|
||||
const nextConfig = readRepository("next.config.ts");
|
||||
assert(nextConfig.includes('output: "export"'), "Next must emit a static export");
|
||||
assert(nextConfig.includes("unoptimized: true"), "Static export must not depend on the Next image optimizer");
|
||||
assert(!nextConfig.includes("serverActions"), "Static frontend must not enable Server Actions");
|
||||
|
||||
const dockerfile = readRepository("Dockerfile");
|
||||
assert(dockerfile.includes("nginxinc/nginx-unprivileged:"), "Web runner must use unprivileged Nginx");
|
||||
assert(dockerfile.includes("/app/out"), "Web runner must copy the static export");
|
||||
assert(dockerfile.includes("ENTRYPOINT []"), "Web runner must skip image entrypoint mutations on a read-only root filesystem");
|
||||
assert(!dockerfile.includes("next start"), "Production Web must not start a Next server");
|
||||
assert(!dockerfile.includes("/app/.next"), "Production Web must not copy Next server artifacts");
|
||||
|
||||
const dockerIgnore = readRepository(".dockerignore");
|
||||
assert(dockerIgnore.includes("!deploy/nginx.conf"), "Docker build context must include the Nginx runtime config");
|
||||
|
||||
const nginx = readRepository("deploy/nginx.conf");
|
||||
assert(nginx.includes("listen 3000;"), "Static Nginx must preserve the Web service port");
|
||||
assert(nginx.includes("location = /healthz"), "Static Nginx must expose /healthz");
|
||||
assert(nginx.includes("api|uploads|generated-results"), "Static Nginx must reject Go-owned paths");
|
||||
assert(nginx.includes("immutable"), "Hashed Next assets must be cached immutably");
|
||||
assert(nginx.includes("no-cache, no-store, must-revalidate"), "HTML navigation must not be cached");
|
||||
|
||||
const packageJson = JSON.parse(readRepository("package.json"));
|
||||
assert(!packageJson.scripts.start, "package scripts must not advertise a Next production server");
|
||||
assert(!packageJson.scripts["start:server"], "package scripts must not advertise a public Next production server");
|
||||
assert(!packageJson.scripts.worker, "package scripts must not advertise the removed Node worker");
|
||||
assert(!packageJson.scripts["worker:once"], "package scripts must not advertise the removed one-shot Node worker");
|
||||
assert(!packageJson.scripts.health, "package scripts must not probe the removed Next API server");
|
||||
|
||||
const deploymentDocs = readRepository("docs/DEPLOYMENT.md");
|
||||
assert(
|
||||
deploymentDocs.includes(
|
||||
"kubectl apply -f deploy/ack/namespace.yaml\n\n" +
|
||||
"kubectl apply --dry-run=server -f deploy/ack/secrets.production.yaml\n" +
|
||||
"kubectl apply --dry-run=server -f deploy/ack/configmap.yaml",
|
||||
),
|
||||
"Deployment docs must create the Namespace before dry-running namespaced Secrets and resources",
|
||||
);
|
||||
assert(
|
||||
deploymentDocs.includes(
|
||||
"kubectl apply -f deploy/ack/secrets.production.yaml\n" +
|
||||
"kubectl apply -f deploy/ack/configmap.yaml",
|
||||
),
|
||||
"Deployment docs must publish Secrets before other namespaced resources",
|
||||
);
|
||||
assert(
|
||||
deploymentDocs.includes("当前 ACK 清单不注入 `ZHINIAN_BOOTSTRAP_ADMIN_NAME`"),
|
||||
"Deployment docs must not claim the optional bootstrap administrator name is injected",
|
||||
);
|
||||
assert(
|
||||
deploymentDocs.includes("Pod 重建或滚动升级同样会永久丢失上传文件和生成结果"),
|
||||
"Deployment docs must disclose emptyDir data loss across Go Pod replacement",
|
||||
);
|
||||
|
||||
const gitIgnore = readRepository(".gitignore");
|
||||
assert(
|
||||
gitIgnore.split("\n").includes("/deploy/ack/secrets.production.yaml"),
|
||||
"The populated production Secret manifest must be ignored with an exact repository-root rule",
|
||||
);
|
||||
|
||||
console.log(`ACK manifest assertions passed (${files.length} files)`);
|
||||
|
||||
function read(file) {
|
||||
return readFileSync(new URL(file, directory), "utf8").replace(/\r\n/g, "\n");
|
||||
}
|
||||
|
||||
function readRepository(file) {
|
||||
return readFileSync(new URL(file, repository), "utf8").replace(/\r\n/g, "\n");
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ console.log(JSON.stringify({
|
||||
appId: 'zhinian-web-studio',
|
||||
packageName: pkg.name,
|
||||
version: pkg.version,
|
||||
runtime: 'Next.js Web',
|
||||
webOnly: true,
|
||||
runtime: 'Static Next.js export on Nginx + Go API',
|
||||
webOnly: false,
|
||||
primaryRoutes: [
|
||||
'/',
|
||||
'/create',
|
||||
|
||||
Reference in New Issue
Block a user