@wpelevator/tug 0.2.1 → 0.4.0

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.
Files changed (50) hide show
  1. package/README.md +38 -4
  2. package/dist/agent.d.ts +66 -6
  3. package/dist/agent.js +851 -170
  4. package/dist/archive.js +9 -3
  5. package/dist/artifacts.js +97 -34
  6. package/dist/build.d.ts +1 -1
  7. package/dist/build.js +17 -8
  8. package/dist/cli-build.js +34 -8
  9. package/dist/cli-config.js +16 -4
  10. package/dist/cli-install.d.ts +10 -0
  11. package/dist/cli-install.js +38 -4
  12. package/dist/cli-live-transport.d.ts +3 -0
  13. package/dist/cli-live-transport.js +230 -0
  14. package/dist/cli-live.d.ts +77 -0
  15. package/dist/cli-live.js +249 -0
  16. package/dist/cli.js +408 -90
  17. package/dist/compose.d.ts +35 -9
  18. package/dist/compose.js +240 -132
  19. package/dist/container-spec.d.ts +56 -0
  20. package/dist/container-spec.js +359 -0
  21. package/dist/control.d.ts +5 -3
  22. package/dist/control.js +1047 -146
  23. package/dist/db.d.ts +1 -0
  24. package/dist/db.js +7 -1
  25. package/dist/docker-api.d.ts +232 -0
  26. package/dist/docker-api.js +416 -0
  27. package/dist/ingress.d.ts +16 -8
  28. package/dist/ingress.js +84 -29
  29. package/dist/live.d.ts +83 -0
  30. package/dist/live.js +276 -0
  31. package/dist/main.js +3 -1
  32. package/dist/mesh.js +32 -13
  33. package/dist/normalize.d.ts +20 -0
  34. package/dist/normalize.js +75 -0
  35. package/dist/protocol.d.ts +158 -0
  36. package/dist/protocol.js +16 -2
  37. package/dist/reconciler.d.ts +89 -0
  38. package/dist/reconciler.js +362 -0
  39. package/dist/runners.d.ts +1 -6
  40. package/dist/runners.js +1 -5
  41. package/dist/secrets.js +3 -1
  42. package/dist/templates/control-compose.yml +1 -0
  43. package/dist/templates/install-docker.sh +12 -7
  44. package/dist/tokens.d.ts +1 -1
  45. package/dist/tokens.js +35 -9
  46. package/dist/util.d.ts +7 -0
  47. package/dist/util.js +73 -16
  48. package/package.json +8 -4
  49. package/dist/rollout.d.ts +0 -72
  50. package/dist/rollout.js +0 -319
package/dist/util.js CHANGED
@@ -1,26 +1,48 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { isIP } from 'node:net';
4
5
  export const now = () => new Date().toISOString();
5
6
  export const id = (prefix) => `${prefix}_${crypto.randomBytes(8).toString('hex')}`;
6
7
  export const token = (prefix = 'tug') => `${prefix}_${crypto.randomBytes(24).toString('base64url')}`;
7
8
  export const hashToken = (value) => crypto.createHash('sha256').update(value).digest('hex');
8
- export async function ensureDir(dir) { await fs.mkdir(dir, { recursive: true }); }
9
- export async function exists(p) { try {
10
- await fs.access(p);
11
- return true;
9
+ export async function ensureDir(dir) {
10
+ await fs.mkdir(dir, { recursive: true });
11
+ }
12
+ export async function exists(p) {
13
+ try {
14
+ await fs.access(p);
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ export function requireEnv(name, fallback) {
22
+ const v = process.env[name] ?? fallback;
23
+ if (!v)
24
+ throw new Error(`Missing ${name}`);
25
+ return v;
26
+ }
27
+ export function projectRoot() {
28
+ return process.cwd();
29
+ }
30
+ export function safeJoin(base, child) {
31
+ const out = path.resolve(base, child);
32
+ if (!out.startsWith(path.resolve(base)))
33
+ throw new Error('unsafe path');
34
+ return out;
35
+ }
36
+ export function renderTemplate(str, vars) {
37
+ return str.replace(/\{\{(\w+)\}\}/g, (m, key) => {
38
+ if (!(key in vars))
39
+ throw new Error(`missing template var ${key}`);
40
+ return vars[key];
41
+ });
42
+ }
43
+ export async function loadTemplate(templatesDir, name, vars) {
44
+ return renderTemplate(await fs.readFile(path.join(templatesDir, name), 'utf8'), vars);
12
45
  }
13
- catch {
14
- return false;
15
- } }
16
- export function requireEnv(name, fallback) { const v = process.env[name] ?? fallback; if (!v)
17
- throw new Error(`Missing ${name}`); return v; }
18
- export function projectRoot() { return process.cwd(); }
19
- export function safeJoin(base, child) { const out = path.resolve(base, child); if (!out.startsWith(path.resolve(base)))
20
- throw new Error('unsafe path'); return out; }
21
- export function renderTemplate(str, vars) { return str.replace(/\{\{(\w+)\}\}/g, (m, key) => { if (!(key in vars))
22
- throw new Error(`missing template var ${key}`); return vars[key]; }); }
23
- export async function loadTemplate(templatesDir, name, vars) { return renderTemplate(await fs.readFile(path.join(templatesDir, name), 'utf8'), vars); }
24
46
  export const errMessage = (e) => (e instanceof Error ? e.message : String(e));
25
47
  /** Parses `500ms` / `2s` / `1m` or a millisecond number; `fallback` when it does not parse. */
26
48
  export function parseDuration(value, fallback = 2000) {
@@ -32,4 +54,39 @@ export function parseDuration(value, fallback = 2000) {
32
54
  const n = Number(m[1]);
33
55
  return m[2] === 'm' ? n * 60000 : m[2] === 'ms' ? n : n * 1000;
34
56
  }
35
- export const isDuration = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0) || (typeof value === 'string' && /^\d+(ms|s|m)?$/.test(value));
57
+ export const isDuration = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0) ||
58
+ (typeof value === 'string' && /^\d+(ms|s|m)?$/.test(value));
59
+ /**
60
+ * True for an IP address that is routable on the internet: anything outside loopback, private
61
+ * (RFC 1918, IPv6 ULA), link-local, CGNAT (`100.64.0.0/10`) and the unspecified address. The
62
+ * unspecified addresses `0.0.0.0` and `::` count as public, since listening there means every
63
+ * interface. Hostnames and other non-IP strings return false.
64
+ */
65
+ export function isPublicAddress(address) {
66
+ const ip = address.replace(/^\[|\]$/g, '');
67
+ const kind = isIP(ip);
68
+ if (kind === 4) {
69
+ const [a, b] = ip.split('.').map(Number);
70
+ if (ip === '0.0.0.0')
71
+ return true;
72
+ return !(a === 10 ||
73
+ a === 127 ||
74
+ (a === 172 && b >= 16 && b <= 31) ||
75
+ (a === 192 && b === 168) ||
76
+ (a === 169 && b === 254) ||
77
+ (a === 100 && b >= 64 && b <= 127));
78
+ }
79
+ if (kind === 6) {
80
+ const lower = ip.toLowerCase();
81
+ if (lower === '::')
82
+ return true;
83
+ if (lower === '::1')
84
+ return false;
85
+ const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
86
+ if (mapped)
87
+ return isPublicAddress(mapped[1]);
88
+ const first = Number.parseInt(lower.split(':')[0] || '0', 16);
89
+ return !((first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80);
90
+ }
91
+ return false;
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/tug",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Portable VPS deployments with Docker Compose",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,8 +24,8 @@
24
24
  "typecheck": "tsc -p tsconfig.json --noEmit",
25
25
  "prepack": "npm run build",
26
26
  "test": "node --test 'src/**/*.test.ts'",
27
- "lint": "eslint .",
28
- "format": "eslint . --fix",
27
+ "lint": "eslint . && prettier --check .",
28
+ "format": "prettier --write . && eslint . --fix",
29
29
  "test:integration": "bash tools/scripts/integration-test.sh",
30
30
  "test:integration:network": "bash tools/scripts/integration-network-test.sh",
31
31
  "test:integration:agent": "node --test 'tests/integration/**/*.test.ts'",
@@ -45,7 +45,8 @@
45
45
  "multer": "^2.2.0",
46
46
  "sqlite": "^5.1.1",
47
47
  "sqlite3": "^5.1.7",
48
- "tar": "^7.4.3"
48
+ "tar": "^7.4.3",
49
+ "ws": "^8.22.1"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@changesets/cli": "^3.0.3",
@@ -54,8 +55,11 @@
54
55
  "@types/js-yaml": "^4.0.9",
55
56
  "@types/multer": "^2.3.0",
56
57
  "@types/node": "^22.20.5",
58
+ "@types/ws": "^8.18.2",
57
59
  "eslint": "^10.5.0",
60
+ "eslint-config-prettier": "^10.1.8",
58
61
  "globals": "^17.7.0",
62
+ "prettier": "^3.9.10",
59
63
  "typescript": "^6.0.3",
60
64
  "typescript-eslint": "^8.72.1"
61
65
  },
package/dist/rollout.d.ts DELETED
@@ -1,72 +0,0 @@
1
- import type { AgentContext } from './agent.ts';
2
- import type { ComposeFile, ComposeService, DesiredState, ServiceState } from './protocol.ts';
3
- export declare const DEFAULT_DRAIN = "30s";
4
- export declare const DEFAULT_PORT_RANGE = "20000-20999";
5
- export declare function parsePortRange(value: string | undefined): [number, number];
6
- /** Compose project names allow lowercase letters, digits, dashes and underscores. */
7
- export declare function revisionProject(project: string, service: string, hash: string): string;
8
- /** Returns `ports` with the host side of its (single) published entry replaced by `port`. */
9
- export declare function remapPublishedPort(ports: NonNullable<ComposeService['ports']>, port: number): NonNullable<ComposeService['ports']>;
10
- /** Identity of one revision of a service: its rendered definition and the versions of the secrets it uses. */
11
- export declare function serviceRevisionHash(desired: DesiredState, rendered: ComposeService, service: string, secretVersions?: Record<string, number>): string;
12
- /** The Compose file of one rolling revision: the service alone, on the main project's network and volumes. */
13
- export declare function rollingCompose(full: ComposeFile, project: string, service: string, port: number | null): ComposeFile;
14
- interface Revision {
15
- hash: string;
16
- project: string;
17
- port: number;
18
- image?: string;
19
- legacy?: boolean;
20
- }
21
- interface Draining {
22
- service: string;
23
- project: string;
24
- legacy?: boolean;
25
- drainUntil: number;
26
- }
27
- interface ServiceRollout {
28
- active?: Revision;
29
- candidate?: Revision;
30
- draining: Draining[];
31
- failed?: {
32
- key: string;
33
- error: string;
34
- };
35
- }
36
- export interface RolloutState {
37
- services: Record<string, ServiceRollout>;
38
- }
39
- export declare function loadRolloutState(dir: string): Promise<RolloutState>;
40
- /** `docker compose` arguments for a revision project, with relative paths and `.env` resolved against the main project dir. */
41
- export declare function composeBase(project: string, dir: string, file: string): string[];
42
- export interface RolloutDeps {
43
- waitForHealth: (project: string, service: string, composeFile: string) => Promise<void>;
44
- ensureArtifact: (service: string) => Promise<void>;
45
- emit: (service: string, event: string, message?: string) => Promise<unknown>;
46
- }
47
- export interface RollingInput {
48
- desired: DesiredState;
49
- /** The project's directory; revision Compose files go in `<dir>/rollout/`. */
50
- dir: string;
51
- /** Rolling services placed on this node. */
52
- services: string[];
53
- /** `composeForNode` output with no exclusions (so `extra_hosts`, `.env` and volumes are all present). */
54
- full: ComposeFile;
55
- secretVersions: Record<string, Record<string, number>>;
56
- deps: RolloutDeps;
57
- }
58
- export interface RollingResult {
59
- /** Reported state of every rolling service; the serving revision keeps `serving` while a newer one is rejected. */
60
- serviceStates: Record<string, ServiceState>;
61
- /** Services whose newest revision failed (or whose current revision is unhealthy), with the reason. */
62
- errors: Record<string, string>;
63
- }
64
- /** True when containers of `services` still run in the main project from before they became rolling. */
65
- export declare function hasLegacyContainers(ctx: AgentContext, project: string, services: string[]): Promise<boolean>;
66
- export declare function isRollingService(desired: DesiredState, service: string): boolean;
67
- export declare function reconcileRolling(ctx: AgentContext, input: RollingInput): Promise<RollingResult>;
68
- /** Drain/cleanup pass for a project with no rolling service left on this node (or none at all). */
69
- export declare function retireAll(ctx: AgentContext, dir: string, desired: DesiredState): Promise<void>;
70
- /** Reported states for rolling services whose current revision is still serving, used when another part of the reconcile failed. */
71
- export declare function activeRollingStates(dir: string, services: string[]): Promise<Record<string, ServiceState>>;
72
- export {};
package/dist/rollout.js DELETED
@@ -1,319 +0,0 @@
1
- // Rolling rollouts on the agent. A service with `x-tug.rollout.strategy: rolling` does not live in the
2
- // node's main Compose project. Every distinct rendering of it runs as its own Compose project
3
- // (`<project>-<service>-<hash>`) with a published host port taken from a free range, so the previous
4
- // revision keeps serving while the new one starts and is health-checked. Control learns the new port
5
- // from the status report and re-renders the ingress; after a drain delay the previous revision is
6
- // removed. State lives in `<project dir>/rollout.json` so an agent restart resumes mid-rollout.
7
- import crypto from 'node:crypto';
8
- import fs from 'node:fs/promises';
9
- import path from 'node:path';
10
- import { dumpYaml, isRolling, publishedPort } from "./compose.js";
11
- import { ensureDir, errMessage, now, parseDuration } from "./util.js";
12
- export const DEFAULT_DRAIN = '30s';
13
- export const DEFAULT_PORT_RANGE = '20000-20999';
14
- const LABEL_PROJECT = 'com.docker.compose.project';
15
- const LABEL_SERVICE = 'com.docker.compose.service';
16
- export function parsePortRange(value) {
17
- const m = (value || DEFAULT_PORT_RANGE).trim().match(/^(\d+)-(\d+)$/);
18
- const lo = Number(m?.[1]);
19
- const hi = Number(m?.[2]);
20
- if (!m || lo < 1024 || hi > 65535 || lo > hi)
21
- throw new Error(`invalid rollout port range ${JSON.stringify(value)} (expected LOW-HIGH within 1024-65535, e.g. ${DEFAULT_PORT_RANGE})`);
22
- return [lo, hi];
23
- }
24
- /** Compose project names allow lowercase letters, digits, dashes and underscores. */
25
- export function revisionProject(project, service, hash) {
26
- return `${project}-${service}-${hash.slice(0, 8)}`.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
27
- }
28
- /** Returns `ports` with the host side of its (single) published entry replaced by `port`. */
29
- export function remapPublishedPort(ports, port) {
30
- return ports.map(entry => {
31
- if (typeof entry === 'number')
32
- return entry;
33
- if (typeof entry === 'object')
34
- return entry.published !== undefined ? { ...entry, published: port } : entry;
35
- const [spec, ...protocol] = String(entry).split('/');
36
- const parts = spec.split(':');
37
- if (parts.length < 2)
38
- return entry;
39
- parts[parts.length - 2] = String(port);
40
- return [parts.join(':'), ...protocol].join('/');
41
- });
42
- }
43
- /** Identity of one revision of a service: its rendered definition and the versions of the secrets it uses. */
44
- export function serviceRevisionHash(desired, rendered, service, secretVersions = {}) {
45
- const artifact = desired.services[service]?.artifact;
46
- const definition = { ...rendered, image: artifact?.imageId ?? rendered.image };
47
- return crypto.createHash('sha256').update(JSON.stringify({ definition, secretVersions })).digest('hex').slice(0, 16);
48
- }
49
- /** The Compose file of one rolling revision: the service alone, on the main project's network and volumes. */
50
- export function rollingCompose(full, project, service, port) {
51
- const svc = { ...full.services[service] };
52
- delete svc.depends_on;
53
- if (port !== null && svc.ports)
54
- svc.ports = remapPublishedPort(svc.ports, port);
55
- const out = { services: { [service]: svc }, networks: { default: { name: `${project}_default`, external: true } } };
56
- const volumes = {};
57
- for (const mount of svc.volumes || []) {
58
- const source = typeof mount === 'string' ? mount.split(':')[0] : mount.source;
59
- const def = source ? full.volumes?.[source] : undefined;
60
- if (!source || !def)
61
- continue;
62
- // Named volumes keep the data the main project would use; explicit `name` / `external` volumes already do.
63
- volumes[source] = def.external || def.name ? def : { ...def, external: true, name: `${project}_${source}` };
64
- }
65
- if (Object.keys(volumes).length)
66
- out.volumes = volumes;
67
- return out;
68
- }
69
- const stateFile = (dir) => path.join(dir, 'rollout.json');
70
- export async function loadRolloutState(dir) {
71
- try {
72
- const parsed = JSON.parse(await fs.readFile(stateFile(dir), 'utf8'));
73
- for (const s of Object.values(parsed.services))
74
- s.draining ||= [];
75
- return parsed;
76
- }
77
- catch {
78
- return { services: {} };
79
- }
80
- }
81
- async function saveRolloutState(dir, state) {
82
- const tmp = `${stateFile(dir)}.tmp`;
83
- await fs.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
84
- await fs.rename(tmp, stateFile(dir));
85
- }
86
- // ---- Docker helpers ----------------------------------------------------------------------------
87
- /** `docker compose` arguments for a revision project, with relative paths and `.env` resolved against the main project dir. */
88
- export function composeBase(project, dir, file) {
89
- return ['compose', '-p', project, '--project-directory', dir, '-f', file];
90
- }
91
- async function containerIds(ctx, project, service) {
92
- const filters = ['--filter', `label=${LABEL_PROJECT}=${project}`, ...(service ? ['--filter', `label=${LABEL_SERVICE}=${service}`] : [])];
93
- return (await ctx.docker(['ps', '-aq', ...filters])).out.split(/\s+/).filter(Boolean);
94
- }
95
- /** Stops and removes every container of a revision project (or of one service of the main project). */
96
- async function removeRevision(ctx, dir, project, service) {
97
- const ids = await containerIds(ctx, project, service);
98
- if (ids.length) {
99
- await ctx.docker(['stop', ...ids]).catch(() => { });
100
- await ctx.docker(['rm', '-f', ...ids]);
101
- }
102
- if (!service)
103
- await fs.rm(path.join(dir, 'rollout', `${project}.yml`), { force: true });
104
- }
105
- async function ensureSharedResources(ctx, project, services, full) {
106
- const network = `${project}_default`;
107
- await ctx.docker(['network', 'inspect', network]).catch(() => ctx.docker(['network', 'create', '--label', `${LABEL_PROJECT}=${project}`, '--label', 'com.docker.compose.network=default', network]));
108
- const created = new Set();
109
- for (const service of services) {
110
- for (const mount of full.services[service].volumes || []) {
111
- const source = typeof mount === 'string' ? mount.split(':')[0] : mount.source;
112
- const def = source ? full.volumes?.[source] : undefined;
113
- if (!source || !def || def.external || def.name || created.has(source))
114
- continue;
115
- created.add(source);
116
- const opts = Object.entries(def.driver_opts || {}).flatMap(([k, v]) => ['--opt', `${k}=${v}`]);
117
- await ctx.docker(['volume', 'create', ...(def.driver ? ['--driver', String(def.driver)] : []), ...opts, '--label', `${LABEL_PROJECT}=${project}`, '--label', `com.docker.compose.volume=${source}`, `${project}_${source}`]);
118
- }
119
- }
120
- }
121
- /** Host ports already published by running containers (`0.0.0.0:20001->80/tcp, [::]:20001->80/tcp`). */
122
- async function publishedHostPorts(ctx) {
123
- const out = (await ctx.docker(['ps', '--format', '{{.Ports}}'])).out;
124
- return new Set([...out.matchAll(/:(\d+)(?:-(\d+))?->/g)].flatMap(m => {
125
- const lo = Number(m[1]);
126
- const hi = Number(m[2] || m[1]);
127
- return Array.from({ length: Math.min(hi - lo + 1, 1000) }, (_, i) => lo + i);
128
- }));
129
- }
130
- async function allocatePort(ctx, known, exclude) {
131
- const [lo, hi] = parsePortRange(ctx.rolloutPorts);
132
- const busy = await publishedHostPorts(ctx);
133
- for (const port of [...known, ...exclude])
134
- busy.add(port);
135
- // Start after the newest port in use so a just-released port is not reused straight away.
136
- const last = Math.max(lo - 1, ...known.filter(p => p >= lo && p <= hi));
137
- const size = hi - lo + 1;
138
- for (let i = 0; i < size; i++) {
139
- const port = lo + ((last + 1 - lo + i) % size);
140
- if (!busy.has(port))
141
- return port;
142
- }
143
- throw new Error(`no free host port left in the rollout range ${lo}-${hi} (TUG_ROLLOUT_PORTS)`);
144
- }
145
- const PORT_CONFLICT = /port is already allocated|address already in use|bind for .* failed/i;
146
- function servingState(rev, extra = {}) {
147
- return { rolloutState: 'serving', image: rev.image, healthyAt: now(), ...(rev.legacy ? {} : { port: rev.port }), rolloutRevision: rev.hash, ...extra };
148
- }
149
- /** True when containers of `services` still run in the main project from before they became rolling. */
150
- export async function hasLegacyContainers(ctx, project, services) {
151
- for (const service of services)
152
- if ((await containerIds(ctx, project, service)).length)
153
- return true;
154
- return false;
155
- }
156
- export function isRollingService(desired, service) { return isRolling(desired.compose.services[service]); }
157
- export async function reconcileRolling(ctx, input) {
158
- const { desired, dir, services, full, deps } = input;
159
- const project = desired.project;
160
- const state = await loadRolloutState(dir);
161
- const result = { serviceStates: {}, errors: {} };
162
- await ensureDir(path.join(dir, 'rollout'));
163
- // Drained revisions go first so their host ports are free for this rollout.
164
- await retireRevisions(ctx, dir, state, desired, new Set(services), deps);
165
- await ensureSharedResources(ctx, project, services, full);
166
- for (const service of services) {
167
- const rollout = state.services[service] ||= { draining: [] };
168
- const spec = desired.compose.services[service];
169
- const declaredPort = publishedPort(spec, spec['x-tug'].ingress.port);
170
- const drainMs = parseDuration(spec['x-tug']?.rollout?.drain ?? DEFAULT_DRAIN, 30000);
171
- const hash = serviceRevisionHash(desired, full.services[service], service, input.secretVersions[service]);
172
- const revProject = revisionProject(project, service, hash);
173
- const file = path.join(dir, 'rollout', `${revProject}.yml`);
174
- const image = desired.services[service].image;
175
- // A service that used to be recreated still runs in the main project: it is the revision to replace.
176
- if (!rollout.active && declaredPort && (await containerIds(ctx, project, service)).length) {
177
- rollout.active = { hash: 'legacy', project, port: declaredPort, image, legacy: true };
178
- }
179
- if (rollout.candidate && rollout.candidate.hash !== hash) {
180
- await removeRevision(ctx, dir, rollout.candidate.project);
181
- delete rollout.candidate;
182
- }
183
- const writeCompose = (port) => fs.writeFile(file, dumpYaml(rollingCompose(full, project, service, port)));
184
- const up = () => ctx.docker([...composeBase(revProject, dir, file), 'up', '-d'], { cwd: dir });
185
- if (rollout.active && rollout.active.hash === hash) {
186
- // Steady state: make sure the revision is still up and healthy; nothing restarts when it is.
187
- const active = { ...rollout.active, image };
188
- try {
189
- await writeCompose(active.port);
190
- await up();
191
- await deps.waitForHealth(revProject, service, file);
192
- delete rollout.failed;
193
- result.serviceStates[service] = servingState(active);
194
- }
195
- catch (e) {
196
- result.errors[service] = errMessage(e);
197
- result.serviceStates[service] = { rolloutState: 'health_checking', image, rolloutRevision: hash };
198
- }
199
- continue;
200
- }
201
- const key = `${desired.revision || desired.version}:${hash}`;
202
- const reject = (error) => {
203
- result.errors[service] = error;
204
- result.serviceStates[service] = rollout.active ? servingState(rollout.active, { rolloutError: error }) : { rolloutState: 'health_checking', image, rolloutRevision: hash };
205
- };
206
- if (rollout.failed?.key === key) {
207
- reject(rollout.failed.error);
208
- continue;
209
- }
210
- try {
211
- const candidate = rollout.candidate ?? { hash, project: revProject, port: await allocatePort(ctx, knownPorts(rollout), new Set()), image };
212
- rollout.candidate = candidate;
213
- await saveRolloutState(dir, state);
214
- await deps.emit(service, 'pulling');
215
- await deps.ensureArtifact(service);
216
- const exclude = new Set();
217
- for (let attempt = 0;; attempt++) {
218
- await writeCompose(candidate.port);
219
- if (!desired.services[service].artifact)
220
- await ctx.docker([...composeBase(revProject, dir, file), 'pull', service], { cwd: dir });
221
- await deps.emit(service, 'starting', `starting new revision on host port ${candidate.port}`);
222
- try {
223
- await up();
224
- break;
225
- }
226
- catch (e) {
227
- if (attempt >= 4 || !PORT_CONFLICT.test(errMessage(e)))
228
- throw e;
229
- exclude.add(candidate.port);
230
- candidate.port = await allocatePort(ctx, knownPorts(rollout), exclude);
231
- await saveRolloutState(dir, state);
232
- }
233
- }
234
- await deps.emit(service, 'health_checking');
235
- await deps.waitForHealth(revProject, service, file);
236
- // Takeover: the next status report carries the new port and control re-renders the ingress.
237
- if (rollout.active)
238
- rollout.draining.push({ service, project: rollout.active.project, legacy: rollout.active.legacy, drainUntil: Date.now() + drainMs });
239
- rollout.active = { ...candidate };
240
- delete rollout.candidate;
241
- delete rollout.failed;
242
- await saveRolloutState(dir, state);
243
- result.serviceStates[service] = servingState(rollout.active);
244
- await deps.emit(service, 'serving', `new revision serving on host port ${rollout.active.port}`);
245
- }
246
- catch (e) {
247
- const error = errMessage(e);
248
- if (rollout.candidate)
249
- await removeRevision(ctx, dir, rollout.candidate.project).catch(() => { });
250
- delete rollout.candidate;
251
- rollout.failed = { key, error };
252
- await saveRolloutState(dir, state);
253
- reject(error);
254
- await deps.emit(service, 'rollout_failed', `new revision rejected, previous revision keeps serving: ${error}`);
255
- }
256
- }
257
- await saveRolloutState(dir, state);
258
- return result;
259
- }
260
- function knownPorts(rollout) {
261
- return [rollout.active, rollout.candidate].filter((r) => Boolean(r && !r.legacy)).map(r => r.port);
262
- }
263
- /** Removes drained revisions, and revisions of services that are no longer rolling on this node. */
264
- async function retireRevisions(ctx, dir, state, desired, rolling, deps) {
265
- for (const [service, rollout] of Object.entries(state.services)) {
266
- if (!rolling.has(service)) {
267
- // No longer rolling here. A service that is now recreated keeps the old revision for one drain so the switch is not abrupt.
268
- const stillRuns = desired.compose.services[service] && (desired.services[service]?.placement?.nodes?.includes(ctx.nodeName) || !desired.services[service]?.placement?.nodes?.length);
269
- if (rollout.candidate) {
270
- await removeRevision(ctx, dir, rollout.candidate.project).catch(() => { });
271
- delete rollout.candidate;
272
- }
273
- if (rollout.active && stillRuns) {
274
- rollout.draining.push({ service, project: rollout.active.project, legacy: rollout.active.legacy, drainUntil: Date.now() + parseDuration(DEFAULT_DRAIN) });
275
- delete rollout.active;
276
- }
277
- else if (rollout.active) {
278
- await removeRevision(ctx, dir, rollout.active.project, rollout.active.legacy ? service : undefined).catch(() => { });
279
- delete rollout.active;
280
- }
281
- }
282
- const keep = [];
283
- for (const old of rollout.draining) {
284
- if (old.drainUntil > Date.now()) {
285
- keep.push(old);
286
- continue;
287
- }
288
- try {
289
- await removeRevision(ctx, dir, old.project, old.legacy ? old.service : undefined);
290
- await deps?.emit(service, 'drained', `previous revision ${old.project} removed after drain`);
291
- }
292
- catch {
293
- keep.push(old);
294
- }
295
- }
296
- rollout.draining = keep;
297
- if (!rollout.active && !rollout.candidate && !rollout.draining.length && !rollout.failed)
298
- delete state.services[service];
299
- }
300
- }
301
- /** Drain/cleanup pass for a project with no rolling service left on this node (or none at all). */
302
- export async function retireAll(ctx, dir, desired) {
303
- if (!(await fs.stat(stateFile(dir)).then(() => true, () => false)))
304
- return;
305
- const state = await loadRolloutState(dir);
306
- await retireRevisions(ctx, dir, state, desired, new Set());
307
- await saveRolloutState(dir, state);
308
- }
309
- /** Reported states for rolling services whose current revision is still serving, used when another part of the reconcile failed. */
310
- export async function activeRollingStates(dir, services) {
311
- const state = await loadRolloutState(dir);
312
- const out = {};
313
- for (const service of services) {
314
- const active = state.services[service]?.active;
315
- if (active)
316
- out[service] = servingState(active);
317
- }
318
- return out;
319
- }