@ykdz/template 0.0.22 → 0.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/@ykdz/template-builtin-source/package.json +1 -1
- package/node_modules/@ykdz/template-builtin-source/templates/preset-source.json +7 -0
- package/node_modules/@ykdz/template-builtin-source/templates/vike-app/.github/workflows/check.yml +2 -8
- package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/Dockerfile +45 -9
- package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/pages/index/+Page.telefunc.ts +1 -2
- package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/playwright.config.ts +45 -9
- package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/scripts/check-standalone-deployment.ts +449 -0
- package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/scripts/container-entrypoint.sh +52 -0
- package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/scripts/prepare-database.sh +6 -0
- package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/scripts/run-playwright.ts +95 -25
- package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/server/app.ts +1 -1
- package/node_modules/@ykdz/template-core/dist/dependency-catalog.js +13 -0
- package/node_modules/@ykdz/template-core/dist/generated-scenarios.d.ts +3 -2
- package/node_modules/@ykdz/template-core/dist/generated-scenarios.js +99 -13
- package/node_modules/@ykdz/template-core/dist/module-graph.d.ts +12 -0
- package/node_modules/@ykdz/template-core/dist/module-graph.js +18 -0
- package/node_modules/@ykdz/template-core/dist/pnpm-workspace.yaml +7 -4
- package/node_modules/@ykdz/template-core/dist/preset-projection.d.ts +1 -2
- package/node_modules/@ykdz/template-core/dist/project-github.d.ts +0 -8
- package/node_modules/@ykdz/template-core/dist/project-github.js +11 -4
- package/node_modules/@ykdz/template-core/dist/projection-capabilities.js +15 -13
- package/node_modules/@ykdz/template-core/dist/renderer.js +1 -1
- package/node_modules/@ykdz/template-core/dist/template-boundary-check.js +1 -1
- package/node_modules/@ykdz/template-core/package.json +2 -1
- package/node_modules/@ykdz/template-shared/dist/index.d.ts +6 -6
- package/node_modules/@ykdz/template-shared/package.json +1 -0
- package/package.json +5 -4
|
@@ -65,7 +65,6 @@
|
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@ykdz/template-core": "workspace:*",
|
|
67
67
|
"@ykdz/template-shared": "workspace:*",
|
|
68
|
-
"typescript": "catalog:",
|
|
69
68
|
"valibot": "catalog:"
|
|
70
69
|
},
|
|
71
70
|
"devDependencies": {
|
|
@@ -73,6 +72,7 @@
|
|
|
73
72
|
"@playwright/test": "catalog:",
|
|
74
73
|
"@tailwindcss/vite": "catalog:",
|
|
75
74
|
"@types/node": "catalog:",
|
|
75
|
+
"@typescript/native": "catalog:",
|
|
76
76
|
"@vikejs/hono": "catalog:",
|
|
77
77
|
"@vitejs/plugin-vue": "catalog:",
|
|
78
78
|
"drizzle-kit": "catalog:",
|
|
@@ -223,6 +223,7 @@
|
|
|
223
223
|
"oxfmt",
|
|
224
224
|
"oxlint",
|
|
225
225
|
"oxlint-tsgolint",
|
|
226
|
+
"srvx",
|
|
226
227
|
"tailwindcss",
|
|
227
228
|
"telefunc",
|
|
228
229
|
"typescript",
|
|
@@ -263,6 +264,9 @@
|
|
|
263
264
|
"web/pages/index/+Page.telefunc.ts",
|
|
264
265
|
"web/pages/tailwind.css",
|
|
265
266
|
"web/playwright.config.ts",
|
|
267
|
+
"web/scripts/check-standalone-deployment.ts",
|
|
268
|
+
"web/scripts/container-entrypoint.sh",
|
|
269
|
+
"web/scripts/prepare-database.sh",
|
|
266
270
|
"web/scripts/run-playwright.ts",
|
|
267
271
|
"web/turbo.json",
|
|
268
272
|
"web/server/api.ts",
|
|
@@ -311,6 +315,9 @@
|
|
|
311
315
|
"vike-app/web/Dockerfile",
|
|
312
316
|
"vike-app/web/Dockerfile.dockerignore",
|
|
313
317
|
"vike-app/web/playwright.config.ts",
|
|
318
|
+
"vike-app/web/scripts/check-standalone-deployment.ts",
|
|
319
|
+
"vike-app/web/scripts/container-entrypoint.sh",
|
|
320
|
+
"vike-app/web/scripts/prepare-database.sh",
|
|
314
321
|
"vike-app/web/vite.config.ts",
|
|
315
322
|
"vike-app/web/turbo.json",
|
|
316
323
|
"vike-app/web/vitest.config.ts"
|
package/node_modules/@ykdz/template-builtin-source/templates/vike-app/.github/workflows/check.yml
CHANGED
|
@@ -15,14 +15,8 @@ jobs:
|
|
|
15
15
|
with:
|
|
16
16
|
node-version-file: package.json
|
|
17
17
|
- run: corepack enable
|
|
18
|
+
- uses: docker/setup-buildx-action@v3
|
|
18
19
|
- run: pnpm install
|
|
19
20
|
- run: pnpm --filter ./apps/web exec playwright install --with-deps chromium
|
|
20
21
|
- run: pnpm run check
|
|
21
|
-
|
|
22
|
-
docker-image:
|
|
23
|
-
runs-on: ubuntu-latest
|
|
24
|
-
steps:
|
|
25
|
-
- uses: actions/checkout@v7
|
|
26
|
-
- uses: docker/setup-buildx-action@v3
|
|
27
|
-
- run: docker buildx build --load --file apps/web/Dockerfile --target runtime --tag vike-app:check .
|
|
28
|
-
- run: docker buildx build --load --file apps/web/Dockerfile --target database-preparation --tag vike-app-db-prepare:check .
|
|
22
|
+
- run: pnpm run check:deployment
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# syntax=docker/dockerfile:1.7
|
|
2
2
|
|
|
3
|
+
ARG DEPLOYMENT_BUILD_ID="local"
|
|
4
|
+
|
|
3
5
|
FROM node:{{NODE_VERSION}}-bookworm-slim AS base
|
|
4
6
|
ARG PACKAGE_MANAGER_PIN="{{PACKAGE_MANAGER_PIN}}"
|
|
7
|
+
ENV COREPACK_HOME="/corepack"
|
|
5
8
|
ENV PNPM_HOME="/pnpm"
|
|
6
9
|
ENV PATH="$PNPM_HOME:$PATH"
|
|
7
10
|
WORKDIR /repo
|
|
@@ -25,6 +28,21 @@ COPY --from=pruner /repo/out/json/ ./
|
|
|
25
28
|
RUN pnpm install --offline --frozen-lockfile
|
|
26
29
|
COPY --from=pruner /repo/out/full/ ./
|
|
27
30
|
RUN pnpm --filter ./apps/web run build:run
|
|
31
|
+
RUN pnpm --filter ./apps/web deploy --prod --legacy /runtime-deploy \
|
|
32
|
+
&& rm -rf /runtime-deploy/node_modules/@*/db \
|
|
33
|
+
&& find /runtime-deploy/node_modules/.pnpm -mindepth 1 -maxdepth 1 -type d -name '*@file+packages+db' -exec rm -rf '{}' +
|
|
34
|
+
|
|
35
|
+
FROM node:{{NODE_VERSION}}-bookworm-slim AS application-runtime
|
|
36
|
+
ENV NODE_ENV="production"
|
|
37
|
+
WORKDIR /app
|
|
38
|
+
RUN groupadd --system --gid 1001 nodejs \
|
|
39
|
+
&& useradd --system --uid 1001 --gid nodejs --home-dir /app --shell /usr/sbin/nologin app \
|
|
40
|
+
&& install -d -o app -g nodejs /app /data
|
|
41
|
+
COPY --from=build --chown=app:nodejs /runtime-deploy/node_modules ./node_modules
|
|
42
|
+
COPY --from=build --chown=app:nodejs /repo/apps/web/dist ./dist
|
|
43
|
+
COPY --chmod=755 --from=build /repo/apps/web/scripts/container-entrypoint.sh /usr/local/bin/container-entrypoint
|
|
44
|
+
ENV DATABASE_FILE="/data/app.sqlite"
|
|
45
|
+
USER app
|
|
28
46
|
|
|
29
47
|
FROM base AS database-preparation
|
|
30
48
|
ENV NODE_ENV="production"
|
|
@@ -32,16 +50,34 @@ COPY --from=pruner /repo/out/json/pnpm-lock.yaml ./pnpm-lock.yaml
|
|
|
32
50
|
COPY --from=pruner /repo/out/json/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
|
33
51
|
RUN pnpm fetch
|
|
34
52
|
COPY --from=pruner /repo/out/json/ ./
|
|
35
|
-
RUN pnpm install --offline --frozen-lockfile
|
|
53
|
+
RUN pnpm install --offline --frozen-lockfile --prod=false
|
|
36
54
|
COPY --from=pruner /repo/out/full/ ./
|
|
37
|
-
CMD ["pnpm", "--dir", "packages/db", "run", "db:prepare:deploy"]
|
|
38
|
-
|
|
39
|
-
FROM node:{{NODE_VERSION}}-bookworm-slim AS runtime
|
|
40
|
-
ENV NODE_ENV="production"
|
|
41
|
-
WORKDIR /app
|
|
42
55
|
RUN groupadd --system --gid 1001 nodejs \
|
|
43
|
-
&& useradd --system --uid 1001 --gid nodejs --home-dir /app --shell /usr/sbin/nologin app
|
|
44
|
-
|
|
56
|
+
&& useradd --system --uid 1001 --gid nodejs --home-dir /app --shell /usr/sbin/nologin app \
|
|
57
|
+
&& install -d -o app -g nodejs /app /data
|
|
58
|
+
COPY --chmod=755 --from=pruner /repo/out/full/apps/web/scripts/container-entrypoint.sh /usr/local/bin/container-entrypoint
|
|
59
|
+
COPY --chmod=755 --from=pruner /repo/out/full/apps/web/scripts/prepare-database.sh /usr/local/bin/prepare-database
|
|
60
|
+
ENV DATABASE_FILE="/data/app.sqlite"
|
|
45
61
|
USER app
|
|
62
|
+
|
|
63
|
+
# Final deployment target: standalone
|
|
64
|
+
# One-container deployments prepare the database before startup.
|
|
65
|
+
FROM database-preparation AS standalone
|
|
66
|
+
ARG DEPLOYMENT_BUILD_ID
|
|
67
|
+
LABEL org.opencontainers.image.version="$DEPLOYMENT_BUILD_ID"
|
|
68
|
+
WORKDIR /app
|
|
69
|
+
COPY --from=application-runtime --chown=app:nodejs /app/node_modules ./node_modules
|
|
70
|
+
COPY --from=application-runtime --chown=app:nodejs /app/dist ./dist
|
|
71
|
+
EXPOSE 3000
|
|
72
|
+
ENTRYPOINT ["/usr/local/bin/container-entrypoint"]
|
|
73
|
+
CMD ["prepare-and-start"]
|
|
74
|
+
|
|
75
|
+
# Final deployment target: runtime
|
|
76
|
+
# Start-only image for databases prepared by a matching standalone image.
|
|
77
|
+
FROM application-runtime AS runtime
|
|
78
|
+
ARG DEPLOYMENT_BUILD_ID
|
|
79
|
+
LABEL org.opencontainers.image.version="$DEPLOYMENT_BUILD_ID"
|
|
80
|
+
ENV CONTAINER_CAPABILITY="start-only"
|
|
46
81
|
EXPOSE 3000
|
|
47
|
-
|
|
82
|
+
ENTRYPOINT ["/usr/local/bin/container-entrypoint"]
|
|
83
|
+
CMD ["start-only"]
|
package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/playwright.config.ts
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
import { defineConfig, devices } from "@playwright/test";
|
|
2
2
|
|
|
3
|
+
const externalBaseUrlName = "PLAYWRIGHT_EXTERNAL_BASE_URL";
|
|
4
|
+
|
|
5
|
+
function externalBaseUrl(): string | undefined {
|
|
6
|
+
if (!Object.hasOwn(process.env, externalBaseUrlName)) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const value = process.env[externalBaseUrlName]?.trim();
|
|
11
|
+
if (!value) {
|
|
12
|
+
throw new Error(`${externalBaseUrlName} must be a non-empty HTTP(S) URL`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let url: URL;
|
|
16
|
+
try {
|
|
17
|
+
url = new URL(value);
|
|
18
|
+
} catch {
|
|
19
|
+
throw new Error(`${externalBaseUrlName} must be a valid HTTP(S) URL`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
23
|
+
throw new Error(`${externalBaseUrlName} must use HTTP or HTTPS`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return url.toString();
|
|
27
|
+
}
|
|
28
|
+
|
|
3
29
|
function requiredPort(name: string): number {
|
|
4
30
|
return Number(requiredEnv(name));
|
|
5
31
|
}
|
|
@@ -14,9 +40,14 @@ function requiredEnv(name: string): string {
|
|
|
14
40
|
return value;
|
|
15
41
|
}
|
|
16
42
|
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
43
|
+
const externalServiceUrl = externalBaseUrl();
|
|
44
|
+
const previewPort = externalServiceUrl
|
|
45
|
+
? undefined
|
|
46
|
+
: requiredPort("PLAYWRIGHT_WEB_PORT");
|
|
47
|
+
const previewUrl = externalServiceUrl ?? `http://127.0.0.1:${previewPort}`;
|
|
48
|
+
const databaseFile = externalServiceUrl
|
|
49
|
+
? undefined
|
|
50
|
+
: requiredEnv("DATABASE_FILE");
|
|
20
51
|
|
|
21
52
|
function shellValue(value: string): string {
|
|
22
53
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
@@ -27,13 +58,18 @@ export default defineConfig({
|
|
|
27
58
|
timeout: 60_000,
|
|
28
59
|
use: {
|
|
29
60
|
baseURL: previewUrl,
|
|
30
|
-
trace: "on-
|
|
31
|
-
},
|
|
32
|
-
webServer: {
|
|
33
|
-
command: `DATABASE_FILE=${shellValue(databaseFile)} pnpm --dir ../../packages/db run db:prepare:test && PORT=${previewPort} node dist/server/index.mjs`,
|
|
34
|
-
reuseExistingServer: !process.env.CI,
|
|
35
|
-
url: previewUrl,
|
|
61
|
+
trace: "retain-on-failure",
|
|
36
62
|
},
|
|
63
|
+
...(externalServiceUrl
|
|
64
|
+
? {}
|
|
65
|
+
: {
|
|
66
|
+
webServer: {
|
|
67
|
+
command: `DATABASE_FILE=${shellValue(databaseFile!)} pnpm --dir ../../packages/db run db:prepare:test && PORT=${previewPort} node dist/server/index.mjs`,
|
|
68
|
+
reuseExistingServer: !process.env.CI,
|
|
69
|
+
timeout: 60_000,
|
|
70
|
+
url: previewUrl,
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
37
73
|
projects: [
|
|
38
74
|
{
|
|
39
75
|
name: "chromium",
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const webRoot = path.resolve(import.meta.dirname, "..");
|
|
5
|
+
const repositoryRoot = path.resolve(webRoot, "../..");
|
|
6
|
+
const uniqueId = `${process.pid}-${Date.now()}`;
|
|
7
|
+
const standaloneImage = `vike-deployment-check-standalone:${uniqueId}`;
|
|
8
|
+
const runtimeImage = `vike-deployment-check-runtime:${uniqueId}`;
|
|
9
|
+
const containers = {
|
|
10
|
+
standalone: `vike-deployment-check-standalone-${uniqueId}`,
|
|
11
|
+
preparation: `vike-deployment-check-preparation-${uniqueId}`,
|
|
12
|
+
runtime: `vike-deployment-check-runtime-${uniqueId}`,
|
|
13
|
+
unprepared: `vike-deployment-check-unprepared-${uniqueId}`,
|
|
14
|
+
} as const;
|
|
15
|
+
const volumes = {
|
|
16
|
+
standalone: `vike-deployment-check-standalone-${uniqueId}`,
|
|
17
|
+
prepared: `vike-deployment-check-prepared-${uniqueId}`,
|
|
18
|
+
unprepared: `vike-deployment-check-unprepared-${uniqueId}`,
|
|
19
|
+
} as const;
|
|
20
|
+
const abortController = new AbortController();
|
|
21
|
+
const forwardedSignals = ["SIGINT", "SIGTERM"] as const;
|
|
22
|
+
const signalHandlers = new Map<NodeJS.Signals, () => void>();
|
|
23
|
+
const defaultReadinessTimeoutMs = 60_000;
|
|
24
|
+
|
|
25
|
+
interface CommandResult {
|
|
26
|
+
readonly stderr: string;
|
|
27
|
+
readonly stdout: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface CommandOptions {
|
|
31
|
+
readonly cwd?: string;
|
|
32
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
33
|
+
readonly signal?: AbortSignal;
|
|
34
|
+
readonly timeout: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type DeploymentMode =
|
|
38
|
+
| "standalone"
|
|
39
|
+
| "external-preparation"
|
|
40
|
+
| "runtime"
|
|
41
|
+
| "fresh-runtime";
|
|
42
|
+
|
|
43
|
+
async function deploymentPhase<T>(
|
|
44
|
+
mode: DeploymentMode,
|
|
45
|
+
phase: string,
|
|
46
|
+
operation: () => Promise<T>,
|
|
47
|
+
): Promise<T> {
|
|
48
|
+
try {
|
|
49
|
+
return await operation();
|
|
50
|
+
} catch (error: unknown) {
|
|
51
|
+
throw new Error(`Deployment mode ${mode} failed during ${phase}.`, {
|
|
52
|
+
cause: error,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function commandDisplay(command: string, args: readonly string[]): string {
|
|
58
|
+
return [command, ...args].map((part) => JSON.stringify(part)).join(" ");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function runCommand(
|
|
62
|
+
command: string,
|
|
63
|
+
args: readonly string[],
|
|
64
|
+
options: CommandOptions,
|
|
65
|
+
): Promise<CommandResult> {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const child = spawn(command, args, {
|
|
68
|
+
cwd: options.cwd,
|
|
69
|
+
env: options.env,
|
|
70
|
+
signal: options.signal,
|
|
71
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
72
|
+
});
|
|
73
|
+
let stderr = "";
|
|
74
|
+
let stdout = "";
|
|
75
|
+
let timedOut = false;
|
|
76
|
+
let settled = false;
|
|
77
|
+
child.stdout.setEncoding("utf8");
|
|
78
|
+
child.stderr.setEncoding("utf8");
|
|
79
|
+
child.stdout.on("data", (chunk: string) => {
|
|
80
|
+
stdout += chunk;
|
|
81
|
+
});
|
|
82
|
+
child.stderr.on("data", (chunk: string) => {
|
|
83
|
+
stderr += chunk;
|
|
84
|
+
});
|
|
85
|
+
const timeout = setTimeout(() => {
|
|
86
|
+
timedOut = true;
|
|
87
|
+
child.kill("SIGKILL");
|
|
88
|
+
}, options.timeout);
|
|
89
|
+
const finish = (error?: Error): void => {
|
|
90
|
+
if (settled) return;
|
|
91
|
+
settled = true;
|
|
92
|
+
clearTimeout(timeout);
|
|
93
|
+
if (error === undefined) {
|
|
94
|
+
resolve({ stderr, stdout });
|
|
95
|
+
} else {
|
|
96
|
+
reject(Object.assign(error, { stderr, stdout }));
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
child.once("error", (error) => finish(error));
|
|
100
|
+
child.once("close", (code, signal) => {
|
|
101
|
+
if (code === 0 && !timedOut) {
|
|
102
|
+
finish();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
finish(
|
|
106
|
+
Object.assign(
|
|
107
|
+
new Error(
|
|
108
|
+
`${commandDisplay(command, args)} ${
|
|
109
|
+
timedOut
|
|
110
|
+
? `timed out after ${options.timeout}ms`
|
|
111
|
+
: `exited with code ${String(code)}${signal === null ? "" : ` from ${signal}`}`
|
|
112
|
+
}\nstdout:\n${stdout}\nstderr:\n${stderr}`,
|
|
113
|
+
),
|
|
114
|
+
{ code, signal },
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readinessTimeoutMs(): number {
|
|
122
|
+
const configured = process.env.STANDALONE_DEPLOYMENT_READINESS_TIMEOUT_MS;
|
|
123
|
+
if (configured === undefined) {
|
|
124
|
+
return defaultReadinessTimeoutMs;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const timeout = Number(configured);
|
|
128
|
+
if (!Number.isSafeInteger(timeout) || timeout <= 0) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
"STANDALONE_DEPLOYMENT_READINESS_TIMEOUT_MS must be a positive integer",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return timeout;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function dockerWithTimeout(
|
|
137
|
+
timeout: number,
|
|
138
|
+
...args: string[]
|
|
139
|
+
): Promise<string> {
|
|
140
|
+
const { stdout } = await runCommand("docker", args, {
|
|
141
|
+
cwd: repositoryRoot,
|
|
142
|
+
signal: abortController.signal,
|
|
143
|
+
timeout,
|
|
144
|
+
});
|
|
145
|
+
return stdout.trim();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function docker(...args: string[]): Promise<string> {
|
|
149
|
+
return dockerWithTimeout(600_000, ...args);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function cleanup(): Promise<void> {
|
|
153
|
+
const cleanupCommands = [
|
|
154
|
+
...Object.values(containers).map((name) => ["rm", "--force", name]),
|
|
155
|
+
...Object.values(volumes).map((name) => ["volume", "rm", "--force", name]),
|
|
156
|
+
["image", "rm", "--force", runtimeImage],
|
|
157
|
+
["image", "rm", "--force", standaloneImage],
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
for (const args of cleanupCommands) {
|
|
161
|
+
try {
|
|
162
|
+
await runCommand("docker", args, {
|
|
163
|
+
cwd: repositoryRoot,
|
|
164
|
+
timeout: 30_000,
|
|
165
|
+
});
|
|
166
|
+
} catch {
|
|
167
|
+
// A resource may not have been created yet.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function containerLogs(container: string): Promise<string> {
|
|
173
|
+
try {
|
|
174
|
+
const { stderr, stdout } = await runCommand("docker", ["logs", container], {
|
|
175
|
+
cwd: repositoryRoot,
|
|
176
|
+
signal: AbortSignal.timeout(10_000),
|
|
177
|
+
timeout: 10_000,
|
|
178
|
+
});
|
|
179
|
+
return [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return `Container logs unavailable: ${String(error)}`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function deploymentLogs(): Promise<string> {
|
|
186
|
+
const logs = await Promise.all(
|
|
187
|
+
Object.entries(containers).map(
|
|
188
|
+
async ([phase, container]) =>
|
|
189
|
+
`${phase} container logs:\n${await containerLogs(container)}`,
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
return logs.join("\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function publishedBaseUrl(container: string): Promise<string> {
|
|
196
|
+
const portOutput = await dockerWithTimeout(
|
|
197
|
+
10_000,
|
|
198
|
+
"port",
|
|
199
|
+
container,
|
|
200
|
+
"3000/tcp",
|
|
201
|
+
);
|
|
202
|
+
const match = /:(\d+)$/u.exec(portOutput.split("\n")[0] ?? "");
|
|
203
|
+
if (!match?.[1]) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Could not determine the published web port: ${portOutput}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return `http://127.0.0.1:${match[1]}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function waitForReady(
|
|
212
|
+
phase: "Standalone" | "Runtime",
|
|
213
|
+
container: string,
|
|
214
|
+
baseUrl: string,
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
const timeout = readinessTimeoutMs();
|
|
217
|
+
const deadline = Date.now() + timeout;
|
|
218
|
+
while (Date.now() < deadline) {
|
|
219
|
+
try {
|
|
220
|
+
const response = await fetch(baseUrl, {
|
|
221
|
+
signal: AbortSignal.timeout(2_000),
|
|
222
|
+
});
|
|
223
|
+
if (response.ok) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
// The container may still be starting the server.
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const running = await dockerWithTimeout(
|
|
231
|
+
5_000,
|
|
232
|
+
"inspect",
|
|
233
|
+
"--format={{.State.Running}}",
|
|
234
|
+
container,
|
|
235
|
+
);
|
|
236
|
+
if (running !== "true") {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`${phase} container exited before readiness.\n${await containerLogs(container)}`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
throw new Error(
|
|
245
|
+
`${phase} container was not ready within ${timeout}ms.\n${await containerLogs(container)}`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function assertProcessIdentity(
|
|
250
|
+
phase: "Standalone" | "Runtime",
|
|
251
|
+
container: string,
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
await docker(
|
|
254
|
+
"exec",
|
|
255
|
+
container,
|
|
256
|
+
"node",
|
|
257
|
+
"--input-type=module",
|
|
258
|
+
"--eval",
|
|
259
|
+
`import { statSync } from "node:fs";
|
|
260
|
+
import { DatabaseSync } from "node:sqlite";
|
|
261
|
+
const expectedIdentity = 1001;
|
|
262
|
+
if (process.getuid() !== expectedIdentity || process.getgid() !== expectedIdentity) {
|
|
263
|
+
throw new Error(\`${phase} process identity must be 1001:1001, received \${process.getuid()}:\${process.getgid()}\`);
|
|
264
|
+
}
|
|
265
|
+
const databaseStat = statSync(process.env.DATABASE_FILE);
|
|
266
|
+
if (databaseStat.uid !== expectedIdentity || databaseStat.gid !== expectedIdentity) {
|
|
267
|
+
throw new Error(\`Database ownership must be 1001:1001, received \${databaseStat.uid}:\${databaseStat.gid}\`);
|
|
268
|
+
}`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function assertEmptyDatabase(container: string): Promise<void> {
|
|
273
|
+
await docker(
|
|
274
|
+
"exec",
|
|
275
|
+
container,
|
|
276
|
+
"node",
|
|
277
|
+
"--input-type=module",
|
|
278
|
+
"--eval",
|
|
279
|
+
`import { DatabaseSync } from "node:sqlite";
|
|
280
|
+
const db = new DatabaseSync(process.env.DATABASE_FILE);
|
|
281
|
+
const row = db.prepare("select count(*) as count from todos").get();
|
|
282
|
+
if (row.count !== 0) throw new Error(\`Fresh deployment must contain zero TODOs, received \${row.count}\`);`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function runPlaywright(baseUrl: string): Promise<void> {
|
|
287
|
+
await runCommand("pnpm", ["--dir", "apps/web", "run", "test:e2e:run"], {
|
|
288
|
+
cwd: repositoryRoot,
|
|
289
|
+
env: {
|
|
290
|
+
...process.env,
|
|
291
|
+
PLAYWRIGHT_EXTERNAL_BASE_URL: baseUrl,
|
|
292
|
+
},
|
|
293
|
+
signal: abortController.signal,
|
|
294
|
+
timeout: 120_000,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function runStartedDeployment(
|
|
299
|
+
mode: "standalone" | "runtime",
|
|
300
|
+
readinessLabel: "Standalone" | "Runtime",
|
|
301
|
+
container: string,
|
|
302
|
+
volume: string,
|
|
303
|
+
image: string,
|
|
304
|
+
): Promise<void> {
|
|
305
|
+
await deploymentPhase(mode, "start", async () =>
|
|
306
|
+
docker(
|
|
307
|
+
"run",
|
|
308
|
+
"--detach",
|
|
309
|
+
"--name",
|
|
310
|
+
container,
|
|
311
|
+
"--publish",
|
|
312
|
+
"127.0.0.1::3000",
|
|
313
|
+
"--volume",
|
|
314
|
+
`${volume}:/data`,
|
|
315
|
+
image,
|
|
316
|
+
),
|
|
317
|
+
);
|
|
318
|
+
const baseUrl = await deploymentPhase(mode, "start", async () =>
|
|
319
|
+
publishedBaseUrl(container),
|
|
320
|
+
);
|
|
321
|
+
await deploymentPhase(mode, "readiness", async () =>
|
|
322
|
+
waitForReady(readinessLabel, container, baseUrl),
|
|
323
|
+
);
|
|
324
|
+
await deploymentPhase(mode, "identity", async () =>
|
|
325
|
+
assertProcessIdentity(readinessLabel, container),
|
|
326
|
+
);
|
|
327
|
+
await deploymentPhase(mode, "empty-database", async () =>
|
|
328
|
+
assertEmptyDatabase(container),
|
|
329
|
+
);
|
|
330
|
+
await deploymentPhase(mode, "playwright", async () => runPlaywright(baseUrl));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function assertUnpreparedRuntimeFails(): Promise<void> {
|
|
334
|
+
try {
|
|
335
|
+
await dockerWithTimeout(
|
|
336
|
+
30_000,
|
|
337
|
+
"run",
|
|
338
|
+
"--name",
|
|
339
|
+
containers.unprepared,
|
|
340
|
+
"--volume",
|
|
341
|
+
`${volumes.unprepared}:/data`,
|
|
342
|
+
runtimeImage,
|
|
343
|
+
);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
const failure = error as {
|
|
346
|
+
code?: unknown;
|
|
347
|
+
stderr?: string;
|
|
348
|
+
stdout?: string;
|
|
349
|
+
};
|
|
350
|
+
const output = `${failure.stdout ?? ""}\n${failure.stderr ?? ""}`;
|
|
351
|
+
if (typeof failure.code === "number" && failure.code !== 0) {
|
|
352
|
+
if (!output.includes("Database is not ready")) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`Unprepared runtime did not report database readiness failure.\n${output}\n${await containerLogs(containers.unprepared)}`,
|
|
355
|
+
{ cause: error },
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
throw new Error("Unprepared runtime started successfully");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (const signal of forwardedSignals) {
|
|
367
|
+
const handler = (): void => {
|
|
368
|
+
abortController.abort(new Error(`Received ${signal}`));
|
|
369
|
+
};
|
|
370
|
+
signalHandlers.set(signal, handler);
|
|
371
|
+
process.once(signal, handler);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
await deploymentPhase("standalone", "build", async () =>
|
|
376
|
+
docker(
|
|
377
|
+
"build",
|
|
378
|
+
"--file",
|
|
379
|
+
"apps/web/Dockerfile",
|
|
380
|
+
"--target",
|
|
381
|
+
"standalone",
|
|
382
|
+
"--build-arg",
|
|
383
|
+
`DEPLOYMENT_BUILD_ID=${uniqueId}`,
|
|
384
|
+
"--tag",
|
|
385
|
+
standaloneImage,
|
|
386
|
+
".",
|
|
387
|
+
),
|
|
388
|
+
);
|
|
389
|
+
await deploymentPhase("runtime", "build", async () =>
|
|
390
|
+
docker(
|
|
391
|
+
"build",
|
|
392
|
+
"--file",
|
|
393
|
+
"apps/web/Dockerfile",
|
|
394
|
+
"--target",
|
|
395
|
+
"runtime",
|
|
396
|
+
"--build-arg",
|
|
397
|
+
`DEPLOYMENT_BUILD_ID=${uniqueId}`,
|
|
398
|
+
"--tag",
|
|
399
|
+
runtimeImage,
|
|
400
|
+
".",
|
|
401
|
+
),
|
|
402
|
+
);
|
|
403
|
+
for (const volume of Object.values(volumes)) {
|
|
404
|
+
await docker("volume", "create", volume);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
await runStartedDeployment(
|
|
408
|
+
"standalone",
|
|
409
|
+
"Standalone",
|
|
410
|
+
containers.standalone,
|
|
411
|
+
volumes.standalone,
|
|
412
|
+
standaloneImage,
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
await deploymentPhase("external-preparation", "prepare", async () =>
|
|
416
|
+
docker(
|
|
417
|
+
"run",
|
|
418
|
+
"--name",
|
|
419
|
+
containers.preparation,
|
|
420
|
+
"--volume",
|
|
421
|
+
`${volumes.prepared}:/data`,
|
|
422
|
+
standaloneImage,
|
|
423
|
+
"prepare-only",
|
|
424
|
+
),
|
|
425
|
+
);
|
|
426
|
+
await runStartedDeployment(
|
|
427
|
+
"runtime",
|
|
428
|
+
"Runtime",
|
|
429
|
+
containers.runtime,
|
|
430
|
+
volumes.prepared,
|
|
431
|
+
runtimeImage,
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
await deploymentPhase("fresh-runtime", "rejection", async () =>
|
|
435
|
+
assertUnpreparedRuntimeFails(),
|
|
436
|
+
);
|
|
437
|
+
} catch (error) {
|
|
438
|
+
console.error(error);
|
|
439
|
+
console.error(await deploymentLogs());
|
|
440
|
+
process.exitCode = 1;
|
|
441
|
+
} finally {
|
|
442
|
+
for (const signal of forwardedSignals) {
|
|
443
|
+
const handler = signalHandlers.get(signal);
|
|
444
|
+
if (handler) {
|
|
445
|
+
process.removeListener(signal, handler);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
await cleanup();
|
|
449
|
+
}
|