@podosoft/podokit 0.16.4 → 0.17.1
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/dist/deploy-compose-exec.d.ts +93 -0
- package/dist/deploy-compose-exec.js +198 -0
- package/dist/deploy-compose-profile.d.ts +107 -0
- package/dist/deploy-compose-profile.js +361 -0
- package/dist/deploy-compose-render.d.ts +48 -0
- package/dist/deploy-compose-render.js +333 -0
- package/dist/deploy-compose-sync.d.ts +144 -0
- package/dist/deploy-compose-sync.js +412 -0
- package/dist/deploy-compose.d.ts +98 -0
- package/dist/deploy-compose.js +667 -0
- package/dist/deploy-dispatch.d.ts +33 -0
- package/dist/deploy-dispatch.js +58 -0
- package/dist/deploy-driver.d.ts +25 -0
- package/dist/deploy-driver.js +78 -0
- package/dist/deploy-profile.d.ts +3 -10
- package/dist/deploy-profile.js +82 -291
- package/dist/deploy-schema.d.ts +56 -0
- package/dist/deploy-schema.js +261 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +178 -6
- package/dist/lib.d.ts +9 -0
- package/dist/lib.js +30 -1
- package/dist/templates/fullstack-nest-svelte/AGENTS.md +2 -1
- package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +34 -3
- package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +68 -7
- package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +60 -8
- package/dist/templates/fullstack-nest-svelte/apps/web/server.js +31 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.test.ts +21 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +15 -1
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.js +267 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
- package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +17 -7
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +50 -1
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/release.md +64 -0
- package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
- package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
- package/dist/templates/fullstack-nest-svelte/dot-dockerignore +7 -0
- package/dist/templates/fullstack-nest-svelte/dot-env.example +7 -0
- package/dist/templates/fullstack-nest-svelte/dot-github/workflows/release.yml +142 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/README.md +18 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +6 -1
- package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +8 -0
- package/dist/templates/fullstack-nest-svelte/infra/k3s/web-deployment.yaml +11 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +15 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.test.ts +62 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/device-label.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/reset-password/+page.svelte +12 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +13 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +27 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +20 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +26 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +10 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +3 -3
- package/dist/templates/modules/bullmq/files/infra/k3s/worker-deployment.yaml +2 -1
- package/dist/templates/todo/AGENTS.md +2 -1
- package/dist/templates/todo/Dockerfile.dev +34 -3
- package/dist/templates/todo/apps/api/Dockerfile +68 -7
- package/dist/templates/todo/apps/web/Dockerfile +60 -8
- package/dist/templates/todo/apps/web/server.js +31 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.test.ts +21 -0
- package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +15 -1
- package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.js +267 -0
- package/dist/templates/todo/apps/web/src/lib/server/ws-upgrade.test.js +134 -0
- package/dist/templates/todo/apps/web/vite.config.ts +17 -7
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +55 -10
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +47 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/release.md +64 -0
- package/dist/templates/todo/dot-agents/skills/podokit-deploy-fast/SKILL.md +92 -0
- package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +1 -1
- package/dist/templates/todo/dot-claude/skills/podokit-deploy-fast/SKILL.md +8 -0
- package/dist/templates/todo/dot-dockerignore +7 -0
- package/dist/templates/todo/dot-env.example +7 -0
- package/dist/templates/todo/dot-github/workflows/release.yml +142 -0
- package/dist/templates/todo/infra/k3s/README.md +18 -0
- package/dist/templates/todo/infra/k3s/api-deployment.yaml +6 -1
- package/dist/templates/todo/infra/k3s/ingress.yaml +8 -0
- package/dist/templates/todo/infra/k3s/web-deployment.yaml +11 -1
- package/package.json +1 -1
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.serviceName = serviceName;
|
|
4
|
+
exports.derivedRuntimeConfig = derivedRuntimeConfig;
|
|
5
|
+
exports.rolloutStateDigest = rolloutStateDigest;
|
|
6
|
+
exports.renderComposeDocument = renderComposeDocument;
|
|
7
|
+
exports.renderMigrationDocument = renderMigrationDocument;
|
|
8
|
+
exports.defaultComposeImages = defaultComposeImages;
|
|
9
|
+
exports.renderComposeDeployment = renderComposeDeployment;
|
|
10
|
+
const node_crypto_1 = require("node:crypto");
|
|
11
|
+
const node_fs_1 = require("node:fs");
|
|
12
|
+
const node_path_1 = require("node:path");
|
|
13
|
+
const deploy_schema_1 = require("./deploy-schema");
|
|
14
|
+
/** The internal port every generated application service listens on. */
|
|
15
|
+
const CONTAINER_PORT = 3000;
|
|
16
|
+
function quote(value) {
|
|
17
|
+
return JSON.stringify(value);
|
|
18
|
+
}
|
|
19
|
+
function digest(parts) {
|
|
20
|
+
const hash = (0, node_crypto_1.createHash)("sha256");
|
|
21
|
+
for (const part of parts)
|
|
22
|
+
hash.update(part).update("\0");
|
|
23
|
+
return `sha256:${hash.digest("hex")}`;
|
|
24
|
+
}
|
|
25
|
+
function serviceName(profile, role) {
|
|
26
|
+
return `${profile.target.project}-${role}`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Configuration PodoKit derives rather than accepts, so a profile cannot point the
|
|
30
|
+
* app at the wrong service or port.
|
|
31
|
+
*/
|
|
32
|
+
function derivedRuntimeConfig(profile) {
|
|
33
|
+
const result = {
|
|
34
|
+
PORT: String(CONTAINER_PORT),
|
|
35
|
+
BACKEND_INTERNAL_URL: `http://${serviceName(profile, "api")}:${CONTAINER_PORT}`,
|
|
36
|
+
...profile.runtimeConfig,
|
|
37
|
+
};
|
|
38
|
+
if (profile.dependencies.postgres.mode === "managed") {
|
|
39
|
+
result.POSTGRES_HOST = serviceName(profile, "postgres");
|
|
40
|
+
result.POSTGRES_PORT = "5432";
|
|
41
|
+
}
|
|
42
|
+
if (profile.dependencies.redis.mode === "managed") {
|
|
43
|
+
result.REDIS_HOST = serviceName(profile, "redis");
|
|
44
|
+
result.REDIS_PORT = "6379";
|
|
45
|
+
}
|
|
46
|
+
if (profile.dependencies.objectStorage.mode === "managed") {
|
|
47
|
+
result.S3_ENDPOINT = `http://${serviceName(profile, "object-storage")}:9000`;
|
|
48
|
+
result.S3_BUCKET = profile.dependencies.objectStorage.bucket;
|
|
49
|
+
result.S3_FORCE_PATH_STYLE = "true";
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
function sortedEnvironment(config) {
|
|
54
|
+
return Object.entries(config).sort(([left], [right]) => left.localeCompare(right));
|
|
55
|
+
}
|
|
56
|
+
function environmentBlock(config, indent) {
|
|
57
|
+
const entries = sortedEnvironment(config);
|
|
58
|
+
if (!entries.length)
|
|
59
|
+
return "";
|
|
60
|
+
return `${indent}environment:\n${entries
|
|
61
|
+
.map(([key, value]) => `${indent} ${key}: ${quote(value)}`)
|
|
62
|
+
.join("\n")}\n`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Identity of everything the containers read but Compose cannot see a change in.
|
|
66
|
+
*
|
|
67
|
+
* Compose recreates a container when its definition changes; it has no idea an env
|
|
68
|
+
* file on the host was edited. Stamping this digest as a label makes an edited
|
|
69
|
+
* secret or a changed runtime config a definition change, so `up` actually rolls
|
|
70
|
+
* the containers instead of leaving stale values running.
|
|
71
|
+
*/
|
|
72
|
+
function rolloutStateDigest(profile, secretIdentities) {
|
|
73
|
+
return digest([
|
|
74
|
+
"compose-rollout-state",
|
|
75
|
+
JSON.stringify(sortedEnvironment(derivedRuntimeConfig(profile))),
|
|
76
|
+
JSON.stringify(Object.entries(secretIdentities).sort(([l], [r]) => l.localeCompare(r))),
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
function resourceBlock(workload, indent) {
|
|
80
|
+
return (`${indent}deploy:\n` +
|
|
81
|
+
`${indent} replicas: ${workload.replicas}\n` +
|
|
82
|
+
`${indent} resources:\n` +
|
|
83
|
+
`${indent} limits:\n` +
|
|
84
|
+
`${indent} cpus: ${quote(workload.resources.cpuLimit)}\n` +
|
|
85
|
+
`${indent} memory: ${workload.resources.memoryLimit}\n`);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* An HTTP readiness probe that needs nothing the runtime image does not already
|
|
89
|
+
* have. The application images are Node, and Node 22 has a global `fetch`, so this
|
|
90
|
+
* avoids making `curl` or `wget` a deployment dependency.
|
|
91
|
+
*/
|
|
92
|
+
function httpHealthcheck(path, indent) {
|
|
93
|
+
const probe = `fetch('http://127.0.0.1:${CONTAINER_PORT}${path}')` +
|
|
94
|
+
`.then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))`;
|
|
95
|
+
return (`${indent}healthcheck:\n` +
|
|
96
|
+
`${indent} test: ["CMD", "node", "-e", ${quote(probe)}]\n` +
|
|
97
|
+
`${indent} interval: 10s\n` +
|
|
98
|
+
`${indent} timeout: 5s\n` +
|
|
99
|
+
`${indent} retries: 6\n` +
|
|
100
|
+
`${indent} start_period: 30s\n`);
|
|
101
|
+
}
|
|
102
|
+
function labelsBlock(profile, release, stateDigest, indent) {
|
|
103
|
+
return (`${indent}labels:\n` +
|
|
104
|
+
`${indent} io.podokit.project: ${quote(profile.target.project)}\n` +
|
|
105
|
+
`${indent} io.podokit.release: ${quote(release)}\n` +
|
|
106
|
+
`${indent} io.podokit.rollout-state: ${quote(stateDigest)}\n`);
|
|
107
|
+
}
|
|
108
|
+
function dependencyService(profile, role, dependency, image, extra) {
|
|
109
|
+
return (` ${serviceName(profile, role)}:\n` +
|
|
110
|
+
` image: ${quote(image)}\n` +
|
|
111
|
+
` restart: unless-stopped\n` +
|
|
112
|
+
(extra.command ? ` command: ${extra.command}\n` : "") +
|
|
113
|
+
` env_file:\n - ${quote(dependency.envFile)}\n` +
|
|
114
|
+
` volumes:\n - ${quote(`${dependency.volume}:${extra.dataPath}`)}\n` +
|
|
115
|
+
` networks: [${profile.target.project}]\n` +
|
|
116
|
+
extra.healthcheck);
|
|
117
|
+
}
|
|
118
|
+
function postgresService(profile, image) {
|
|
119
|
+
return dependencyService(profile, "postgres", profile.dependencies.postgres, image, {
|
|
120
|
+
dataPath: "/var/lib/postgresql/data",
|
|
121
|
+
healthcheck: ` healthcheck:\n` +
|
|
122
|
+
` test: ["CMD-SHELL", "pg_isready -U \\"$$POSTGRES_USER\\" -d \\"$$POSTGRES_DB\\""]\n` +
|
|
123
|
+
` interval: 10s\n timeout: 5s\n retries: 10\n start_period: 20s\n`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function redisService(profile, image) {
|
|
127
|
+
return dependencyService(profile, "redis", profile.dependencies.redis, image, {
|
|
128
|
+
dataPath: "/data",
|
|
129
|
+
// Authenticated and durable, matching what the Kubernetes driver renders.
|
|
130
|
+
command: `["sh", "-c", ${quote('exec redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD"')}]`,
|
|
131
|
+
healthcheck: ` healthcheck:\n` +
|
|
132
|
+
` test: ["CMD-SHELL", "redis-cli -a \\"$$REDIS_PASSWORD\\" ping | grep -q PONG"]\n` +
|
|
133
|
+
` interval: 10s\n timeout: 5s\n retries: 10\n start_period: 10s\n`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function objectStorageService(profile, image) {
|
|
137
|
+
return dependencyService(profile, "object-storage", profile.dependencies.objectStorage, image, {
|
|
138
|
+
dataPath: "/data",
|
|
139
|
+
command: `["server", "/data", "--address", ":9000", "--console-address", ":9001"]`,
|
|
140
|
+
healthcheck: ` healthcheck:\n` +
|
|
141
|
+
` test: ["CMD", "mc", "ready", "local"]\n` +
|
|
142
|
+
` interval: 10s\n timeout: 5s\n retries: 10\n start_period: 15s\n`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Creates the bucket and a least-privilege application user, then exits. Compose
|
|
147
|
+
* runs it as a normal service with `restart: "no"`, which is the closest thing it
|
|
148
|
+
* has to a Job.
|
|
149
|
+
*/
|
|
150
|
+
function objectStorageInitService(profile, clientImage) {
|
|
151
|
+
const storage = profile.dependencies.objectStorage;
|
|
152
|
+
const endpoint = `http://${serviceName(profile, "object-storage")}:9000`;
|
|
153
|
+
// `$$` is Compose's escape: a single `$` would be substituted at parse time,
|
|
154
|
+
// from the environment of whoever runs compose, and these values only exist in
|
|
155
|
+
// the env file the container reads.
|
|
156
|
+
// The least-privilege policy the application user gets: its own bucket, nothing
|
|
157
|
+
// else. It is written here rather than referenced, because a policy file that the
|
|
158
|
+
// job does not create is a policy that never gets attached -- the user is then
|
|
159
|
+
// created with no permissions at all and every object operation fails, which
|
|
160
|
+
// surfaces as a readiness check that is down for no visible reason.
|
|
161
|
+
const policy = JSON.stringify({
|
|
162
|
+
Version: "2012-10-17",
|
|
163
|
+
Statement: [
|
|
164
|
+
{
|
|
165
|
+
Effect: "Allow",
|
|
166
|
+
Action: ["s3:*"],
|
|
167
|
+
Resource: [`arn:aws:s3:::${storage.bucket}`, `arn:aws:s3:::${storage.bucket}/*`],
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
});
|
|
171
|
+
const script = [
|
|
172
|
+
"set -e",
|
|
173
|
+
`mc alias set podokit ${endpoint} "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD"`,
|
|
174
|
+
`mc mb --ignore-existing podokit/${storage.bucket}`,
|
|
175
|
+
`mc admin user add podokit "$$S3_ACCESS_KEY_ID" "$$S3_SECRET_ACCESS_KEY" || true`,
|
|
176
|
+
`printf '%s' '${policy}' > /tmp/podokit-policy.json`,
|
|
177
|
+
`mc admin policy create podokit podokit-app /tmp/podokit-policy.json || true`,
|
|
178
|
+
`mc admin policy attach podokit podokit-app --user "$$S3_ACCESS_KEY_ID" || true`,
|
|
179
|
+
// Prove the attachment, or the job reports success while the user still cannot
|
|
180
|
+
// read its own bucket. Checked with shell builtins only: the mc image is
|
|
181
|
+
// minimal and has no grep, so a pipeline into one exits 127 and takes the job
|
|
182
|
+
// down for the wrong reason.
|
|
183
|
+
`attached=$$(mc admin policy entities podokit --user "$$S3_ACCESS_KEY_ID")`,
|
|
184
|
+
`case "$$attached" in *podokit-app*) ;; *) echo "policy not attached" >&2; exit 1 ;; esac`,
|
|
185
|
+
].join("\n");
|
|
186
|
+
return (` ${serviceName(profile, "object-storage-init")}:\n` +
|
|
187
|
+
` image: ${quote(clientImage)}\n` +
|
|
188
|
+
` restart: "no"\n` +
|
|
189
|
+
` depends_on:\n ${serviceName(profile, "object-storage")}:\n condition: service_healthy\n` +
|
|
190
|
+
` env_file:\n - ${quote(storage.envFile)}\n` +
|
|
191
|
+
` entrypoint: ["sh", "-c", ${quote(script)}]\n` +
|
|
192
|
+
` networks: [${profile.target.project}]\n`);
|
|
193
|
+
}
|
|
194
|
+
function applicationService(profile, role, image, release, stateDigest, options) {
|
|
195
|
+
const workload = role === "worker" ? profile.workloads.worker : profile.workloads[role];
|
|
196
|
+
if (!workload)
|
|
197
|
+
return "";
|
|
198
|
+
const envFile = role === "web" ? profile.secrets.web : profile.secrets.api;
|
|
199
|
+
// Service name -> the condition it must reach before this one starts.
|
|
200
|
+
const dependsOn = [];
|
|
201
|
+
if (profile.dependencies.postgres.mode === "managed" && role !== "web") {
|
|
202
|
+
dependsOn.push([serviceName(profile, "postgres"), "service_healthy"]);
|
|
203
|
+
}
|
|
204
|
+
if (profile.dependencies.redis.mode === "managed" && role !== "web") {
|
|
205
|
+
dependsOn.push([serviceName(profile, "redis"), "service_healthy"]);
|
|
206
|
+
}
|
|
207
|
+
if (profile.dependencies.objectStorage.mode === "managed" && role !== "web") {
|
|
208
|
+
// Not just "storage is up": the bucket and the application user's policy are
|
|
209
|
+
// created by the init service, and an API that starts before that finishes
|
|
210
|
+
// fails every object operation and reports itself unready.
|
|
211
|
+
dependsOn.push([serviceName(profile, "object-storage-init"), "service_completed_successfully"]);
|
|
212
|
+
}
|
|
213
|
+
if (role === "web")
|
|
214
|
+
dependsOn.push([serviceName(profile, "api"), "service_healthy"]);
|
|
215
|
+
return (` ${serviceName(profile, role)}:\n` +
|
|
216
|
+
` image: ${quote(image)}\n` +
|
|
217
|
+
` restart: unless-stopped\n` +
|
|
218
|
+
(options.command ? ` command: ${options.command}\n` : "") +
|
|
219
|
+
(envFile ? ` env_file:\n - ${quote(envFile.path)}\n` : "") +
|
|
220
|
+
environmentBlock(derivedRuntimeConfig(profile), " ") +
|
|
221
|
+
(dependsOn.length
|
|
222
|
+
? ` depends_on:\n${dependsOn
|
|
223
|
+
.map(([name, condition]) => ` ${name}:\n condition: ${condition}\n`)
|
|
224
|
+
.join("")}`
|
|
225
|
+
: "") +
|
|
226
|
+
(options.publish
|
|
227
|
+
? ` ports:\n - ${quote(`${profile.exposure.bindAddress}:${profile.exposure.port}:${CONTAINER_PORT}`)}\n`
|
|
228
|
+
: "") +
|
|
229
|
+
` networks: [${profile.target.project}]\n` +
|
|
230
|
+
resourceBlock(workload, " ") +
|
|
231
|
+
(options.healthcheckPath
|
|
232
|
+
? httpHealthcheck(options.healthcheckPath, " ")
|
|
233
|
+
: // The worker runs the API image with a different entry point, and that image
|
|
234
|
+
// declares an HTTP healthcheck. A process with no HTTP server can never pass
|
|
235
|
+
// it, so the container reports unhealthy forever unless the inherited check
|
|
236
|
+
// is switched off here.
|
|
237
|
+
" healthcheck:\n disable: true\n") +
|
|
238
|
+
labelsBlock(profile, release, stateDigest, " "));
|
|
239
|
+
}
|
|
240
|
+
function renderComposeDocument(profile, release, images, stateDigest) {
|
|
241
|
+
const services = [];
|
|
242
|
+
if (profile.dependencies.postgres.mode === "managed" && images.postgres) {
|
|
243
|
+
services.push(postgresService(profile, images.postgres));
|
|
244
|
+
}
|
|
245
|
+
if (profile.dependencies.redis.mode === "managed" && images.redis) {
|
|
246
|
+
services.push(redisService(profile, images.redis));
|
|
247
|
+
}
|
|
248
|
+
if (profile.dependencies.objectStorage.mode === "managed" && images.objectStorage) {
|
|
249
|
+
services.push(objectStorageService(profile, images.objectStorage));
|
|
250
|
+
if (images.objectStorageClient) {
|
|
251
|
+
services.push(objectStorageInitService(profile, images.objectStorageClient));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
services.push(applicationService(profile, "api", images.api, release, stateDigest, {
|
|
255
|
+
healthcheckPath: "/health/ready",
|
|
256
|
+
}));
|
|
257
|
+
services.push(applicationService(profile, "web", images.web, release, stateDigest, {
|
|
258
|
+
healthcheckPath: "/",
|
|
259
|
+
publish: true,
|
|
260
|
+
}));
|
|
261
|
+
if (profile.workloads.worker) {
|
|
262
|
+
// The worker runs the API image with a different entry point, exactly as the
|
|
263
|
+
// Kubernetes driver does.
|
|
264
|
+
services.push(applicationService(profile, "worker", images.api, release, stateDigest, {
|
|
265
|
+
command: `["node", "dist/main-worker"]`,
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
const volumes = [
|
|
269
|
+
...(profile.dependencies.postgres.mode === "managed"
|
|
270
|
+
? [profile.dependencies.postgres.volume]
|
|
271
|
+
: []),
|
|
272
|
+
...(profile.dependencies.redis.mode === "managed" ? [profile.dependencies.redis.volume] : []),
|
|
273
|
+
...(profile.dependencies.objectStorage.mode === "managed"
|
|
274
|
+
? [profile.dependencies.objectStorage.volume]
|
|
275
|
+
: []),
|
|
276
|
+
];
|
|
277
|
+
return (`# Generated by PodoKit. Do not edit; run "podo deploy render" to regenerate.\n` +
|
|
278
|
+
`name: ${profile.target.project}\n` +
|
|
279
|
+
`services:\n${services.filter(Boolean).join("")}` +
|
|
280
|
+
(volumes.length
|
|
281
|
+
? `volumes:\n${volumes.map((name) => ` ${name}:\n external: true\n`).join("")}`
|
|
282
|
+
: "") +
|
|
283
|
+
`networks:\n ${profile.target.project}:\n name: ${profile.target.project}\n`);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The migration runs as its own Compose project so it can be started, waited on,
|
|
287
|
+
* and removed without touching the running application definition.
|
|
288
|
+
*/
|
|
289
|
+
function renderMigrationDocument(profile, release, images) {
|
|
290
|
+
const command = profile.migration?.command ?? ["npm", "run", "migrate:all"];
|
|
291
|
+
return (`# Generated by PodoKit. One-shot migration for ${release}.\n` +
|
|
292
|
+
`name: ${profile.target.project}-migrate\n` +
|
|
293
|
+
`services:\n` +
|
|
294
|
+
` migrate:\n` +
|
|
295
|
+
` image: ${quote(images.api)}\n` +
|
|
296
|
+
` restart: "no"\n` +
|
|
297
|
+
` command: [${command.map((part) => quote(part)).join(", ")}]\n` +
|
|
298
|
+
` env_file:\n - ${quote(profile.secrets.api.path)}\n` +
|
|
299
|
+
environmentBlock(derivedRuntimeConfig(profile), " ") +
|
|
300
|
+
` networks: [${profile.target.project}]\n` +
|
|
301
|
+
`networks:\n ${profile.target.project}:\n name: ${profile.target.project}\n external: true\n`);
|
|
302
|
+
}
|
|
303
|
+
function defaultComposeImages(profile, release) {
|
|
304
|
+
return {
|
|
305
|
+
api: `${profile.release.apiRepository}:${release}`,
|
|
306
|
+
web: `${profile.release.webRepository}:${release}`,
|
|
307
|
+
postgres: profile.dependencies.postgres.mode === "managed"
|
|
308
|
+
? profile.dependencies.postgres.image
|
|
309
|
+
: null,
|
|
310
|
+
redis: profile.dependencies.redis.mode === "managed" ? profile.dependencies.redis.image : null,
|
|
311
|
+
objectStorage: profile.dependencies.objectStorage.mode === "managed"
|
|
312
|
+
? profile.dependencies.objectStorage.image
|
|
313
|
+
: null,
|
|
314
|
+
objectStorageClient: profile.dependencies.objectStorage.mode === "managed"
|
|
315
|
+
? profile.dependencies.objectStorage.clientImage
|
|
316
|
+
: null,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function renderComposeDeployment(projectRoot, profileName, profile, release, resolvedImages, stateDigest) {
|
|
320
|
+
(0, deploy_schema_1.profilePath)(projectRoot, profileName);
|
|
321
|
+
const images = resolvedImages ?? defaultComposeImages(profile, release);
|
|
322
|
+
const effectiveStateDigest = stateDigest ?? rolloutStateDigest(profile, {});
|
|
323
|
+
const root = (0, node_path_1.join)((0, node_path_1.resolve)(projectRoot), ".podokit", "runtime", "deploy", profileName);
|
|
324
|
+
(0, node_fs_1.rmSync)(root, { recursive: true, force: true });
|
|
325
|
+
(0, node_fs_1.mkdirSync)(root, { recursive: true });
|
|
326
|
+
const composeDocument = renderComposeDocument(profile, release, images, effectiveStateDigest);
|
|
327
|
+
const migrationDocument = renderMigrationDocument(profile, release, images);
|
|
328
|
+
const composeFile = (0, node_path_1.join)(root, "compose.yaml");
|
|
329
|
+
const migrationFile = (0, node_path_1.join)(root, "compose.migrate.yaml");
|
|
330
|
+
(0, node_fs_1.writeFileSync)(composeFile, composeDocument);
|
|
331
|
+
(0, node_fs_1.writeFileSync)(migrationFile, migrationDocument);
|
|
332
|
+
return { root, composeFile, migrationFile, composeDocument, migrationDocument };
|
|
333
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { CommandRunner } from "./dev";
|
|
2
|
+
/**
|
|
3
|
+
* Development-time artifact sync for the docker-compose driver.
|
|
4
|
+
*
|
|
5
|
+
* `podo deploy apply` replaces containers with a new image, which is the only way a
|
|
6
|
+
* release reaches users. That round trip has to build for the target's architecture,
|
|
7
|
+
* push to a registry, and roll out -- minutes, every time, and most of it is spent
|
|
8
|
+
* rebuilding dependencies that did not change. While *developing against* a
|
|
9
|
+
* deployment, the thing that actually changed is a few megabytes of compiled output.
|
|
10
|
+
*
|
|
11
|
+
* So this copies that output into the containers that are already running and
|
|
12
|
+
* restarts them. It is deliberately NOT a release:
|
|
13
|
+
*
|
|
14
|
+
* - The image tag does not change, so the deployment now runs code its tag does not
|
|
15
|
+
* describe. A marker file records that, and `podo deploy status` reports it.
|
|
16
|
+
* - Nothing is pushed anywhere, so nothing is reproducible from it.
|
|
17
|
+
* - The next `apply` (or any container recreate) discards it, because container
|
|
18
|
+
* writable layers do not survive recreation. That is the property that makes this
|
|
19
|
+
* safe rather than a parallel deployment path: the drift heals itself.
|
|
20
|
+
*
|
|
21
|
+
* It refuses rather than warns whenever the copy would produce a container that
|
|
22
|
+
* cannot be trusted -- see the guards below.
|
|
23
|
+
*/
|
|
24
|
+
export type ComposeSyncRole = "api" | "web" | "worker";
|
|
25
|
+
export interface ComposeSyncArtifact {
|
|
26
|
+
/** Project-relative source path. */
|
|
27
|
+
source: string;
|
|
28
|
+
/** Absolute path inside the container. */
|
|
29
|
+
destination: string;
|
|
30
|
+
kind: "directory" | "file";
|
|
31
|
+
roles: ComposeSyncRole[];
|
|
32
|
+
}
|
|
33
|
+
export interface ComposeSyncTarget {
|
|
34
|
+
role: ComposeSyncRole;
|
|
35
|
+
service: string;
|
|
36
|
+
container: string;
|
|
37
|
+
/** The image's configured user, so copied files can be given back to it. */
|
|
38
|
+
user: string | null;
|
|
39
|
+
}
|
|
40
|
+
export interface ComposeSyncPlan {
|
|
41
|
+
profile: string;
|
|
42
|
+
project: string;
|
|
43
|
+
artifacts: ComposeSyncArtifact[];
|
|
44
|
+
excluded: string[];
|
|
45
|
+
targets: ComposeSyncTarget[];
|
|
46
|
+
warnings: string[];
|
|
47
|
+
}
|
|
48
|
+
export interface ComposeSyncMarker {
|
|
49
|
+
syncedAt: string;
|
|
50
|
+
profile: string;
|
|
51
|
+
artifacts: string[];
|
|
52
|
+
note: string;
|
|
53
|
+
}
|
|
54
|
+
export interface ComposeSyncDrift {
|
|
55
|
+
container: string;
|
|
56
|
+
marker: ComposeSyncMarker;
|
|
57
|
+
}
|
|
58
|
+
export interface ComposeSyncResult {
|
|
59
|
+
plan: ComposeSyncPlan;
|
|
60
|
+
copied: Array<{
|
|
61
|
+
container: string;
|
|
62
|
+
source: string;
|
|
63
|
+
destination: string;
|
|
64
|
+
}>;
|
|
65
|
+
restarted: string[];
|
|
66
|
+
marker: ComposeSyncMarker;
|
|
67
|
+
}
|
|
68
|
+
export interface ComposeSyncOptions {
|
|
69
|
+
/** Build the artifacts before copying them, in the order the images build them. */
|
|
70
|
+
build?: boolean;
|
|
71
|
+
/** Empty each destination before copying, so deleted files do not linger. */
|
|
72
|
+
clean?: boolean;
|
|
73
|
+
/** Injected for tests; the marker records when the sync happened. */
|
|
74
|
+
now?: () => Date;
|
|
75
|
+
/** How long to wait for a restarted container to report healthy. */
|
|
76
|
+
healthTimeoutMs?: number;
|
|
77
|
+
}
|
|
78
|
+
interface PackageManifest {
|
|
79
|
+
dependencies?: Record<string, string>;
|
|
80
|
+
optionalDependencies?: Record<string, string>;
|
|
81
|
+
peerDependencies?: Record<string, string>;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* What the images ship outside their dependency tree.
|
|
85
|
+
*
|
|
86
|
+
* Every entry mirrors a `COPY --from=build` line in the generated Dockerfiles, which
|
|
87
|
+
* is what makes the copy equivalent to a rebuild for code-only changes. Two entries
|
|
88
|
+
* exist for a reason worth keeping:
|
|
89
|
+
*
|
|
90
|
+
* - `apps/web/server.js` and `apps/web/src/lib/server` ship as SOURCE, outside the
|
|
91
|
+
* bundle, because the entry point has to run before the adapter does. Syncing only
|
|
92
|
+
* `build/` would leave a stale entry running new routes.
|
|
93
|
+
* - `packages/*` are copied as real directories rather than the symlinks npm leaves
|
|
94
|
+
* behind, so their `dist/` is a separate destination from the apps'.
|
|
95
|
+
*
|
|
96
|
+
* `node_modules` is deliberately absent: the image installs a production-only,
|
|
97
|
+
* workspace-scoped tree for its own platform, and a developer's tree is neither.
|
|
98
|
+
*/
|
|
99
|
+
export declare function composeSyncArtifacts(projectRoot: string): ComposeSyncArtifact[];
|
|
100
|
+
/** The excludes that fall inside one artifact, as paths relative to it. */
|
|
101
|
+
export declare function excludesWithin(artifact: ComposeSyncArtifact, excludes: string[]): string[];
|
|
102
|
+
/**
|
|
103
|
+
* Whether the image's dependency tree can still run this code.
|
|
104
|
+
*
|
|
105
|
+
* Only the runtime dependency sets matter: `npm ci --omit=dev` installed what these
|
|
106
|
+
* declare and nothing else, so a change to any of them means the container is
|
|
107
|
+
* missing a package the new code imports -- a failure that surfaces as a crash loop
|
|
108
|
+
* after the restart rather than as a copy error. `devDependencies` are absent from
|
|
109
|
+
* the image by design and a `version` bump changes nothing at runtime, so neither
|
|
110
|
+
* takes part.
|
|
111
|
+
*/
|
|
112
|
+
export declare function runtimeDependenciesOf(manifest: PackageManifest): string;
|
|
113
|
+
export declare function planComposeSync(projectRoot: string, profileName: string, runner?: CommandRunner): ComposeSyncPlan;
|
|
114
|
+
export declare function syncComposeDeployment(projectRoot: string, profileName: string, options?: ComposeSyncOptions, runner?: CommandRunner): Promise<ComposeSyncResult>;
|
|
115
|
+
/**
|
|
116
|
+
* Which containers are running synced artifacts rather than their image's.
|
|
117
|
+
*
|
|
118
|
+
* Reporting-only, so it never throws: `podo deploy status` folds this in, and a
|
|
119
|
+
* target that cannot answer right now is a reason to report less, not a reason for
|
|
120
|
+
* status to fail. An empty result therefore means "no drift found", which is also
|
|
121
|
+
* what an unreachable target produces -- the services list beside it already shows
|
|
122
|
+
* that case for what it is.
|
|
123
|
+
*/
|
|
124
|
+
export declare function readComposeSyncDrift(projectRoot: string, profileName: string, runner?: CommandRunner): ComposeSyncDrift[];
|
|
125
|
+
/**
|
|
126
|
+
* Put the deployment back on its image.
|
|
127
|
+
*
|
|
128
|
+
* The release to restore comes from the ledger, and the project is rendered from
|
|
129
|
+
* that entry's own release, images and rollout state -- the same way a rollback
|
|
130
|
+
* reproduces a revision.
|
|
131
|
+
*
|
|
132
|
+
* ⚠ Not from the Compose file sitting on the target, which was the first attempt.
|
|
133
|
+
* That file is written by whichever command last ran a compose mutation, and one of
|
|
134
|
+
* them renders a placeholder release to obtain a file path. The revert then tried to
|
|
135
|
+
* pull an image tag that never existed and refused to recreate anything. Measured
|
|
136
|
+
* against a live deployment; the read path was fixed at the same time so a status
|
|
137
|
+
* call can no longer overwrite the target's project.
|
|
138
|
+
*/
|
|
139
|
+
export declare function revertComposeSync(projectRoot: string, profileName: string, runner?: CommandRunner): Promise<{
|
|
140
|
+
services: string[];
|
|
141
|
+
project: string;
|
|
142
|
+
release: string;
|
|
143
|
+
}>;
|
|
144
|
+
export {};
|