vyft 0.1.0-alpha
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/LICENSE +21 -0
- package/README.md +195 -0
- package/dist/build.d.ts +11 -0
- package/dist/build.js +39 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +200 -0
- package/dist/docker.d.ts +48 -0
- package/dist/docker.js +855 -0
- package/dist/exec.d.ts +2 -0
- package/dist/exec.js +28 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +100 -0
- package/dist/interpolate.d.ts +2 -0
- package/dist/interpolate.js +8 -0
- package/dist/logger.d.ts +2 -0
- package/dist/logger.js +10 -0
- package/dist/resource.d.ts +78 -0
- package/dist/resource.js +35 -0
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.js +0 -0
- package/dist/swarm/factories.d.ts +8 -0
- package/dist/swarm/factories.js +50 -0
- package/dist/swarm/index.d.ts +10 -0
- package/dist/swarm/index.js +5 -0
- package/dist/swarm/types.d.ts +25 -0
- package/dist/swarm/types.js +0 -0
- package/dist/symbols.d.ts +8 -0
- package/dist/symbols.js +1 -0
- package/package.json +68 -0
- package/templates/fullstack/apps/api/Dockerfile +21 -0
- package/templates/fullstack/apps/api/package.json +26 -0
- package/templates/fullstack/apps/api/src/auth.ts +21 -0
- package/templates/fullstack/apps/api/src/db.ts +16 -0
- package/templates/fullstack/apps/api/src/index.ts +17 -0
- package/templates/fullstack/apps/api/src/router.ts +11 -0
- package/templates/fullstack/apps/api/src/schema.ts +11 -0
- package/templates/fullstack/apps/api/tsconfig.json +8 -0
- package/templates/fullstack/apps/web/index.html +12 -0
- package/templates/fullstack/apps/web/package.json +21 -0
- package/templates/fullstack/apps/web/src/app.tsx +8 -0
- package/templates/fullstack/apps/web/src/main.tsx +9 -0
- package/templates/fullstack/apps/web/tsconfig.json +7 -0
- package/templates/fullstack/apps/web/vite.config.ts +14 -0
- package/templates/fullstack/compose.yaml +14 -0
- package/templates/fullstack/dockerignore +7 -0
- package/templates/fullstack/gitignore +3 -0
- package/templates/fullstack/package.json +17 -0
- package/templates/fullstack/pnpm-workspace.yaml +2 -0
- package/templates/fullstack/tsconfig.json +11 -0
- package/templates/fullstack/vyft.config.ts +37 -0
package/dist/docker.js
ADDED
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { PassThrough } from "node:stream";
|
|
5
|
+
import { log } from "@clack/prompts";
|
|
6
|
+
import Docker from "dockerode";
|
|
7
|
+
import tar from "tar-fs";
|
|
8
|
+
import { buildStatic } from "./build.js";
|
|
9
|
+
import { logger as defaultLogger } from "./logger.js";
|
|
10
|
+
import { isInterpolation, isReference } from "./resource.js";
|
|
11
|
+
const PROXY_SERVICE_NAME = "vyft-proxy";
|
|
12
|
+
function resolveDockerHost() {
|
|
13
|
+
if (process.env.DOCKER_HOST)
|
|
14
|
+
return undefined;
|
|
15
|
+
try {
|
|
16
|
+
const host = execSync("docker context inspect --format '{{.Endpoints.docker.Host}}'", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
17
|
+
if (host && host !== `unix:///var/run/docker.sock`)
|
|
18
|
+
return host;
|
|
19
|
+
}
|
|
20
|
+
catch { }
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
function secretMount(id, name) {
|
|
24
|
+
return {
|
|
25
|
+
SecretID: id,
|
|
26
|
+
SecretName: name,
|
|
27
|
+
File: { Name: name, UID: "0", GID: "0", Mode: 0o444 },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function parseDuration(duration) {
|
|
31
|
+
const match = duration.match(/^(\d+)(ms|s|m|h)$/);
|
|
32
|
+
if (!match || !match[1] || !match[2]) {
|
|
33
|
+
throw new Error(`Invalid duration format: ${duration}`);
|
|
34
|
+
}
|
|
35
|
+
const value = parseInt(match[1], 10);
|
|
36
|
+
const unit = match[2];
|
|
37
|
+
const multipliers = {
|
|
38
|
+
ms: 1_000_000,
|
|
39
|
+
s: 1_000_000_000,
|
|
40
|
+
m: 60_000_000_000,
|
|
41
|
+
h: 3_600_000_000_000,
|
|
42
|
+
};
|
|
43
|
+
return value * multipliers[unit];
|
|
44
|
+
}
|
|
45
|
+
function parseMemory(memory) {
|
|
46
|
+
const match = memory.match(/^(\d+)(B|KB|MB|GB)$/i);
|
|
47
|
+
if (!match || !match[1] || !match[2]) {
|
|
48
|
+
throw new Error(`Invalid memory format: ${memory}`);
|
|
49
|
+
}
|
|
50
|
+
const value = parseInt(match[1], 10);
|
|
51
|
+
const unit = match[2].toUpperCase();
|
|
52
|
+
const multipliers = {
|
|
53
|
+
B: 1,
|
|
54
|
+
KB: 1024,
|
|
55
|
+
MB: 1024 * 1024,
|
|
56
|
+
GB: 1024 * 1024 * 1024,
|
|
57
|
+
};
|
|
58
|
+
return value * multipliers[unit];
|
|
59
|
+
}
|
|
60
|
+
export function parseRoute(route) {
|
|
61
|
+
const slashIndex = route.indexOf("/");
|
|
62
|
+
if (slashIndex === -1) {
|
|
63
|
+
return { host: route };
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
host: route.slice(0, slashIndex),
|
|
67
|
+
path: route.slice(slashIndex),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function buildCaddyRoute(project, resourceId, route, handler) {
|
|
71
|
+
const { host, path } = parseRoute(route);
|
|
72
|
+
const match = { host: [host] };
|
|
73
|
+
if (path)
|
|
74
|
+
match.path = [path];
|
|
75
|
+
return {
|
|
76
|
+
"@id": `vyft-${project}-${resourceId}`,
|
|
77
|
+
match: [match],
|
|
78
|
+
terminal: true,
|
|
79
|
+
handle: [handler],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export class DockerClient {
|
|
83
|
+
docker;
|
|
84
|
+
project;
|
|
85
|
+
secretValues = new Map();
|
|
86
|
+
log;
|
|
87
|
+
verbose = false;
|
|
88
|
+
constructor(project, parentLogger) {
|
|
89
|
+
const contextHost = resolveDockerHost();
|
|
90
|
+
if (contextHost)
|
|
91
|
+
process.env.DOCKER_HOST = contextHost;
|
|
92
|
+
this.docker = new Docker();
|
|
93
|
+
this.project = project;
|
|
94
|
+
this.log = (parentLogger ?? defaultLogger).child({
|
|
95
|
+
component: "docker",
|
|
96
|
+
project,
|
|
97
|
+
});
|
|
98
|
+
if (contextHost)
|
|
99
|
+
this.log.debug({ host: contextHost }, "using docker context endpoint");
|
|
100
|
+
}
|
|
101
|
+
async ensureInfrastructure() {
|
|
102
|
+
const start = performance.now();
|
|
103
|
+
this.log.debug("checking proxy existence");
|
|
104
|
+
const exists = await this.serviceExists(PROXY_SERVICE_NAME);
|
|
105
|
+
if (exists) {
|
|
106
|
+
this.log.debug("proxy already exists");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// Create network for services to communicate
|
|
110
|
+
const networks = await this.docker.listNetworks({
|
|
111
|
+
filters: JSON.stringify({ name: ["vyft-network"] }),
|
|
112
|
+
});
|
|
113
|
+
if (networks.length === 0) {
|
|
114
|
+
this.log.debug("creating network");
|
|
115
|
+
await this.docker.createNetwork({
|
|
116
|
+
Name: "vyft-network",
|
|
117
|
+
Driver: "overlay",
|
|
118
|
+
Attachable: true,
|
|
119
|
+
Labels: { "vyft.infrastructure": "true" },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
this.log.debug("creating proxy service");
|
|
123
|
+
await this.docker.createService({
|
|
124
|
+
Name: PROXY_SERVICE_NAME,
|
|
125
|
+
Labels: { "vyft.infrastructure": "true" },
|
|
126
|
+
TaskTemplate: {
|
|
127
|
+
ContainerSpec: {
|
|
128
|
+
Image: "caddy:latest",
|
|
129
|
+
Command: ["caddy", "run", "--resume"],
|
|
130
|
+
Mounts: [
|
|
131
|
+
{
|
|
132
|
+
Type: "volume",
|
|
133
|
+
Source: "vyft-proxy-config",
|
|
134
|
+
Target: "/config",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
Type: "volume",
|
|
138
|
+
Source: "vyft-proxy-data",
|
|
139
|
+
Target: "/data",
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
Labels: { "vyft.infrastructure": "true" },
|
|
143
|
+
},
|
|
144
|
+
Networks: [{ Target: "vyft-network" }],
|
|
145
|
+
},
|
|
146
|
+
EndpointSpec: {
|
|
147
|
+
Ports: [
|
|
148
|
+
{
|
|
149
|
+
Protocol: "tcp",
|
|
150
|
+
TargetPort: 80,
|
|
151
|
+
PublishedPort: 80,
|
|
152
|
+
PublishMode: "ingress",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
Protocol: "tcp",
|
|
156
|
+
TargetPort: 443,
|
|
157
|
+
PublishedPort: 443,
|
|
158
|
+
PublishMode: "ingress",
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
Mode: { Replicated: { Replicas: 1 } },
|
|
163
|
+
});
|
|
164
|
+
log.step("Created vyft-proxy");
|
|
165
|
+
await this.seedCaddyConfig();
|
|
166
|
+
const durationMs = Math.round(performance.now() - start);
|
|
167
|
+
this.log.info({ proxyCreated: true, durationMs }, "infrastructure ready");
|
|
168
|
+
}
|
|
169
|
+
async findProxyContainer() {
|
|
170
|
+
const containers = await this.docker.listContainers({
|
|
171
|
+
filters: JSON.stringify({
|
|
172
|
+
label: [`com.docker.swarm.service.name=${PROXY_SERVICE_NAME}`],
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
if (containers.length === 0) {
|
|
176
|
+
throw new Error("vyft-proxy container not found");
|
|
177
|
+
}
|
|
178
|
+
return this.docker.getContainer(containers[0].Id);
|
|
179
|
+
}
|
|
180
|
+
async caddyApiRequest(method, path, body) {
|
|
181
|
+
const start = performance.now();
|
|
182
|
+
this.log.debug({ method, path }, "caddy request started");
|
|
183
|
+
const container = await this.findProxyContainer();
|
|
184
|
+
let cmd;
|
|
185
|
+
if (method === "POST") {
|
|
186
|
+
const bodyStr = body ? JSON.stringify(body) : "";
|
|
187
|
+
cmd = [
|
|
188
|
+
"wget",
|
|
189
|
+
"-q",
|
|
190
|
+
"-O",
|
|
191
|
+
"-",
|
|
192
|
+
"--timeout=10",
|
|
193
|
+
"--header=Content-Type: application/json",
|
|
194
|
+
`--post-data=${bodyStr}`,
|
|
195
|
+
`http://127.0.0.1:2019${path}`,
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
else if (method === "DELETE") {
|
|
199
|
+
cmd = [
|
|
200
|
+
"curl",
|
|
201
|
+
"-s",
|
|
202
|
+
"--connect-timeout",
|
|
203
|
+
"5",
|
|
204
|
+
"--max-time",
|
|
205
|
+
"10",
|
|
206
|
+
"-X",
|
|
207
|
+
"DELETE",
|
|
208
|
+
`http://127.0.0.1:2019${path}`,
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
cmd = [
|
|
213
|
+
"wget",
|
|
214
|
+
"-q",
|
|
215
|
+
"-O",
|
|
216
|
+
"-",
|
|
217
|
+
"--timeout=10",
|
|
218
|
+
`http://127.0.0.1:2019${path}`,
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
this.log.trace({ cmd }, "caddy exec command");
|
|
222
|
+
const exec = await container.exec({
|
|
223
|
+
Cmd: cmd,
|
|
224
|
+
AttachStdout: true,
|
|
225
|
+
AttachStderr: true,
|
|
226
|
+
});
|
|
227
|
+
const stream = await exec.start({});
|
|
228
|
+
return new Promise((resolve, reject) => {
|
|
229
|
+
const stdout = [];
|
|
230
|
+
const stderr = [];
|
|
231
|
+
const stdoutStream = new PassThrough();
|
|
232
|
+
const stderrStream = new PassThrough();
|
|
233
|
+
stdoutStream.on("data", (chunk) => stdout.push(chunk));
|
|
234
|
+
stderrStream.on("data", (chunk) => stderr.push(chunk));
|
|
235
|
+
this.docker.modem.demuxStream(stream, stdoutStream, stderrStream);
|
|
236
|
+
stream.on("end", () => {
|
|
237
|
+
const output = Buffer.concat(stdout).toString();
|
|
238
|
+
const errOutput = Buffer.concat(stderr).toString();
|
|
239
|
+
const durationMs = Math.round(performance.now() - start);
|
|
240
|
+
this.log.trace({ stdoutBytes: output.length, stderrBytes: errOutput.length }, "caddy exec output");
|
|
241
|
+
if (errOutput) {
|
|
242
|
+
this.log.debug({ method, path, durationMs, stderr: errOutput }, "caddy request failed");
|
|
243
|
+
reject(new Error(`Caddy API ${method} ${path} failed: ${errOutput}`));
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
this.log.debug({ method, path, durationMs }, "caddy request completed");
|
|
247
|
+
resolve(output);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
async seedCaddyConfig() {
|
|
253
|
+
const maxWait = 30000;
|
|
254
|
+
const interval = 2000;
|
|
255
|
+
let waited = 0;
|
|
256
|
+
this.log.debug("waiting for proxy container");
|
|
257
|
+
while (waited < maxWait) {
|
|
258
|
+
try {
|
|
259
|
+
await this.findProxyContainer();
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
this.log.debug({ err, waited, maxWait }, "proxy container not ready");
|
|
264
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
265
|
+
waited += interval;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const baseConfig = {
|
|
269
|
+
apps: {
|
|
270
|
+
http: {
|
|
271
|
+
servers: {
|
|
272
|
+
main: {
|
|
273
|
+
listen: [":443", ":80"],
|
|
274
|
+
routes: [],
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
const maxAttempts = 5;
|
|
281
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
282
|
+
try {
|
|
283
|
+
this.log.debug({ attempt: attempt + 1, max: maxAttempts }, "seeding caddy config");
|
|
284
|
+
await this.caddyApiRequest("POST", "/load", baseConfig);
|
|
285
|
+
this.log.debug("caddy config seeded");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
this.log.debug({ err, attempt: attempt + 1, max: maxAttempts }, "seed attempt failed");
|
|
290
|
+
if (attempt >= Math.floor(maxAttempts * 0.8)) {
|
|
291
|
+
this.log.warn({ attempt: attempt + 1, max: maxAttempts }, "seed retry nearing limit");
|
|
292
|
+
}
|
|
293
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
this.log.error("caddy config seed exhausted retries");
|
|
297
|
+
throw new Error("Failed to seed Caddy config after retries");
|
|
298
|
+
}
|
|
299
|
+
async addRoute(resourceId, route, handler) {
|
|
300
|
+
this.log.debug({ resourceId, route }, "adding route");
|
|
301
|
+
const caddyRoute = buildCaddyRoute(this.project, resourceId, route, handler);
|
|
302
|
+
// Delete existing route for idempotency
|
|
303
|
+
try {
|
|
304
|
+
await this.caddyApiRequest("DELETE", `/id/vyft-${this.project}-${resourceId}`);
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
this.log.debug({ err, resourceId }, "idempotent route delete failed");
|
|
308
|
+
}
|
|
309
|
+
const maxAttempts = 5;
|
|
310
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
311
|
+
try {
|
|
312
|
+
await this.caddyApiRequest("POST", "/config/apps/http/servers/main/routes", caddyRoute);
|
|
313
|
+
this.log.debug({ resourceId, route }, "route added");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
this.log.debug({ err, resourceId, attempt: attempt + 1, max: maxAttempts }, "route add attempt failed");
|
|
318
|
+
if (attempt === maxAttempts - 1) {
|
|
319
|
+
this.log.error({ resourceId, route }, "route add exhausted retries");
|
|
320
|
+
throw new Error(`Failed to add route for ${resourceId}`);
|
|
321
|
+
}
|
|
322
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
async removeRoute(resourceId) {
|
|
327
|
+
this.log.debug({ resourceId }, "removing route");
|
|
328
|
+
try {
|
|
329
|
+
await this.caddyApiRequest("DELETE", `/id/vyft-${this.project}-${resourceId}`);
|
|
330
|
+
this.log.debug({ resourceId }, "route removed");
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
this.log.debug({ err, resourceId }, "route removal failed (may not exist)");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async listManagedResources() {
|
|
337
|
+
const resources = [];
|
|
338
|
+
const [services, volumes, secrets] = await Promise.all([
|
|
339
|
+
this.docker.listServices({
|
|
340
|
+
filters: JSON.stringify({
|
|
341
|
+
label: ["vyft.managed=true", `vyft.project=${this.project}`],
|
|
342
|
+
}),
|
|
343
|
+
}),
|
|
344
|
+
this.docker.listVolumes({
|
|
345
|
+
filters: JSON.stringify({
|
|
346
|
+
label: ["vyft.managed=true", `vyft.project=${this.project}`],
|
|
347
|
+
}),
|
|
348
|
+
}),
|
|
349
|
+
this.docker.listSecrets({
|
|
350
|
+
filters: JSON.stringify({
|
|
351
|
+
label: ["vyft.managed=true", `vyft.project=${this.project}`],
|
|
352
|
+
}),
|
|
353
|
+
}),
|
|
354
|
+
]);
|
|
355
|
+
for (const svc of services) {
|
|
356
|
+
const labels = svc.Spec?.Labels || {};
|
|
357
|
+
resources.push({
|
|
358
|
+
id: svc.Spec?.Name || "",
|
|
359
|
+
type: labels["vyft.type"] || "service",
|
|
360
|
+
route: labels["vyft.route"],
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
for (const vol of volumes.Volumes || []) {
|
|
364
|
+
if (vol.Labels?.["vyft.static"])
|
|
365
|
+
continue;
|
|
366
|
+
resources.push({
|
|
367
|
+
id: vol.Name,
|
|
368
|
+
type: "volume",
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
for (const secret of secrets) {
|
|
372
|
+
const secretLabels = secret.Spec?.Labels || {};
|
|
373
|
+
resources.push({
|
|
374
|
+
id: secret.Spec?.Name || "",
|
|
375
|
+
type: "secret",
|
|
376
|
+
derived: secretLabels["vyft.derived"] === "true",
|
|
377
|
+
parentService: secretLabels["vyft.parent-service"],
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return resources;
|
|
381
|
+
}
|
|
382
|
+
async create(resource) {
|
|
383
|
+
switch (resource.type) {
|
|
384
|
+
case "volume":
|
|
385
|
+
return this.createVolume(resource);
|
|
386
|
+
case "secret":
|
|
387
|
+
return this.createSecret(resource);
|
|
388
|
+
case "service":
|
|
389
|
+
return this.createService(resource);
|
|
390
|
+
case "site":
|
|
391
|
+
return this.createStatic(resource);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
async exists(resource) {
|
|
395
|
+
switch (resource.type) {
|
|
396
|
+
case "volume":
|
|
397
|
+
return this.volumeExists(resource.id);
|
|
398
|
+
case "secret":
|
|
399
|
+
return this.secretExists(resource.id);
|
|
400
|
+
case "service":
|
|
401
|
+
case "site":
|
|
402
|
+
return this.serviceExists(resource.id);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
async remove(resource) {
|
|
406
|
+
switch (resource.type) {
|
|
407
|
+
case "volume":
|
|
408
|
+
return this.removeVolume(resource.id);
|
|
409
|
+
case "secret":
|
|
410
|
+
return this.removeSecret(resource.id);
|
|
411
|
+
case "service":
|
|
412
|
+
return this.removeService(resource.id);
|
|
413
|
+
case "site":
|
|
414
|
+
return this.removeStatic(resource.id);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
async createVolume(volume) {
|
|
418
|
+
const start = performance.now();
|
|
419
|
+
this.log.debug({ volumeName: volume.id }, "creating volume");
|
|
420
|
+
const config = volume.config;
|
|
421
|
+
const labels = {
|
|
422
|
+
"vyft.managed": "true",
|
|
423
|
+
"vyft.project": this.project,
|
|
424
|
+
"vyft.type": "volume",
|
|
425
|
+
};
|
|
426
|
+
if (config.size) {
|
|
427
|
+
labels["vyft.size"] = config.size;
|
|
428
|
+
}
|
|
429
|
+
await this.docker.createVolume({
|
|
430
|
+
Name: volume.id,
|
|
431
|
+
Labels: labels,
|
|
432
|
+
});
|
|
433
|
+
const durationMs = Math.round(performance.now() - start);
|
|
434
|
+
this.log.debug({ volumeName: volume.id, durationMs }, "volume created");
|
|
435
|
+
log.step(`Created ${volume.id}`);
|
|
436
|
+
}
|
|
437
|
+
async volumeExists(id) {
|
|
438
|
+
const start = performance.now();
|
|
439
|
+
try {
|
|
440
|
+
await this.docker.getVolume(id).inspect();
|
|
441
|
+
const durationMs = Math.round(performance.now() - start);
|
|
442
|
+
this.log.debug({ volumeName: id, exists: true, durationMs }, "volume exists check");
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
const durationMs = Math.round(performance.now() - start);
|
|
447
|
+
this.log.debug({ err, volumeName: id, exists: false, durationMs }, "volume exists check");
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
async removeVolume(id) {
|
|
452
|
+
const start = performance.now();
|
|
453
|
+
this.log.debug({ volumeName: id }, "removing volume");
|
|
454
|
+
await this.docker.getVolume(id).remove();
|
|
455
|
+
const durationMs = Math.round(performance.now() - start);
|
|
456
|
+
this.log.debug({ volumeName: id, durationMs }, "volume removed");
|
|
457
|
+
log.step(`Removed ${id}`);
|
|
458
|
+
}
|
|
459
|
+
async createSecret(secret) {
|
|
460
|
+
const start = performance.now();
|
|
461
|
+
const length = secret.config.length || 32;
|
|
462
|
+
this.log.debug({ secretName: secret.id, length }, "creating secret");
|
|
463
|
+
const value = randomBytes(length).toString("base64url");
|
|
464
|
+
this.secretValues.set(secret.id, value);
|
|
465
|
+
await this.storeSecretData(secret.id, value, {
|
|
466
|
+
"vyft.length": length.toString(),
|
|
467
|
+
});
|
|
468
|
+
const durationMs = Math.round(performance.now() - start);
|
|
469
|
+
this.log.debug({ secretName: secret.id, length, durationMs }, "secret created");
|
|
470
|
+
log.step(`Created ${secret.id}`);
|
|
471
|
+
}
|
|
472
|
+
async secretExists(id) {
|
|
473
|
+
const start = performance.now();
|
|
474
|
+
try {
|
|
475
|
+
await this.docker.getSecret(id).inspect();
|
|
476
|
+
const durationMs = Math.round(performance.now() - start);
|
|
477
|
+
this.log.debug({ secretName: id, exists: true, durationMs }, "secret exists check");
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
const durationMs = Math.round(performance.now() - start);
|
|
482
|
+
this.log.debug({ err, secretName: id, exists: false, durationMs }, "secret exists check");
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
async removeSecret(id) {
|
|
487
|
+
const start = performance.now();
|
|
488
|
+
this.log.debug({ secretName: id }, "removing secret");
|
|
489
|
+
await this.docker.getSecret(id).remove();
|
|
490
|
+
const durationMs = Math.round(performance.now() - start);
|
|
491
|
+
this.log.debug({ secretName: id, durationMs }, "secret removed");
|
|
492
|
+
log.step(`Removed ${id}`);
|
|
493
|
+
}
|
|
494
|
+
async storeSecretData(name, value, labels = {}) {
|
|
495
|
+
this.log.debug({ secretName: name }, "storing secret data");
|
|
496
|
+
await this.docker.createSecret({
|
|
497
|
+
Name: name,
|
|
498
|
+
Data: Buffer.from(value).toString("base64"),
|
|
499
|
+
Labels: {
|
|
500
|
+
"vyft.managed": "true",
|
|
501
|
+
"vyft.project": this.project,
|
|
502
|
+
"vyft.type": "secret",
|
|
503
|
+
...labels,
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
async lookupSecretId(name) {
|
|
508
|
+
this.log.debug({ secretName: name }, "looking up secret id");
|
|
509
|
+
const secret = await this.docker.getSecret(name).inspect();
|
|
510
|
+
return secret.ID;
|
|
511
|
+
}
|
|
512
|
+
async createDerivedSecret(name, value, parentService) {
|
|
513
|
+
this.log.debug({ secretName: name, parentService }, "creating derived secret");
|
|
514
|
+
await this.storeSecretData(name, value, {
|
|
515
|
+
"vyft.derived": "true",
|
|
516
|
+
"vyft.parent-service": parentService,
|
|
517
|
+
});
|
|
518
|
+
log.step(`Created ${name}`);
|
|
519
|
+
}
|
|
520
|
+
async resolveEnv(serviceId, env) {
|
|
521
|
+
const envList = [];
|
|
522
|
+
const secrets = [];
|
|
523
|
+
for (const [key, value] of Object.entries(env)) {
|
|
524
|
+
if (typeof value === "string") {
|
|
525
|
+
envList.push(`${key}=${value}`);
|
|
526
|
+
}
|
|
527
|
+
else if (isReference(value)) {
|
|
528
|
+
const secretId = await this.lookupSecretId(value.id);
|
|
529
|
+
secrets.push(secretMount(secretId, value.id));
|
|
530
|
+
const envKey = key.endsWith("_FILE") ? key : `${key}_FILE`;
|
|
531
|
+
envList.push(`${envKey}=/run/secrets/${value.id}`);
|
|
532
|
+
}
|
|
533
|
+
else if (isInterpolation(value)) {
|
|
534
|
+
const parts = [];
|
|
535
|
+
for (let i = 0; i < value.strings.length; i++) {
|
|
536
|
+
parts.push(value.strings[i]);
|
|
537
|
+
if (i < value.values.length) {
|
|
538
|
+
const v = value.values[i];
|
|
539
|
+
if (isReference(v)) {
|
|
540
|
+
const secretValue = this.secretValues.get(v.id);
|
|
541
|
+
if (secretValue === undefined) {
|
|
542
|
+
throw new Error(`Secret "${v.id}" value not available — it must be created in the same deploy session`);
|
|
543
|
+
}
|
|
544
|
+
parts.push(secretValue);
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
parts.push(v);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const derivedName = `${serviceId}-${key.toLowerCase().replace(/_/g, "-")}`;
|
|
552
|
+
await this.createDerivedSecret(derivedName, parts.join(""), serviceId);
|
|
553
|
+
const derivedId = await this.lookupSecretId(derivedName);
|
|
554
|
+
secrets.push(secretMount(derivedId, derivedName));
|
|
555
|
+
const envKey = key.endsWith("_FILE") ? key : `${key}_FILE`;
|
|
556
|
+
envList.push(`${envKey}=/run/secrets/${derivedName}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
this.log.debug({ serviceId, envCount: envList.length, secretMountCount: secrets.length }, "env resolved");
|
|
560
|
+
return { envList, secrets };
|
|
561
|
+
}
|
|
562
|
+
async createService(service) {
|
|
563
|
+
const start = performance.now();
|
|
564
|
+
const config = service.config;
|
|
565
|
+
let imageName;
|
|
566
|
+
if (typeof config.image === "object") {
|
|
567
|
+
const { context = ".", dockerfile = "Dockerfile" } = config.image;
|
|
568
|
+
imageName = `${service.id}:latest`;
|
|
569
|
+
this.log.debug({ resourceId: service.id, context, dockerfile }, "image build started");
|
|
570
|
+
const buildStart = performance.now();
|
|
571
|
+
const tarStream = tar.pack(path.resolve(context));
|
|
572
|
+
const stream = await this.docker.buildImage(tarStream, {
|
|
573
|
+
t: imageName,
|
|
574
|
+
dockerfile,
|
|
575
|
+
});
|
|
576
|
+
await new Promise((resolve, reject) => {
|
|
577
|
+
this.docker.modem.followProgress(stream, (err) => (err ? reject(err) : resolve()), (event) => {
|
|
578
|
+
if (event.error)
|
|
579
|
+
log.error(event.error);
|
|
580
|
+
else if (this.verbose && event.stream)
|
|
581
|
+
process.stdout.write(event.stream);
|
|
582
|
+
this.log.trace({ event }, "build progress");
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
const buildDurationMs = Math.round(performance.now() - buildStart);
|
|
586
|
+
this.log.debug({
|
|
587
|
+
resourceId: service.id,
|
|
588
|
+
image: imageName,
|
|
589
|
+
durationMs: buildDurationMs,
|
|
590
|
+
}, "image build completed");
|
|
591
|
+
const images = await this.docker.listImages({
|
|
592
|
+
filters: JSON.stringify({ reference: [imageName] }),
|
|
593
|
+
});
|
|
594
|
+
if (images.length === 0) {
|
|
595
|
+
throw new Error(`Image ${imageName} was not built successfully`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
imageName = config.image;
|
|
600
|
+
}
|
|
601
|
+
// Build labels for service and container
|
|
602
|
+
const serviceLabels = {
|
|
603
|
+
"vyft.managed": "true",
|
|
604
|
+
"vyft.project": this.project,
|
|
605
|
+
"vyft.type": "service",
|
|
606
|
+
};
|
|
607
|
+
const containerLabels = {
|
|
608
|
+
"vyft.managed": "true",
|
|
609
|
+
"vyft.project": this.project,
|
|
610
|
+
};
|
|
611
|
+
if (config.route) {
|
|
612
|
+
serviceLabels["vyft.route"] = config.route;
|
|
613
|
+
}
|
|
614
|
+
const resolved = config.env
|
|
615
|
+
? await this.resolveEnv(service.id, config.env)
|
|
616
|
+
: undefined;
|
|
617
|
+
const containerSpec = {
|
|
618
|
+
Image: imageName,
|
|
619
|
+
Labels: containerLabels,
|
|
620
|
+
Command: config.command,
|
|
621
|
+
Env: resolved?.envList,
|
|
622
|
+
Mounts: config.volumes?.map(({ volume, mount }) => ({
|
|
623
|
+
Type: "volume",
|
|
624
|
+
Source: volume.id,
|
|
625
|
+
Target: mount,
|
|
626
|
+
})),
|
|
627
|
+
Secrets: resolved?.secrets.length ? resolved.secrets : undefined,
|
|
628
|
+
};
|
|
629
|
+
if (config.healthCheck) {
|
|
630
|
+
containerSpec.HealthCheck = {
|
|
631
|
+
Test: ["CMD", ...config.healthCheck.command],
|
|
632
|
+
Interval: config.healthCheck.interval
|
|
633
|
+
? parseDuration(config.healthCheck.interval)
|
|
634
|
+
: undefined,
|
|
635
|
+
Timeout: config.healthCheck.timeout
|
|
636
|
+
? parseDuration(config.healthCheck.timeout)
|
|
637
|
+
: undefined,
|
|
638
|
+
Retries: config.healthCheck.retries,
|
|
639
|
+
StartPeriod: config.healthCheck.startPeriod
|
|
640
|
+
? parseDuration(config.healthCheck.startPeriod)
|
|
641
|
+
: undefined,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
const taskTemplate = {
|
|
645
|
+
ContainerSpec: containerSpec,
|
|
646
|
+
Networks: config.route ? [{ Target: "vyft-network" }] : undefined,
|
|
647
|
+
};
|
|
648
|
+
if (config.resources) {
|
|
649
|
+
taskTemplate.Resources = {
|
|
650
|
+
Limits: {
|
|
651
|
+
MemoryBytes: config.resources.memory
|
|
652
|
+
? parseMemory(config.resources.memory)
|
|
653
|
+
: undefined,
|
|
654
|
+
NanoCPUs: config.resources.cpus
|
|
655
|
+
? config.resources.cpus * 1e9
|
|
656
|
+
: undefined,
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
if (config.restartPolicy) {
|
|
661
|
+
const conditionMap = {
|
|
662
|
+
none: "none",
|
|
663
|
+
"on-failure": "on-failure",
|
|
664
|
+
any: "any",
|
|
665
|
+
};
|
|
666
|
+
taskTemplate.RestartPolicy = {
|
|
667
|
+
Condition: conditionMap[config.restartPolicy],
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
const serviceSpec = {
|
|
671
|
+
Name: service.id,
|
|
672
|
+
Labels: serviceLabels,
|
|
673
|
+
TaskTemplate: taskTemplate,
|
|
674
|
+
Mode: { Replicated: { Replicas: config.replicas ?? 1 } },
|
|
675
|
+
// Only publish ports directly if no route (internal service or direct access)
|
|
676
|
+
EndpointSpec: !config.route && config.port
|
|
677
|
+
? {
|
|
678
|
+
Ports: [
|
|
679
|
+
{
|
|
680
|
+
Protocol: "tcp",
|
|
681
|
+
TargetPort: config.port,
|
|
682
|
+
PublishedPort: config.port,
|
|
683
|
+
PublishMode: "ingress",
|
|
684
|
+
},
|
|
685
|
+
],
|
|
686
|
+
}
|
|
687
|
+
: undefined,
|
|
688
|
+
};
|
|
689
|
+
this.log.trace({ serviceSpec }, "docker create service spec");
|
|
690
|
+
this.log.debug({ resourceId: service.id }, "docker.createService called");
|
|
691
|
+
await this.docker.createService(serviceSpec);
|
|
692
|
+
if (config.route) {
|
|
693
|
+
const port = config.port || 3000;
|
|
694
|
+
await this.addRoute(service.id, config.route, {
|
|
695
|
+
handler: "reverse_proxy",
|
|
696
|
+
upstreams: [{ dial: `${service.id}:${port}` }],
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
const durationMs = Math.round(performance.now() - start);
|
|
700
|
+
this.log.info({
|
|
701
|
+
resourceId: service.id,
|
|
702
|
+
image: imageName,
|
|
703
|
+
hasRoute: !!config.route,
|
|
704
|
+
durationMs,
|
|
705
|
+
}, "service created");
|
|
706
|
+
log.step(`Created ${service.id}`);
|
|
707
|
+
}
|
|
708
|
+
async serviceExists(id) {
|
|
709
|
+
try {
|
|
710
|
+
await this.docker.getService(id).inspect();
|
|
711
|
+
return true;
|
|
712
|
+
}
|
|
713
|
+
catch (err) {
|
|
714
|
+
this.log.debug({ err, resourceId: id }, "service does not exist");
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
async removeService(id) {
|
|
719
|
+
const start = performance.now();
|
|
720
|
+
this.log.debug({ resourceId: id }, "removing service");
|
|
721
|
+
await this.removeRoute(id);
|
|
722
|
+
await this.docker.getService(id).remove();
|
|
723
|
+
const maxWait = 60000;
|
|
724
|
+
const interval = 2000;
|
|
725
|
+
let waited = 0;
|
|
726
|
+
this.log.debug({ resourceId: id, maxWait }, "container cleanup polling started");
|
|
727
|
+
while (waited < maxWait) {
|
|
728
|
+
const containers = await this.docker.listContainers({
|
|
729
|
+
all: true,
|
|
730
|
+
filters: JSON.stringify({
|
|
731
|
+
label: [`com.docker.swarm.service.name=${id}`],
|
|
732
|
+
}),
|
|
733
|
+
});
|
|
734
|
+
if (containers.length === 0)
|
|
735
|
+
break;
|
|
736
|
+
if (waited >= maxWait * 0.8) {
|
|
737
|
+
this.log.warn({ resourceId: id, waited, maxWait, remaining: containers.length }, "cleanup polling nearing timeout");
|
|
738
|
+
}
|
|
739
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
740
|
+
waited += interval;
|
|
741
|
+
}
|
|
742
|
+
const durationMs = Math.round(performance.now() - start);
|
|
743
|
+
this.log.info({ resourceId: id, durationMs }, "service removed");
|
|
744
|
+
log.step(`Removed ${id}`);
|
|
745
|
+
}
|
|
746
|
+
async createStatic(static_) {
|
|
747
|
+
const start = performance.now();
|
|
748
|
+
this.log.debug({ resourceId: static_.id, route: static_.config.route }, "creating static site");
|
|
749
|
+
this.log.debug({ resourceId: static_.id }, "static build starting");
|
|
750
|
+
const { outputPath } = await buildStatic(static_.config.build.cwd, {
|
|
751
|
+
output: static_.config.build.output,
|
|
752
|
+
command: static_.config.build.command,
|
|
753
|
+
env: static_.config.build.env,
|
|
754
|
+
}, this.log);
|
|
755
|
+
const volumeName = `${static_.id}-files`;
|
|
756
|
+
this.log.debug({ volumeName }, "creating static volume");
|
|
757
|
+
await this.docker.createVolume({
|
|
758
|
+
Name: volumeName,
|
|
759
|
+
Labels: {
|
|
760
|
+
"vyft.managed": "true",
|
|
761
|
+
"vyft.project": this.project,
|
|
762
|
+
"vyft.type": "volume",
|
|
763
|
+
"vyft.static": static_.id,
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
this.log.debug({ resourceId: static_.id, outputPath }, "tar copy started");
|
|
767
|
+
const tarStream = tar.pack(outputPath);
|
|
768
|
+
const container = await this.docker.createContainer({
|
|
769
|
+
Image: "alpine:latest",
|
|
770
|
+
Cmd: ["sleep", "3600"],
|
|
771
|
+
HostConfig: {
|
|
772
|
+
Mounts: [{ Type: "volume", Source: volumeName, Target: "/data" }],
|
|
773
|
+
},
|
|
774
|
+
});
|
|
775
|
+
await container.start();
|
|
776
|
+
await container.putArchive(tarStream, { path: "/data" });
|
|
777
|
+
await container.remove({ force: true });
|
|
778
|
+
this.log.debug({ resourceId: static_.id }, "tar copy completed");
|
|
779
|
+
const serviceLabels = {
|
|
780
|
+
"vyft.managed": "true",
|
|
781
|
+
"vyft.project": this.project,
|
|
782
|
+
"vyft.type": "site",
|
|
783
|
+
"vyft.route": static_.config.route,
|
|
784
|
+
};
|
|
785
|
+
const containerLabels = {
|
|
786
|
+
"vyft.managed": "true",
|
|
787
|
+
"vyft.project": this.project,
|
|
788
|
+
};
|
|
789
|
+
const command = static_.config.spa
|
|
790
|
+
? [
|
|
791
|
+
"sh",
|
|
792
|
+
"-c",
|
|
793
|
+
"printf ':80 {\\nroot * /srv\\ntry_files {path} /index.html\\nfile_server\\n}\\n' > /etc/caddy/Caddyfile && caddy run --config /etc/caddy/Caddyfile --adapter caddyfile",
|
|
794
|
+
]
|
|
795
|
+
: ["caddy", "file-server", "--root", "/srv", "--listen", ":80"];
|
|
796
|
+
this.log.debug({ resourceId: static_.id }, "creating caddy service for static site");
|
|
797
|
+
await this.docker.createService({
|
|
798
|
+
Name: static_.id,
|
|
799
|
+
Labels: serviceLabels,
|
|
800
|
+
TaskTemplate: {
|
|
801
|
+
ContainerSpec: {
|
|
802
|
+
Image: "caddy:latest",
|
|
803
|
+
Command: command,
|
|
804
|
+
Mounts: [{ Type: "volume", Source: volumeName, Target: "/srv" }],
|
|
805
|
+
Labels: containerLabels,
|
|
806
|
+
},
|
|
807
|
+
Networks: [{ Target: "vyft-network" }],
|
|
808
|
+
},
|
|
809
|
+
Mode: { Replicated: { Replicas: 1 } },
|
|
810
|
+
});
|
|
811
|
+
await this.addRoute(static_.id, static_.config.route, {
|
|
812
|
+
handler: "reverse_proxy",
|
|
813
|
+
upstreams: [{ dial: `${static_.id}:80` }],
|
|
814
|
+
});
|
|
815
|
+
const durationMs = Math.round(performance.now() - start);
|
|
816
|
+
this.log.info({ resourceId: static_.id, route: static_.config.route, durationMs }, "site created");
|
|
817
|
+
log.step(`Created ${static_.id}`);
|
|
818
|
+
}
|
|
819
|
+
async removeStatic(id) {
|
|
820
|
+
const start = performance.now();
|
|
821
|
+
this.log.debug({ resourceId: id }, "removing static site");
|
|
822
|
+
await this.removeRoute(id);
|
|
823
|
+
await this.docker.getService(id).remove();
|
|
824
|
+
const maxWait = 30000;
|
|
825
|
+
const interval = 2000;
|
|
826
|
+
let waited = 0;
|
|
827
|
+
this.log.debug({ resourceId: id, maxWait }, "static cleanup polling started");
|
|
828
|
+
while (waited < maxWait) {
|
|
829
|
+
const containers = await this.docker.listContainers({
|
|
830
|
+
all: true,
|
|
831
|
+
filters: JSON.stringify({
|
|
832
|
+
label: [`com.docker.swarm.service.name=${id}`],
|
|
833
|
+
}),
|
|
834
|
+
});
|
|
835
|
+
if (containers.length === 0)
|
|
836
|
+
break;
|
|
837
|
+
if (waited >= maxWait * 0.8) {
|
|
838
|
+
this.log.warn({ resourceId: id, waited, maxWait, remaining: containers.length }, "cleanup polling nearing timeout");
|
|
839
|
+
}
|
|
840
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
841
|
+
waited += interval;
|
|
842
|
+
}
|
|
843
|
+
const volumeName = `${id}-files`;
|
|
844
|
+
try {
|
|
845
|
+
this.log.debug({ volumeName }, "removing static volume");
|
|
846
|
+
await this.docker.getVolume(volumeName).remove();
|
|
847
|
+
}
|
|
848
|
+
catch (err) {
|
|
849
|
+
this.log.debug({ err, volumeName }, "static volume removal failed (may not exist)");
|
|
850
|
+
}
|
|
851
|
+
const durationMs = Math.round(performance.now() - start);
|
|
852
|
+
this.log.info({ resourceId: id, durationMs }, "site removed");
|
|
853
|
+
log.step(`Removed ${id}`);
|
|
854
|
+
}
|
|
855
|
+
}
|