vyft 0.2.0-alpha → 0.4.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/README.md +5 -16
- package/dist/build.d.ts +1 -0
- package/dist/build.js +9 -4
- package/dist/cli.js +648 -43
- package/dist/context.d.ts +39 -0
- package/dist/context.js +101 -0
- package/dist/docker.d.ts +24 -12
- package/dist/docker.js +299 -389
- package/dist/exec.d.ts +1 -1
- package/dist/exec.js +2 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +5 -1
- package/dist/init.js +19 -2
- package/dist/interpolate.d.ts +11 -0
- package/dist/interpolate.js +11 -0
- package/dist/local/dev.d.ts +31 -0
- package/dist/local/dev.js +109 -0
- package/dist/local/index.d.ts +2 -0
- package/dist/local/index.js +2 -0
- package/dist/local/runtime.d.ts +61 -0
- package/dist/local/runtime.js +391 -0
- package/dist/proxy.d.ts +16 -0
- package/dist/proxy.js +0 -0
- package/dist/resource.d.ts +104 -1
- package/dist/resource.js +11 -1
- package/dist/runtime.d.ts +11 -1
- package/dist/services/index.d.ts +26 -0
- package/dist/services/index.js +35 -0
- package/dist/services/minio.d.ts +36 -0
- package/dist/services/minio.js +53 -0
- package/dist/services/mongo.d.ts +28 -0
- package/dist/services/mongo.js +45 -0
- package/dist/services/mysql.d.ts +28 -0
- package/dist/services/mysql.js +44 -0
- package/dist/services/nats.d.ts +26 -0
- package/dist/services/nats.js +38 -0
- package/dist/services/postgres.d.ts +28 -0
- package/dist/services/postgres.js +45 -0
- package/dist/services/rabbitmq.d.ts +28 -0
- package/dist/services/rabbitmq.js +44 -0
- package/dist/services/redis.d.ts +28 -0
- package/dist/services/redis.js +49 -0
- package/dist/services/storage.d.ts +39 -0
- package/dist/services/storage.js +94 -0
- package/dist/swarm/factories.d.ts +9 -2
- package/dist/swarm/factories.js +9 -32
- package/dist/swarm/index.d.ts +11 -2
- package/dist/swarm/proxy.d.ts +24 -0
- package/dist/swarm/proxy.js +339 -0
- package/dist/swarm/types.d.ts +11 -21
- package/dist/symbols.d.ts +7 -0
- package/dist/symbols.js +3 -0
- package/package.json +4 -5
- package/templates/fullstack/package.json +2 -6
- package/templates/fullstack/vyft.config.ts +13 -28
- package/templates/fullstack/compose.yaml +0 -14
package/dist/docker.js
CHANGED
|
@@ -1,25 +1,14 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
2
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { PassThrough } from "node:stream";
|
|
5
|
-
import { log } from "@clack/prompts";
|
|
6
5
|
import Docker from "dockerode";
|
|
6
|
+
import { Client as SshClient } from "ssh2";
|
|
7
7
|
import tar from "tar-fs";
|
|
8
8
|
import { buildStatic } from "./build.js";
|
|
9
9
|
import { logger as defaultLogger } from "./logger.js";
|
|
10
10
|
import { isInterpolation, isReference } from "./resource.js";
|
|
11
|
-
|
|
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
|
-
}
|
|
11
|
+
import { CaddyProxy } from "./swarm/proxy.js";
|
|
23
12
|
function secretMount(id, name) {
|
|
24
13
|
return {
|
|
25
14
|
SecretID: id,
|
|
@@ -67,40 +56,88 @@ export function parseRoute(route) {
|
|
|
67
56
|
path: route.slice(slashIndex),
|
|
68
57
|
};
|
|
69
58
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Create a Docker client that reuses a single SSH connection instead of
|
|
61
|
+
* opening a new one for every API call (the docker-modem default).
|
|
62
|
+
*/
|
|
63
|
+
function createSshDocker(sshUrl) {
|
|
64
|
+
const parsed = new URL(sshUrl);
|
|
65
|
+
const conn = new SshClient();
|
|
66
|
+
let connected = false;
|
|
67
|
+
const ready = new Promise((resolve, reject) => {
|
|
68
|
+
conn.once("ready", () => {
|
|
69
|
+
connected = true;
|
|
70
|
+
resolve();
|
|
71
|
+
});
|
|
72
|
+
conn.once("error", reject);
|
|
73
|
+
conn.connect({
|
|
74
|
+
host: parsed.hostname,
|
|
75
|
+
port: parsed.port ? parseInt(parsed.port, 10) : 22,
|
|
76
|
+
username: parsed.username || undefined,
|
|
77
|
+
agent: process.env.SSH_AUTH_SOCK,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
const agent = new http.Agent({ keepAlive: false });
|
|
81
|
+
// biome-ignore lint/suspicious/noExplicitAny: monkey-patching internal Agent method for SSH tunneling
|
|
82
|
+
agent.createConnection = (_opts, fn) => {
|
|
83
|
+
const exec = () => conn.exec("docker system dial-stdio", (err, stream) => {
|
|
84
|
+
if (err)
|
|
85
|
+
return fn(err);
|
|
86
|
+
fn(null, stream);
|
|
87
|
+
});
|
|
88
|
+
if (connected)
|
|
89
|
+
exec();
|
|
90
|
+
else
|
|
91
|
+
ready.then(exec).catch((err) => fn(err));
|
|
92
|
+
};
|
|
75
93
|
return {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
94
|
+
docker: new Docker({
|
|
95
|
+
protocol: "http",
|
|
96
|
+
host: parsed.hostname,
|
|
97
|
+
agent,
|
|
98
|
+
// biome-ignore lint/suspicious/noExplicitAny: dockerode typing doesn't expose agent option
|
|
99
|
+
}),
|
|
100
|
+
sshClient: conn,
|
|
80
101
|
};
|
|
81
102
|
}
|
|
82
103
|
export class DockerClient {
|
|
83
104
|
docker;
|
|
84
105
|
localDocker;
|
|
85
106
|
isRemote;
|
|
107
|
+
sshClient = null;
|
|
86
108
|
project;
|
|
87
109
|
secretValues = new Map();
|
|
110
|
+
builtImages = new Map();
|
|
88
111
|
log;
|
|
112
|
+
proxy;
|
|
89
113
|
verbose = false;
|
|
90
|
-
constructor(project,
|
|
114
|
+
constructor(project, opts) {
|
|
91
115
|
this.localDocker = new Docker({ socketPath: "/var/run/docker.sock" });
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
116
|
+
const host = opts?.host;
|
|
117
|
+
if (host?.startsWith("ssh://")) {
|
|
118
|
+
const { docker, sshClient } = createSshDocker(host);
|
|
119
|
+
this.docker = docker;
|
|
120
|
+
this.sshClient = sshClient;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
if (host)
|
|
124
|
+
process.env.DOCKER_HOST = host;
|
|
125
|
+
this.docker = new Docker();
|
|
126
|
+
}
|
|
127
|
+
this.isRemote = !!host;
|
|
97
128
|
this.project = project;
|
|
98
|
-
this.log = (parentLogger ?? defaultLogger).child({
|
|
129
|
+
this.log = (opts?.parentLogger ?? defaultLogger).child({
|
|
99
130
|
component: "docker",
|
|
100
131
|
project,
|
|
101
132
|
});
|
|
102
|
-
|
|
103
|
-
|
|
133
|
+
this.proxy = new CaddyProxy(this.docker, this.log);
|
|
134
|
+
if (host)
|
|
135
|
+
this.log.debug({ host }, "using remote endpoint");
|
|
136
|
+
}
|
|
137
|
+
/** Close the SSH connection (if any), allowing the process to exit. */
|
|
138
|
+
destroy() {
|
|
139
|
+
this.sshClient?.end();
|
|
140
|
+
this.sshClient = null;
|
|
104
141
|
}
|
|
105
142
|
async ensureNetwork(name, labels = {}) {
|
|
106
143
|
const networks = await this.docker.listNetworks({
|
|
@@ -125,247 +162,9 @@ export class DockerClient {
|
|
|
125
162
|
"vyft.managed": "true",
|
|
126
163
|
"vyft.project": this.project,
|
|
127
164
|
});
|
|
128
|
-
this.
|
|
129
|
-
const exists = await this.serviceExists(PROXY_SERVICE_NAME);
|
|
130
|
-
if (exists) {
|
|
131
|
-
this.log.debug("proxy already exists, verifying config structure");
|
|
132
|
-
try {
|
|
133
|
-
const raw = await this.caddyApiRequest("GET", "/config/");
|
|
134
|
-
const config = JSON.parse(raw);
|
|
135
|
-
if (!config?.apps?.http?.servers?.main) {
|
|
136
|
-
this.log.debug("proxy config missing servers.main, re-seeding");
|
|
137
|
-
await this.seedCaddyConfig();
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
catch {
|
|
141
|
-
this.log.debug("proxy config check failed, re-seeding");
|
|
142
|
-
await this.seedCaddyConfig();
|
|
143
|
-
}
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
// Create shared proxy network
|
|
147
|
-
await this.ensureNetwork("vyft-network", { "vyft.infrastructure": "true" });
|
|
148
|
-
this.log.debug("creating proxy service");
|
|
149
|
-
await this.docker.createService({
|
|
150
|
-
Name: PROXY_SERVICE_NAME,
|
|
151
|
-
Labels: { "vyft.infrastructure": "true" },
|
|
152
|
-
TaskTemplate: {
|
|
153
|
-
ContainerSpec: {
|
|
154
|
-
Image: "caddy:latest",
|
|
155
|
-
Command: ["caddy", "run", "--resume"],
|
|
156
|
-
Mounts: [
|
|
157
|
-
{
|
|
158
|
-
Type: "volume",
|
|
159
|
-
Source: "vyft-proxy-config",
|
|
160
|
-
Target: "/config",
|
|
161
|
-
},
|
|
162
|
-
{
|
|
163
|
-
Type: "volume",
|
|
164
|
-
Source: "vyft-proxy-data",
|
|
165
|
-
Target: "/data",
|
|
166
|
-
},
|
|
167
|
-
],
|
|
168
|
-
Labels: { "vyft.infrastructure": "true" },
|
|
169
|
-
},
|
|
170
|
-
Networks: [{ Target: "vyft-network" }],
|
|
171
|
-
},
|
|
172
|
-
EndpointSpec: {
|
|
173
|
-
Ports: [
|
|
174
|
-
{
|
|
175
|
-
Protocol: "tcp",
|
|
176
|
-
TargetPort: 80,
|
|
177
|
-
PublishedPort: 80,
|
|
178
|
-
PublishMode: "ingress",
|
|
179
|
-
},
|
|
180
|
-
{
|
|
181
|
-
Protocol: "tcp",
|
|
182
|
-
TargetPort: 443,
|
|
183
|
-
PublishedPort: 443,
|
|
184
|
-
PublishMode: "ingress",
|
|
185
|
-
},
|
|
186
|
-
],
|
|
187
|
-
},
|
|
188
|
-
Mode: { Replicated: { Replicas: 1 } },
|
|
189
|
-
});
|
|
190
|
-
log.step("Created vyft-proxy");
|
|
191
|
-
await this.seedCaddyConfig();
|
|
165
|
+
await this.proxy.ensure();
|
|
192
166
|
const durationMs = Math.round(performance.now() - start);
|
|
193
|
-
this.log.info({
|
|
194
|
-
}
|
|
195
|
-
async findProxyContainer() {
|
|
196
|
-
const containers = await this.docker.listContainers({
|
|
197
|
-
filters: JSON.stringify({
|
|
198
|
-
label: [`com.docker.swarm.service.name=${PROXY_SERVICE_NAME}`],
|
|
199
|
-
}),
|
|
200
|
-
});
|
|
201
|
-
if (containers.length === 0) {
|
|
202
|
-
throw new Error("vyft-proxy container not found");
|
|
203
|
-
}
|
|
204
|
-
return this.docker.getContainer(containers[0].Id);
|
|
205
|
-
}
|
|
206
|
-
async caddyApiRequest(method, path, body) {
|
|
207
|
-
const start = performance.now();
|
|
208
|
-
this.log.debug({ method, path }, "caddy request started");
|
|
209
|
-
const container = await this.findProxyContainer();
|
|
210
|
-
let cmd;
|
|
211
|
-
if (method === "POST") {
|
|
212
|
-
const bodyStr = body ? JSON.stringify(body) : "";
|
|
213
|
-
cmd = [
|
|
214
|
-
"wget",
|
|
215
|
-
"-q",
|
|
216
|
-
"-O",
|
|
217
|
-
"-",
|
|
218
|
-
"--timeout=10",
|
|
219
|
-
"--header=Content-Type: application/json",
|
|
220
|
-
`--post-data=${bodyStr}`,
|
|
221
|
-
`http://127.0.0.1:2019${path}`,
|
|
222
|
-
];
|
|
223
|
-
}
|
|
224
|
-
else if (method === "DELETE") {
|
|
225
|
-
cmd = [
|
|
226
|
-
"curl",
|
|
227
|
-
"-s",
|
|
228
|
-
"--connect-timeout",
|
|
229
|
-
"5",
|
|
230
|
-
"--max-time",
|
|
231
|
-
"10",
|
|
232
|
-
"-X",
|
|
233
|
-
"DELETE",
|
|
234
|
-
`http://127.0.0.1:2019${path}`,
|
|
235
|
-
];
|
|
236
|
-
}
|
|
237
|
-
else {
|
|
238
|
-
cmd = [
|
|
239
|
-
"wget",
|
|
240
|
-
"-q",
|
|
241
|
-
"-O",
|
|
242
|
-
"-",
|
|
243
|
-
"--timeout=10",
|
|
244
|
-
`http://127.0.0.1:2019${path}`,
|
|
245
|
-
];
|
|
246
|
-
}
|
|
247
|
-
this.log.trace({ cmd }, "caddy exec command");
|
|
248
|
-
const exec = await container.exec({
|
|
249
|
-
Cmd: cmd,
|
|
250
|
-
AttachStdout: true,
|
|
251
|
-
AttachStderr: true,
|
|
252
|
-
});
|
|
253
|
-
const stream = await exec.start({});
|
|
254
|
-
return new Promise((resolve, reject) => {
|
|
255
|
-
const stdout = [];
|
|
256
|
-
const stderr = [];
|
|
257
|
-
const stdoutStream = new PassThrough();
|
|
258
|
-
const stderrStream = new PassThrough();
|
|
259
|
-
stdoutStream.on("data", (chunk) => stdout.push(chunk));
|
|
260
|
-
stderrStream.on("data", (chunk) => stderr.push(chunk));
|
|
261
|
-
this.docker.modem.demuxStream(stream, stdoutStream, stderrStream);
|
|
262
|
-
stream.on("end", () => {
|
|
263
|
-
const output = Buffer.concat(stdout).toString();
|
|
264
|
-
const errOutput = Buffer.concat(stderr).toString();
|
|
265
|
-
const durationMs = Math.round(performance.now() - start);
|
|
266
|
-
this.log.trace({ stdoutBytes: output.length, stderrBytes: errOutput.length }, "caddy exec output");
|
|
267
|
-
if (errOutput) {
|
|
268
|
-
this.log.debug({ method, path, durationMs, stderr: errOutput }, "caddy request failed");
|
|
269
|
-
reject(new Error(`Caddy API ${method} ${path} failed: ${errOutput}`));
|
|
270
|
-
}
|
|
271
|
-
else {
|
|
272
|
-
this.log.debug({ method, path, durationMs }, "caddy request completed");
|
|
273
|
-
resolve(output);
|
|
274
|
-
}
|
|
275
|
-
});
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
async seedCaddyConfig() {
|
|
279
|
-
const maxWait = 30000;
|
|
280
|
-
const interval = 2000;
|
|
281
|
-
let waited = 0;
|
|
282
|
-
this.log.debug("waiting for proxy container");
|
|
283
|
-
while (waited < maxWait) {
|
|
284
|
-
try {
|
|
285
|
-
await this.findProxyContainer();
|
|
286
|
-
break;
|
|
287
|
-
}
|
|
288
|
-
catch (err) {
|
|
289
|
-
this.log.debug({ err, waited, maxWait }, "proxy container not ready");
|
|
290
|
-
await new Promise((r) => setTimeout(r, interval));
|
|
291
|
-
waited += interval;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
const baseConfig = {
|
|
295
|
-
logging: {
|
|
296
|
-
logs: {
|
|
297
|
-
default: {
|
|
298
|
-
level: "INFO",
|
|
299
|
-
},
|
|
300
|
-
},
|
|
301
|
-
},
|
|
302
|
-
apps: {
|
|
303
|
-
http: {
|
|
304
|
-
servers: {
|
|
305
|
-
main: {
|
|
306
|
-
listen: [":443", ":80"],
|
|
307
|
-
routes: [],
|
|
308
|
-
logs: {},
|
|
309
|
-
},
|
|
310
|
-
},
|
|
311
|
-
},
|
|
312
|
-
},
|
|
313
|
-
};
|
|
314
|
-
const maxAttempts = 5;
|
|
315
|
-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
316
|
-
try {
|
|
317
|
-
this.log.debug({ attempt: attempt + 1, max: maxAttempts }, "seeding caddy config");
|
|
318
|
-
await this.caddyApiRequest("POST", "/load", baseConfig);
|
|
319
|
-
this.log.debug("caddy config seeded");
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
catch (err) {
|
|
323
|
-
this.log.debug({ err, attempt: attempt + 1, max: maxAttempts }, "seed attempt failed");
|
|
324
|
-
if (attempt >= Math.floor(maxAttempts * 0.8)) {
|
|
325
|
-
this.log.warn({ attempt: attempt + 1, max: maxAttempts }, "seed retry nearing limit");
|
|
326
|
-
}
|
|
327
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
this.log.error("caddy config seed exhausted retries");
|
|
331
|
-
throw new Error("Failed to seed Caddy config after retries");
|
|
332
|
-
}
|
|
333
|
-
async addRoute(resourceId, route, handler) {
|
|
334
|
-
this.log.debug({ resourceId, route }, "adding route");
|
|
335
|
-
const caddyRoute = buildCaddyRoute(resourceId, route, handler);
|
|
336
|
-
// Delete existing route for idempotency
|
|
337
|
-
try {
|
|
338
|
-
await this.caddyApiRequest("DELETE", `/id/vyft-${resourceId}`);
|
|
339
|
-
}
|
|
340
|
-
catch (err) {
|
|
341
|
-
this.log.debug({ err, resourceId }, "idempotent route delete failed");
|
|
342
|
-
}
|
|
343
|
-
const maxAttempts = 5;
|
|
344
|
-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
345
|
-
try {
|
|
346
|
-
await this.caddyApiRequest("POST", "/config/apps/http/servers/main/routes", caddyRoute);
|
|
347
|
-
this.log.debug({ resourceId, route }, "route added");
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
catch (err) {
|
|
351
|
-
this.log.debug({ err, resourceId, attempt: attempt + 1, max: maxAttempts }, "route add attempt failed");
|
|
352
|
-
if (attempt === maxAttempts - 1) {
|
|
353
|
-
this.log.error({ resourceId, route }, "route add exhausted retries");
|
|
354
|
-
throw new Error(`Failed to add route for ${resourceId}`);
|
|
355
|
-
}
|
|
356
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
async removeRoute(resourceId) {
|
|
361
|
-
this.log.debug({ resourceId }, "removing route");
|
|
362
|
-
try {
|
|
363
|
-
await this.caddyApiRequest("DELETE", `/id/vyft-${resourceId}`);
|
|
364
|
-
this.log.debug({ resourceId }, "route removed");
|
|
365
|
-
}
|
|
366
|
-
catch (err) {
|
|
367
|
-
this.log.debug({ err, resourceId }, "route removal failed (may not exist)");
|
|
368
|
-
}
|
|
167
|
+
this.log.info({ durationMs }, "infrastructure ready");
|
|
369
168
|
}
|
|
370
169
|
async listManagedResources() {
|
|
371
170
|
const resources = [];
|
|
@@ -413,7 +212,7 @@ export class DockerClient {
|
|
|
413
212
|
}
|
|
414
213
|
return resources;
|
|
415
214
|
}
|
|
416
|
-
async create(resource) {
|
|
215
|
+
async create(resource, options) {
|
|
417
216
|
switch (resource.type) {
|
|
418
217
|
case "volume":
|
|
419
218
|
return this.createVolume(resource);
|
|
@@ -422,7 +221,7 @@ export class DockerClient {
|
|
|
422
221
|
case "service":
|
|
423
222
|
return this.createService(resource);
|
|
424
223
|
case "site":
|
|
425
|
-
return this.createStatic(resource);
|
|
224
|
+
return this.createStatic(resource, options?.silent);
|
|
426
225
|
}
|
|
427
226
|
}
|
|
428
227
|
async exists(resource) {
|
|
@@ -465,9 +264,9 @@ export class DockerClient {
|
|
|
465
264
|
Name: volume.id,
|
|
466
265
|
Labels: labels,
|
|
467
266
|
});
|
|
468
|
-
const
|
|
469
|
-
this.log.debug({ volumeName: volume.id, durationMs }, "volume created");
|
|
470
|
-
|
|
267
|
+
const duration = Math.round(performance.now() - start);
|
|
268
|
+
this.log.debug({ volumeName: volume.id, durationMs: duration }, "volume created");
|
|
269
|
+
return { action: "created", duration };
|
|
471
270
|
}
|
|
472
271
|
async volumeExists(id) {
|
|
473
272
|
const start = performance.now();
|
|
@@ -489,7 +288,6 @@ export class DockerClient {
|
|
|
489
288
|
await this.docker.getVolume(id).remove();
|
|
490
289
|
const durationMs = Math.round(performance.now() - start);
|
|
491
290
|
this.log.debug({ volumeName: id, durationMs }, "volume removed");
|
|
492
|
-
log.step(`Removed ${id}`);
|
|
493
291
|
}
|
|
494
292
|
async createSecret(secret) {
|
|
495
293
|
const start = performance.now();
|
|
@@ -500,9 +298,9 @@ export class DockerClient {
|
|
|
500
298
|
await this.storeSecretData(secret.id, value, {
|
|
501
299
|
"vyft.length": length.toString(),
|
|
502
300
|
});
|
|
503
|
-
const
|
|
504
|
-
this.log.debug({ secretName: secret.id, length, durationMs }, "secret created");
|
|
505
|
-
|
|
301
|
+
const duration = Math.round(performance.now() - start);
|
|
302
|
+
this.log.debug({ secretName: secret.id, length, durationMs: duration }, "secret created");
|
|
303
|
+
return { action: "created", duration };
|
|
506
304
|
}
|
|
507
305
|
async secretExists(id) {
|
|
508
306
|
const start = performance.now();
|
|
@@ -524,7 +322,6 @@ export class DockerClient {
|
|
|
524
322
|
await this.docker.getSecret(id).remove();
|
|
525
323
|
const durationMs = Math.round(performance.now() - start);
|
|
526
324
|
this.log.debug({ secretName: id, durationMs }, "secret removed");
|
|
527
|
-
log.step(`Removed ${id}`);
|
|
528
325
|
}
|
|
529
326
|
async storeSecretData(name, value, labels = {}) {
|
|
530
327
|
this.log.debug({ secretName: name }, "storing secret data");
|
|
@@ -551,7 +348,6 @@ export class DockerClient {
|
|
|
551
348
|
"vyft.derived": "true",
|
|
552
349
|
"vyft.parent-service": parentService,
|
|
553
350
|
});
|
|
554
|
-
log.step(`Created ${name}`);
|
|
555
351
|
}
|
|
556
352
|
async resolveEnv(serviceId, env) {
|
|
557
353
|
const envList = [];
|
|
@@ -609,60 +405,70 @@ export class DockerClient {
|
|
|
609
405
|
this.log.debug({ serviceId, envCount: envList.length, secretMountCount: secrets.length }, "env resolved");
|
|
610
406
|
return { envList, secrets };
|
|
611
407
|
}
|
|
408
|
+
async buildImage(service) {
|
|
409
|
+
const config = service.config;
|
|
410
|
+
if (typeof config.image !== "object")
|
|
411
|
+
return null;
|
|
412
|
+
const { context = ".", dockerfile = "Dockerfile" } = config.image;
|
|
413
|
+
const buildTag = `${service.id}:latest`;
|
|
414
|
+
this.log.debug({ resourceId: service.id, context, dockerfile }, "image build started");
|
|
415
|
+
const buildStart = performance.now();
|
|
416
|
+
const tarStream = tar.pack(path.resolve(context));
|
|
417
|
+
const stream = await this.localDocker.buildImage(tarStream, {
|
|
418
|
+
t: buildTag,
|
|
419
|
+
dockerfile,
|
|
420
|
+
});
|
|
421
|
+
await new Promise((resolve, reject) => {
|
|
422
|
+
this.localDocker.modem.followProgress(stream, (err) => (err ? reject(err) : resolve()), (event) => {
|
|
423
|
+
if (event.error)
|
|
424
|
+
this.log.error({ error: event.error }, "build error");
|
|
425
|
+
else if (this.verbose && event.stream)
|
|
426
|
+
process.stdout.write(event.stream);
|
|
427
|
+
this.log.trace({ event }, "build progress");
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
const buildDurationMs = Math.round(performance.now() - buildStart);
|
|
431
|
+
this.log.debug({
|
|
432
|
+
resourceId: service.id,
|
|
433
|
+
image: buildTag,
|
|
434
|
+
durationMs: buildDurationMs,
|
|
435
|
+
}, "image build completed");
|
|
436
|
+
const images = await this.localDocker.listImages({
|
|
437
|
+
filters: JSON.stringify({ reference: [buildTag] }),
|
|
438
|
+
});
|
|
439
|
+
if (images.length === 0) {
|
|
440
|
+
throw new Error(`Image ${buildTag} was not built successfully`);
|
|
441
|
+
}
|
|
442
|
+
// Tag with content hash so the orchestrator detects the new image
|
|
443
|
+
const shortHash = images[0].Id.replace("sha256:", "").slice(0, 12);
|
|
444
|
+
const imageName = `${service.id}:${shortHash}`;
|
|
445
|
+
await this.localDocker.getImage(buildTag).tag({
|
|
446
|
+
repo: service.id,
|
|
447
|
+
tag: shortHash,
|
|
448
|
+
});
|
|
449
|
+
if (this.isRemote) {
|
|
450
|
+
this.log.debug({ image: imageName }, "transferring image to remote");
|
|
451
|
+
const transferStart = performance.now();
|
|
452
|
+
const imageStream = await this.localDocker.getImage(imageName).get();
|
|
453
|
+
await this.docker.loadImage(imageStream);
|
|
454
|
+
const transferDurationMs = Math.round(performance.now() - transferStart);
|
|
455
|
+
this.log.debug({ image: imageName, durationMs: transferDurationMs }, "image transferred to remote");
|
|
456
|
+
}
|
|
457
|
+
this.builtImages.set(service.id, imageName);
|
|
458
|
+
return imageName;
|
|
459
|
+
}
|
|
612
460
|
async createService(service) {
|
|
613
461
|
const start = performance.now();
|
|
614
|
-
await this.ensureNetwork(`${this.project}-internal`, {
|
|
615
|
-
"com.docker.stack.namespace": this.project,
|
|
616
|
-
"vyft.managed": "true",
|
|
617
|
-
"vyft.project": this.project,
|
|
618
|
-
});
|
|
619
462
|
const config = service.config;
|
|
620
463
|
let imageName;
|
|
621
464
|
if (typeof config.image === "object") {
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
const buildStart = performance.now();
|
|
626
|
-
const tarStream = tar.pack(path.resolve(context));
|
|
627
|
-
const stream = await this.localDocker.buildImage(tarStream, {
|
|
628
|
-
t: buildTag,
|
|
629
|
-
dockerfile,
|
|
630
|
-
});
|
|
631
|
-
await new Promise((resolve, reject) => {
|
|
632
|
-
this.localDocker.modem.followProgress(stream, (err) => (err ? reject(err) : resolve()), (event) => {
|
|
633
|
-
if (event.error)
|
|
634
|
-
log.error(event.error);
|
|
635
|
-
else if (this.verbose && event.stream)
|
|
636
|
-
process.stdout.write(event.stream);
|
|
637
|
-
this.log.trace({ event }, "build progress");
|
|
638
|
-
});
|
|
639
|
-
});
|
|
640
|
-
const buildDurationMs = Math.round(performance.now() - buildStart);
|
|
641
|
-
this.log.debug({
|
|
642
|
-
resourceId: service.id,
|
|
643
|
-
image: buildTag,
|
|
644
|
-
durationMs: buildDurationMs,
|
|
645
|
-
}, "image build completed");
|
|
646
|
-
const images = await this.localDocker.listImages({
|
|
647
|
-
filters: JSON.stringify({ reference: [buildTag] }),
|
|
648
|
-
});
|
|
649
|
-
if (images.length === 0) {
|
|
650
|
-
throw new Error(`Image ${buildTag} was not built successfully`);
|
|
465
|
+
const prebuilt = this.builtImages.get(service.id);
|
|
466
|
+
if (prebuilt) {
|
|
467
|
+
imageName = prebuilt;
|
|
651
468
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
await this.localDocker.getImage(buildTag).tag({
|
|
656
|
-
repo: service.id,
|
|
657
|
-
tag: shortHash,
|
|
658
|
-
});
|
|
659
|
-
if (this.isRemote) {
|
|
660
|
-
this.log.debug({ image: imageName }, "transferring image to remote");
|
|
661
|
-
const transferStart = performance.now();
|
|
662
|
-
const imageStream = await this.localDocker.getImage(imageName).get();
|
|
663
|
-
await this.docker.loadImage(imageStream);
|
|
664
|
-
const transferDurationMs = Math.round(performance.now() - transferStart);
|
|
665
|
-
this.log.debug({ image: imageName, durationMs: transferDurationMs }, "image transferred to remote");
|
|
469
|
+
else {
|
|
470
|
+
await this.buildImage(service);
|
|
471
|
+
imageName = this.builtImages.get(service.id);
|
|
666
472
|
}
|
|
667
473
|
}
|
|
668
474
|
else {
|
|
@@ -781,19 +587,23 @@ export class DockerClient {
|
|
|
781
587
|
}
|
|
782
588
|
if (config.route) {
|
|
783
589
|
const port = config.port || 3000;
|
|
784
|
-
await this.addRoute(service.id, config.route, {
|
|
785
|
-
|
|
786
|
-
|
|
590
|
+
await this.proxy.addRoute(service.id, config.route, {
|
|
591
|
+
host: service.id,
|
|
592
|
+
port,
|
|
787
593
|
});
|
|
788
594
|
}
|
|
789
|
-
const
|
|
595
|
+
const duration = Math.round(performance.now() - start);
|
|
790
596
|
this.log.info({
|
|
791
597
|
resourceId: service.id,
|
|
792
598
|
image: imageName,
|
|
793
599
|
hasRoute: !!config.route,
|
|
794
|
-
durationMs,
|
|
600
|
+
durationMs: duration,
|
|
795
601
|
}, existing ? "service updated" : "service created");
|
|
796
|
-
|
|
602
|
+
return {
|
|
603
|
+
action: existing ? "updated" : "created",
|
|
604
|
+
route: config.route,
|
|
605
|
+
duration,
|
|
606
|
+
};
|
|
797
607
|
}
|
|
798
608
|
async serviceExists(id) {
|
|
799
609
|
try {
|
|
@@ -805,10 +615,27 @@ export class DockerClient {
|
|
|
805
615
|
return false;
|
|
806
616
|
}
|
|
807
617
|
}
|
|
618
|
+
async waitForHealthy(id, timeoutMs = 120000) {
|
|
619
|
+
const start = performance.now();
|
|
620
|
+
const interval = 2000;
|
|
621
|
+
this.log.debug({ resourceId: id }, "waiting for service to be healthy");
|
|
622
|
+
while (performance.now() - start < timeoutMs) {
|
|
623
|
+
const tasks = await this.docker.listTasks({
|
|
624
|
+
filters: { service: [id], "desired-state": ["running"] },
|
|
625
|
+
});
|
|
626
|
+
const healthy = tasks.some((t) => t.Status.State === "running");
|
|
627
|
+
if (healthy) {
|
|
628
|
+
this.log.debug({ resourceId: id }, "service is healthy");
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
632
|
+
}
|
|
633
|
+
throw new Error(`Service ${id} did not become healthy within ${timeoutMs}ms`);
|
|
634
|
+
}
|
|
808
635
|
async removeService(id) {
|
|
809
636
|
const start = performance.now();
|
|
810
637
|
this.log.debug({ resourceId: id }, "removing service");
|
|
811
|
-
await this.removeRoute(id);
|
|
638
|
+
await this.proxy.removeRoute(id);
|
|
812
639
|
await this.docker.getService(id).remove();
|
|
813
640
|
const maxWait = 60000;
|
|
814
641
|
const interval = 2000;
|
|
@@ -831,7 +658,6 @@ export class DockerClient {
|
|
|
831
658
|
}
|
|
832
659
|
const durationMs = Math.round(performance.now() - start);
|
|
833
660
|
this.log.info({ resourceId: id, durationMs }, "service removed");
|
|
834
|
-
log.step(`Removed ${id}`);
|
|
835
661
|
}
|
|
836
662
|
async pullImage(image) {
|
|
837
663
|
this.log.debug({ image }, "pulling image");
|
|
@@ -841,19 +667,15 @@ export class DockerClient {
|
|
|
841
667
|
});
|
|
842
668
|
this.log.debug({ image }, "image pulled");
|
|
843
669
|
}
|
|
844
|
-
async createStatic(static_) {
|
|
670
|
+
async createStatic(static_, silent) {
|
|
845
671
|
const start = performance.now();
|
|
846
|
-
await this.ensureNetwork(`${this.project}-internal`, {
|
|
847
|
-
"com.docker.stack.namespace": this.project,
|
|
848
|
-
"vyft.managed": "true",
|
|
849
|
-
"vyft.project": this.project,
|
|
850
|
-
});
|
|
851
672
|
this.log.debug({ resourceId: static_.id, route: static_.config.route }, "creating static site");
|
|
852
673
|
this.log.debug({ resourceId: static_.id }, "static build starting");
|
|
853
674
|
const { outputPath } = await buildStatic(static_.config.build.cwd, {
|
|
854
675
|
output: static_.config.build.output,
|
|
855
676
|
command: static_.config.build.command,
|
|
856
677
|
env: static_.config.build.env,
|
|
678
|
+
silent,
|
|
857
679
|
}, this.log);
|
|
858
680
|
const volumeName = `${static_.id}-files`;
|
|
859
681
|
if (!(await this.volumeExists(volumeName))) {
|
|
@@ -901,13 +723,13 @@ export class DockerClient {
|
|
|
901
723
|
"vyft.managed": "true",
|
|
902
724
|
"vyft.project": this.project,
|
|
903
725
|
};
|
|
904
|
-
const command = static_.config.spa
|
|
905
|
-
? [
|
|
726
|
+
const command = static_.config.spa === false
|
|
727
|
+
? ["caddy", "file-server", "--root", "/srv", "--listen", ":80"]
|
|
728
|
+
: [
|
|
906
729
|
"sh",
|
|
907
730
|
"-c",
|
|
908
731
|
"printf ':80 {\\nroot * /srv\\ntry_files {path} /index.html\\nfile_server\\n}\\n' > /etc/caddy/Caddyfile && caddy run --config /etc/caddy/Caddyfile --adapter caddyfile",
|
|
909
|
-
]
|
|
910
|
-
: ["caddy", "file-server", "--root", "/srv", "--listen", ":80"];
|
|
732
|
+
];
|
|
911
733
|
const serviceSpec = {
|
|
912
734
|
Name: static_.id,
|
|
913
735
|
Labels: serviceLabels,
|
|
@@ -945,18 +767,26 @@ export class DockerClient {
|
|
|
945
767
|
this.log.debug({ resourceId: static_.id }, "creating caddy service for static site");
|
|
946
768
|
await this.docker.createService(serviceSpec);
|
|
947
769
|
}
|
|
948
|
-
await this.addRoute(static_.id, static_.config.route, {
|
|
949
|
-
|
|
950
|
-
|
|
770
|
+
await this.proxy.addRoute(static_.id, static_.config.route, {
|
|
771
|
+
host: static_.id,
|
|
772
|
+
port: 80,
|
|
951
773
|
});
|
|
952
|
-
const
|
|
953
|
-
this.log.info({
|
|
954
|
-
|
|
774
|
+
const duration = Math.round(performance.now() - start);
|
|
775
|
+
this.log.info({
|
|
776
|
+
resourceId: static_.id,
|
|
777
|
+
route: static_.config.route,
|
|
778
|
+
durationMs: duration,
|
|
779
|
+
}, existing ? "site updated" : "site created");
|
|
780
|
+
return {
|
|
781
|
+
action: existing ? "updated" : "created",
|
|
782
|
+
route: static_.config.route,
|
|
783
|
+
duration,
|
|
784
|
+
};
|
|
955
785
|
}
|
|
956
786
|
async removeStatic(id) {
|
|
957
787
|
const start = performance.now();
|
|
958
788
|
this.log.debug({ resourceId: id }, "removing static site");
|
|
959
|
-
await this.removeRoute(id);
|
|
789
|
+
await this.proxy.removeRoute(id);
|
|
960
790
|
await this.docker.getService(id).remove();
|
|
961
791
|
const maxWait = 30000;
|
|
962
792
|
const interval = 2000;
|
|
@@ -987,12 +817,92 @@ export class DockerClient {
|
|
|
987
817
|
}
|
|
988
818
|
const durationMs = Math.round(performance.now() - start);
|
|
989
819
|
this.log.info({ resourceId: id, durationMs }, "site removed");
|
|
990
|
-
log.step(`Removed ${id}`);
|
|
991
820
|
}
|
|
992
|
-
async
|
|
993
|
-
const
|
|
821
|
+
async *serviceLogs(options) {
|
|
822
|
+
const allServices = await this.docker.listServices({
|
|
823
|
+
filters: JSON.stringify({
|
|
824
|
+
label: ["vyft.managed=true", `vyft.project=${this.project}`],
|
|
825
|
+
}),
|
|
826
|
+
});
|
|
827
|
+
const prefix = `${this.project}-`;
|
|
828
|
+
let matched = allServices;
|
|
829
|
+
if (options.services && options.services.length > 0) {
|
|
830
|
+
const filter = new Set(options.services);
|
|
831
|
+
matched = allServices.filter((svc) => {
|
|
832
|
+
const name = svc.Spec?.Name || "";
|
|
833
|
+
const short = name.startsWith(prefix)
|
|
834
|
+
? name.slice(prefix.length)
|
|
835
|
+
: name;
|
|
836
|
+
return filter.has(short) || filter.has(name);
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
if (matched.length === 0) {
|
|
840
|
+
this.log.warn("no matching services found");
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
994
843
|
const tail = options.tail ?? 100;
|
|
995
|
-
|
|
844
|
+
// Collect all containers with their short service name (parallel)
|
|
845
|
+
const targets = (await Promise.all(matched.map(async (svc) => {
|
|
846
|
+
const name = svc.Spec?.Name || "";
|
|
847
|
+
const short = name.startsWith(prefix)
|
|
848
|
+
? name.slice(prefix.length)
|
|
849
|
+
: name;
|
|
850
|
+
const containers = await this.docker.listContainers({
|
|
851
|
+
filters: JSON.stringify({
|
|
852
|
+
label: [`com.docker.swarm.service.name=${name}`],
|
|
853
|
+
}),
|
|
854
|
+
});
|
|
855
|
+
return containers.map((cInfo) => ({
|
|
856
|
+
container: this.docker.getContainer(cInfo.Id),
|
|
857
|
+
short,
|
|
858
|
+
}));
|
|
859
|
+
}))).flat();
|
|
860
|
+
if (!options.follow) {
|
|
861
|
+
// Fetch all logs in parallel
|
|
862
|
+
const results = await Promise.all(targets.map(async ({ container, short }) => {
|
|
863
|
+
const buf = await container.logs({
|
|
864
|
+
stdout: true,
|
|
865
|
+
stderr: true,
|
|
866
|
+
follow: false,
|
|
867
|
+
tail,
|
|
868
|
+
timestamps: true,
|
|
869
|
+
});
|
|
870
|
+
const entries = [];
|
|
871
|
+
let offset = 0;
|
|
872
|
+
while (offset + 8 <= buf.length) {
|
|
873
|
+
const type = buf[offset];
|
|
874
|
+
const size = buf.readUInt32BE(offset + 4);
|
|
875
|
+
offset += 8;
|
|
876
|
+
const text = buf.subarray(offset, offset + size).toString();
|
|
877
|
+
entries.push({
|
|
878
|
+
service: short,
|
|
879
|
+
stream: type === 2 ? "stderr" : "stdout",
|
|
880
|
+
text,
|
|
881
|
+
});
|
|
882
|
+
offset += size;
|
|
883
|
+
}
|
|
884
|
+
return entries;
|
|
885
|
+
}));
|
|
886
|
+
for (const entries of results) {
|
|
887
|
+
for (const entry of entries) {
|
|
888
|
+
yield entry;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
// Follow mode: merge all container streams concurrently
|
|
894
|
+
const buffer = [];
|
|
895
|
+
let notify = null;
|
|
896
|
+
let active = targets.length;
|
|
897
|
+
const push = (entry) => {
|
|
898
|
+
buffer.push(entry);
|
|
899
|
+
if (notify) {
|
|
900
|
+
const n = notify;
|
|
901
|
+
notify = null;
|
|
902
|
+
n();
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
await Promise.all(targets.map(async ({ container, short }) => {
|
|
996
906
|
const stream = await container.logs({
|
|
997
907
|
stdout: true,
|
|
998
908
|
stderr: true,
|
|
@@ -1000,38 +910,38 @@ export class DockerClient {
|
|
|
1000
910
|
tail,
|
|
1001
911
|
timestamps: true,
|
|
1002
912
|
});
|
|
1003
|
-
const
|
|
1004
|
-
const
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
});
|
|
1011
|
-
}
|
|
1012
|
-
else {
|
|
1013
|
-
const buf = await container.logs({
|
|
1014
|
-
stdout: true,
|
|
1015
|
-
stderr: true,
|
|
1016
|
-
follow: false,
|
|
1017
|
-
tail,
|
|
1018
|
-
timestamps: true,
|
|
913
|
+
const stdoutPT = new PassThrough();
|
|
914
|
+
const stderrPT = new PassThrough();
|
|
915
|
+
stdoutPT.on("data", (chunk) => {
|
|
916
|
+
for (const line of chunk.toString().split("\n")) {
|
|
917
|
+
if (line)
|
|
918
|
+
push({ service: short, stream: "stdout", text: line });
|
|
919
|
+
}
|
|
1019
920
|
});
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
const type = buf[offset];
|
|
1025
|
-
const size = buf.readUInt32BE(offset + 4);
|
|
1026
|
-
offset += 8;
|
|
1027
|
-
const payload = buf.subarray(offset, offset + size);
|
|
1028
|
-
if (type === 2) {
|
|
1029
|
-
process.stderr.write(payload);
|
|
921
|
+
stderrPT.on("data", (chunk) => {
|
|
922
|
+
for (const line of chunk.toString().split("\n")) {
|
|
923
|
+
if (line)
|
|
924
|
+
push({ service: short, stream: "stderr", text: line });
|
|
1030
925
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
926
|
+
});
|
|
927
|
+
this.docker.modem.demuxStream(stream, stdoutPT, stderrPT);
|
|
928
|
+
stream.on("end", () => {
|
|
929
|
+
active--;
|
|
930
|
+
if (notify && active === 0) {
|
|
931
|
+
const n = notify;
|
|
932
|
+
notify = null;
|
|
933
|
+
n();
|
|
1033
934
|
}
|
|
1034
|
-
|
|
935
|
+
});
|
|
936
|
+
}));
|
|
937
|
+
while (active > 0 || buffer.length > 0) {
|
|
938
|
+
if (buffer.length === 0) {
|
|
939
|
+
await new Promise((r) => {
|
|
940
|
+
notify = r;
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
while (buffer.length > 0) {
|
|
944
|
+
yield buffer.shift();
|
|
1035
945
|
}
|
|
1036
946
|
}
|
|
1037
947
|
}
|