@wpelevator/tug 0.3.0 → 0.5.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.
package/dist/tokens.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isUniqueViolation } from "./db-driver.js";
1
2
  import { hashToken, id, now, token } from "./util.js";
2
3
  /** Roles are cumulative: `deploy` includes `read`, and `admin` includes both. */
3
4
  export const ROLES = ['read', 'deploy', 'admin'];
@@ -14,7 +15,7 @@ const LAST_USED_INTERVAL_MS = 60_000;
14
15
  /**
15
16
  * The single source of truth for who may call what. Every `/api/v1` route must appear here: createApp refuses to
16
17
  * register an unlisted route, and a test compares this table with the live router. `public` routes do their own
17
- * credential check (the registry hook) and are not behind the token guard. docs/access-control.md mirrors this table.
18
+ * credential check and are not behind the token guard. docs/access-control.md mirrors this table.
18
19
  */
19
20
  export const ROUTE_ROLES = {
20
21
  'GET /api/v1/live': 'deploy',
@@ -38,8 +39,6 @@ export const ROUTE_ROLES = {
38
39
  'GET /api/v1/nodes': 'admin',
39
40
  'POST /api/v1/nodes/register': 'admin',
40
41
  'POST /api/v1/nodes/enroll-token': 'admin',
41
- 'POST /api/v1/nodes/:name/rotate-registry-token': 'admin',
42
- 'POST /api/v1/nodes/:name/revoke-registry-token': 'admin',
43
42
  'POST /api/v1/nodes/:name/rotate-wg-key': 'admin',
44
43
  'POST /api/v1/nodes/:name/ingress': 'admin',
45
44
  'DELETE /api/v1/nodes/:name': 'admin',
@@ -50,7 +49,6 @@ export const ROUTE_ROLES = {
50
49
  'POST /api/v1/tokens': 'admin',
51
50
  'GET /api/v1/tokens': 'admin',
52
51
  'DELETE /api/v1/tokens/:name': 'admin',
53
- 'POST /api/v1/registry/auth': 'public',
54
52
  };
55
53
  const statusError = (status, message) => Object.assign(new Error(message), { status });
56
54
  export const isRole = (value) => typeof value === 'string' && ROLES.includes(value);
@@ -98,46 +96,51 @@ const info = (row) => ({
98
96
  revokedAt: row.revoked_at,
99
97
  });
100
98
  /** Creates a token. The plaintext is returned once; only its hash is stored. */
101
- export async function createToken(db, request, createdBy) {
99
+ export function createToken(db, request, createdBy) {
102
100
  const value = token(TOKEN_PREFIX);
103
101
  try {
104
- await db.run('INSERT INTO api_tokens(id,name,token_hash,role,projects_json,created_at,created_by,expires_at) VALUES(?,?,?,?,?,?,?,?)', id('tok'), request.name, hashToken(value), request.role, request.projects ? JSON.stringify(request.projects) : null, now(), createdBy, request.expiresAt);
102
+ db.run('INSERT INTO api_tokens(id,name,token_hash,role,projects_json,created_at,created_by,expires_at) VALUES(?,?,?,?,?,?,?,?)', id('tok'), request.name, hashToken(value), request.role, request.projects ? JSON.stringify(request.projects) : null, now(), createdBy, request.expiresAt);
105
103
  }
106
104
  catch (e) {
107
- if (/UNIQUE constraint/.test(e.message))
105
+ if (isUniqueViolation(e))
108
106
  throw statusError(409, `token name ${request.name} is already used (revoked tokens keep their names for the audit trail)`);
109
107
  throw e;
110
108
  }
111
109
  return {
112
- ...info((await db.get('SELECT * FROM api_tokens WHERE name=?', request.name))),
110
+ ...info(db.get('SELECT * FROM api_tokens WHERE name=?', request.name)),
113
111
  token: value,
114
112
  };
115
113
  }
116
- export async function listTokens(db) {
117
- return (await db.all('SELECT * FROM api_tokens ORDER BY created_at, name')).map(info);
114
+ export function listTokens(db) {
115
+ return db.all('SELECT * FROM api_tokens ORDER BY created_at, name').map(info);
118
116
  }
119
117
  /** Revokes a token immediately. Returns false if no such token exists; revoking twice keeps the first revocation time. */
120
- export async function revokeToken(db, name) {
121
- const row = await db.get('SELECT * FROM api_tokens WHERE name=?', name);
118
+ export function revokeToken(db, name) {
119
+ const row = db.get('SELECT * FROM api_tokens WHERE name=?', name);
122
120
  if (!row)
123
121
  return false;
124
- await db.run('UPDATE api_tokens SET revoked_at=? WHERE id=? AND revoked_at IS NULL', now(), row.id);
122
+ db.run('UPDATE api_tokens SET revoked_at=? WHERE id=? AND revoked_at IS NULL', now(), row.id);
125
123
  return true;
126
124
  }
127
125
  /** Resolves a bearer value to an actor by hash lookup: the root operator token first, then named tokens. */
128
- export async function authenticate(db, raw) {
126
+ export function authenticate(db, raw) {
129
127
  if (!raw)
130
128
  return null;
131
129
  const hash = hashToken(raw);
132
- const operator = await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
130
+ const operator = db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
133
131
  if (operator && hash === operator.value)
134
132
  return { name: OPERATOR_ACTOR, role: 'admin', projects: null };
135
- const row = await db.get('SELECT * FROM api_tokens WHERE token_hash=?', hash);
133
+ const row = db.get('SELECT * FROM api_tokens WHERE token_hash=?', hash);
136
134
  if (!row || row.revoked_at || (row.expires_at && row.expires_at <= now()))
137
135
  return null;
138
- // Best effort and throttled: a slow or failing bookkeeping write must never fail or delay the request.
136
+ // Best effort and throttled: bookkeeping failures must never fail authentication.
139
137
  if (!row.last_used_at || Date.parse(row.last_used_at) < Date.now() - LAST_USED_INTERVAL_MS)
140
- void db.run('UPDATE api_tokens SET last_used_at=? WHERE id=?', now(), row.id).catch(() => { });
138
+ try {
139
+ db.run('UPDATE api_tokens SET last_used_at=? WHERE id=?', now(), row.id);
140
+ }
141
+ catch {
142
+ /* Best-effort bookkeeping. */
143
+ }
141
144
  return {
142
145
  name: row.name,
143
146
  role: row.role,
@@ -156,7 +159,7 @@ export function assertProject(actor, project) {
156
159
  export function createGuard(db) {
157
160
  return (required, resolve) => async (req, res, next) => {
158
161
  try {
159
- const actor = await authenticate(db, (req.headers.authorization || '').replace(/^Bearer /, ''));
162
+ const actor = authenticate(db, (req.headers.authorization || '').replace(/^Bearer /, ''));
160
163
  if (!actor) {
161
164
  res.status(401).json({ error: 'unauthorized' });
162
165
  return;
@@ -186,6 +189,6 @@ export function createGuard(db) {
186
189
  };
187
190
  }
188
191
  /** Appends a control-originated audit event naming the acting token. */
189
- export async function recordEvent(db, event) {
190
- await db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at,actor) VALUES(?,?,?,?,?,?,?,?,?,?)', id('evt'), event.project ?? null, event.deploymentId ?? null, null, 'info', event.type, event.message, JSON.stringify(event.metadata ?? {}), now(), event.actor);
192
+ export function recordEvent(db, event) {
193
+ db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at,actor) VALUES(?,?,?,?,?,?,?,?,?,?)', id('evt'), event.project ?? null, event.deploymentId ?? null, null, 'info', event.type, event.message, JSON.stringify(event.metadata ?? {}), now(), event.actor);
191
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/tug",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Portable VPS deployments with Docker Compose",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,8 +43,6 @@
43
43
  "express": "^4.21.2",
44
44
  "js-yaml": "^4.1.0",
45
45
  "multer": "^2.2.0",
46
- "sqlite": "^5.1.1",
47
- "sqlite3": "^5.1.7",
48
46
  "tar": "^7.4.3",
49
47
  "ws": "^8.22.1"
50
48
  },
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,385 +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
48
- .createHash('sha256')
49
- .update(JSON.stringify({ definition, secretVersions }))
50
- .digest('hex')
51
- .slice(0, 16);
52
- }
53
- /** The Compose file of one rolling revision: the service alone, on the main project's network and volumes. */
54
- export function rollingCompose(full, project, service, port) {
55
- const svc = { ...full.services[service] };
56
- delete svc.depends_on;
57
- if (port !== null && svc.ports)
58
- svc.ports = remapPublishedPort(svc.ports, port);
59
- const out = {
60
- services: { [service]: svc },
61
- networks: { default: { name: `${project}_default`, external: true } },
62
- };
63
- const volumes = {};
64
- for (const mount of svc.volumes || []) {
65
- const source = typeof mount === 'string' ? mount.split(':')[0] : mount.source;
66
- const def = source ? full.volumes?.[source] : undefined;
67
- if (!source || !def)
68
- continue;
69
- // Named volumes keep the data the main project would use; explicit `name` / `external` volumes already do.
70
- volumes[source] =
71
- def.external || def.name ? def : { ...def, external: true, name: `${project}_${source}` };
72
- }
73
- if (Object.keys(volumes).length)
74
- out.volumes = volumes;
75
- return out;
76
- }
77
- const stateFile = (dir) => path.join(dir, 'rollout.json');
78
- export async function loadRolloutState(dir) {
79
- try {
80
- const parsed = JSON.parse(await fs.readFile(stateFile(dir), 'utf8'));
81
- for (const s of Object.values(parsed.services))
82
- s.draining ||= [];
83
- return parsed;
84
- }
85
- catch {
86
- return { services: {} };
87
- }
88
- }
89
- async function saveRolloutState(dir, state) {
90
- const tmp = `${stateFile(dir)}.tmp`;
91
- await fs.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
92
- await fs.rename(tmp, stateFile(dir));
93
- }
94
- // ---- Docker helpers ----------------------------------------------------------------------------
95
- /** `docker compose` arguments for a revision project, with relative paths and `.env` resolved against the main project dir. */
96
- export function composeBase(project, dir, file) {
97
- return ['compose', '-p', project, '--project-directory', dir, '-f', file];
98
- }
99
- async function containerIds(ctx, project, service) {
100
- const filters = [
101
- '--filter',
102
- `label=${LABEL_PROJECT}=${project}`,
103
- ...(service ? ['--filter', `label=${LABEL_SERVICE}=${service}`] : []),
104
- ];
105
- return (await ctx.docker(['ps', '-aq', ...filters])).out.split(/\s+/).filter(Boolean);
106
- }
107
- /** Stops and removes every container of a revision project (or of one service of the main project). */
108
- async function removeRevision(ctx, dir, project, service) {
109
- const ids = await containerIds(ctx, project, service);
110
- if (ids.length) {
111
- await ctx.docker(['stop', ...ids]).catch(() => { });
112
- await ctx.docker(['rm', '-f', ...ids]);
113
- }
114
- if (!service)
115
- await fs.rm(path.join(dir, 'rollout', `${project}.yml`), { force: true });
116
- }
117
- async function ensureSharedResources(ctx, project, services, full) {
118
- const network = `${project}_default`;
119
- await ctx
120
- .docker(['network', 'inspect', network])
121
- .catch(() => ctx.docker([
122
- 'network',
123
- 'create',
124
- '--label',
125
- `${LABEL_PROJECT}=${project}`,
126
- '--label',
127
- 'com.docker.compose.network=default',
128
- network,
129
- ]));
130
- const created = new Set();
131
- for (const service of services) {
132
- for (const mount of full.services[service].volumes || []) {
133
- const source = typeof mount === 'string' ? mount.split(':')[0] : mount.source;
134
- const def = source ? full.volumes?.[source] : undefined;
135
- if (!source || !def || def.external || def.name || created.has(source))
136
- continue;
137
- created.add(source);
138
- const opts = Object.entries(def.driver_opts || {}).flatMap(([k, v]) => ['--opt', `${k}=${v}`]);
139
- await ctx.docker([
140
- 'volume',
141
- 'create',
142
- ...(def.driver ? ['--driver', String(def.driver)] : []),
143
- ...opts,
144
- '--label',
145
- `${LABEL_PROJECT}=${project}`,
146
- '--label',
147
- `com.docker.compose.volume=${source}`,
148
- `${project}_${source}`,
149
- ]);
150
- }
151
- }
152
- }
153
- /** Host ports already published by running containers (`0.0.0.0:20001->80/tcp, [::]:20001->80/tcp`). */
154
- async function publishedHostPorts(ctx) {
155
- const out = (await ctx.docker(['ps', '--format', '{{.Ports}}'])).out;
156
- return new Set([...out.matchAll(/:(\d+)(?:-(\d+))?->/g)].flatMap((m) => {
157
- const lo = Number(m[1]);
158
- const hi = Number(m[2] || m[1]);
159
- return Array.from({ length: Math.min(hi - lo + 1, 1000) }, (_, i) => lo + i);
160
- }));
161
- }
162
- async function allocatePort(ctx, known, exclude) {
163
- const [lo, hi] = parsePortRange(ctx.rolloutPorts);
164
- const busy = await publishedHostPorts(ctx);
165
- for (const port of [...known, ...exclude])
166
- busy.add(port);
167
- // Start after the newest port in use so a just-released port is not reused straight away.
168
- const last = Math.max(lo - 1, ...known.filter((p) => p >= lo && p <= hi));
169
- const size = hi - lo + 1;
170
- for (let i = 0; i < size; i++) {
171
- const port = lo + ((last + 1 - lo + i) % size);
172
- if (!busy.has(port))
173
- return port;
174
- }
175
- throw new Error(`no free host port left in the rollout range ${lo}-${hi} (TUG_ROLLOUT_PORTS)`);
176
- }
177
- const PORT_CONFLICT = /port is already allocated|address already in use|bind for .* failed/i;
178
- function servingState(rev, extra = {}) {
179
- return {
180
- rolloutState: 'serving',
181
- image: rev.image,
182
- healthyAt: now(),
183
- ...(rev.legacy ? {} : { port: rev.port }),
184
- rolloutRevision: rev.hash,
185
- ...extra,
186
- };
187
- }
188
- /** True when containers of `services` still run in the main project from before they became rolling. */
189
- export async function hasLegacyContainers(ctx, project, services) {
190
- for (const service of services)
191
- if ((await containerIds(ctx, project, service)).length)
192
- return true;
193
- return false;
194
- }
195
- export function isRollingService(desired, service) {
196
- return isRolling(desired.compose.services[service]);
197
- }
198
- export async function reconcileRolling(ctx, input) {
199
- const { desired, dir, services, full, deps } = input;
200
- const project = desired.project;
201
- const state = await loadRolloutState(dir);
202
- const result = { serviceStates: {}, errors: {} };
203
- await ensureDir(path.join(dir, 'rollout'));
204
- // Drained revisions go first so their host ports are free for this rollout.
205
- await retireRevisions(ctx, dir, state, desired, new Set(services), deps);
206
- await ensureSharedResources(ctx, project, services, full);
207
- for (const service of services) {
208
- const rollout = (state.services[service] ||= { draining: [] });
209
- const spec = desired.compose.services[service];
210
- const declaredPort = publishedPort(spec, spec['x-tug'].ingress.port);
211
- const drainMs = parseDuration(spec['x-tug']?.rollout?.drain ?? DEFAULT_DRAIN, 30000);
212
- const hash = serviceRevisionHash(desired, full.services[service], service, input.secretVersions[service]);
213
- const revProject = revisionProject(project, service, hash);
214
- const file = path.join(dir, 'rollout', `${revProject}.yml`);
215
- const image = desired.services[service].image;
216
- // A service that used to be recreated still runs in the main project: it is the revision to replace.
217
- if (!rollout.active && declaredPort && (await containerIds(ctx, project, service)).length) {
218
- rollout.active = { hash: 'legacy', project, port: declaredPort, image, legacy: true };
219
- }
220
- if (rollout.candidate && rollout.candidate.hash !== hash) {
221
- await removeRevision(ctx, dir, rollout.candidate.project);
222
- delete rollout.candidate;
223
- }
224
- const writeCompose = (port) => fs.writeFile(file, dumpYaml(rollingCompose(full, project, service, port)));
225
- const up = () => ctx.docker([...composeBase(revProject, dir, file), 'up', '-d'], { cwd: dir });
226
- if (rollout.active && rollout.active.hash === hash) {
227
- // Steady state: make sure the revision is still up and healthy; nothing restarts when it is.
228
- const active = { ...rollout.active, image };
229
- try {
230
- await writeCompose(active.port);
231
- await up();
232
- await deps.waitForHealth(revProject, service, file);
233
- delete rollout.failed;
234
- result.serviceStates[service] = servingState(active);
235
- }
236
- catch (e) {
237
- result.errors[service] = errMessage(e);
238
- result.serviceStates[service] = {
239
- rolloutState: 'health_checking',
240
- image,
241
- rolloutRevision: hash,
242
- };
243
- }
244
- continue;
245
- }
246
- const key = `${desired.revision || desired.version}:${hash}`;
247
- const reject = (error) => {
248
- result.errors[service] = error;
249
- result.serviceStates[service] = rollout.active
250
- ? servingState(rollout.active, { rolloutError: error })
251
- : { rolloutState: 'health_checking', image, rolloutRevision: hash };
252
- };
253
- if (rollout.failed?.key === key) {
254
- reject(rollout.failed.error);
255
- continue;
256
- }
257
- try {
258
- const candidate = rollout.candidate ?? {
259
- hash,
260
- project: revProject,
261
- port: await allocatePort(ctx, knownPorts(rollout), new Set()),
262
- image,
263
- };
264
- rollout.candidate = candidate;
265
- await saveRolloutState(dir, state);
266
- await deps.emit(service, 'pulling');
267
- await deps.ensureArtifact(service);
268
- const exclude = new Set();
269
- for (let attempt = 0;; attempt++) {
270
- await writeCompose(candidate.port);
271
- if (!desired.services[service].artifact)
272
- await ctx.docker([...composeBase(revProject, dir, file), 'pull', service], { cwd: dir });
273
- await deps.emit(service, 'starting', `starting new revision on host port ${candidate.port}`);
274
- try {
275
- await up();
276
- break;
277
- }
278
- catch (e) {
279
- if (attempt >= 4 || !PORT_CONFLICT.test(errMessage(e)))
280
- throw e;
281
- exclude.add(candidate.port);
282
- candidate.port = await allocatePort(ctx, knownPorts(rollout), exclude);
283
- await saveRolloutState(dir, state);
284
- }
285
- }
286
- await deps.emit(service, 'health_checking');
287
- await deps.waitForHealth(revProject, service, file);
288
- // Takeover: the next status report carries the new port and control re-renders the ingress.
289
- if (rollout.active)
290
- rollout.draining.push({
291
- service,
292
- project: rollout.active.project,
293
- legacy: rollout.active.legacy,
294
- drainUntil: Date.now() + drainMs,
295
- });
296
- rollout.active = { ...candidate };
297
- delete rollout.candidate;
298
- delete rollout.failed;
299
- await saveRolloutState(dir, state);
300
- result.serviceStates[service] = servingState(rollout.active);
301
- await deps.emit(service, 'serving', `new revision serving on host port ${rollout.active.port}`);
302
- }
303
- catch (e) {
304
- const error = errMessage(e);
305
- if (rollout.candidate)
306
- await removeRevision(ctx, dir, rollout.candidate.project).catch(() => { });
307
- delete rollout.candidate;
308
- rollout.failed = { key, error };
309
- await saveRolloutState(dir, state);
310
- reject(error);
311
- await deps.emit(service, 'rollout_failed', `new revision rejected, previous revision keeps serving: ${error}`);
312
- }
313
- }
314
- await saveRolloutState(dir, state);
315
- return result;
316
- }
317
- function knownPorts(rollout) {
318
- return [rollout.active, rollout.candidate]
319
- .filter((r) => Boolean(r && !r.legacy))
320
- .map((r) => r.port);
321
- }
322
- /** Removes drained revisions, and revisions of services that are no longer rolling on this node. */
323
- async function retireRevisions(ctx, dir, state, desired, rolling, deps) {
324
- for (const [service, rollout] of Object.entries(state.services)) {
325
- if (!rolling.has(service)) {
326
- // No longer rolling here. A service that is now recreated keeps the old revision for one drain so the switch is not abrupt.
327
- const stillRuns = desired.compose.services[service] &&
328
- (desired.services[service]?.placement?.nodes?.includes(ctx.nodeName) ||
329
- !desired.services[service]?.placement?.nodes?.length);
330
- if (rollout.candidate) {
331
- await removeRevision(ctx, dir, rollout.candidate.project).catch(() => { });
332
- delete rollout.candidate;
333
- }
334
- if (rollout.active && stillRuns) {
335
- rollout.draining.push({
336
- service,
337
- project: rollout.active.project,
338
- legacy: rollout.active.legacy,
339
- drainUntil: Date.now() + parseDuration(DEFAULT_DRAIN),
340
- });
341
- delete rollout.active;
342
- }
343
- else if (rollout.active) {
344
- await removeRevision(ctx, dir, rollout.active.project, rollout.active.legacy ? service : undefined).catch(() => { });
345
- delete rollout.active;
346
- }
347
- }
348
- const keep = [];
349
- for (const old of rollout.draining) {
350
- if (old.drainUntil > Date.now()) {
351
- keep.push(old);
352
- continue;
353
- }
354
- try {
355
- await removeRevision(ctx, dir, old.project, old.legacy ? old.service : undefined);
356
- await deps?.emit(service, 'drained', `previous revision ${old.project} removed after drain`);
357
- }
358
- catch {
359
- keep.push(old);
360
- }
361
- }
362
- rollout.draining = keep;
363
- if (!rollout.active && !rollout.candidate && !rollout.draining.length && !rollout.failed)
364
- delete state.services[service];
365
- }
366
- }
367
- /** Drain/cleanup pass for a project with no rolling service left on this node (or none at all). */
368
- export async function retireAll(ctx, dir, desired) {
369
- if (!(await fs.stat(stateFile(dir)).then(() => true, () => false)))
370
- return;
371
- const state = await loadRolloutState(dir);
372
- await retireRevisions(ctx, dir, state, desired, new Set());
373
- await saveRolloutState(dir, state);
374
- }
375
- /** Reported states for rolling services whose current revision is still serving, used when another part of the reconcile failed. */
376
- export async function activeRollingStates(dir, services) {
377
- const state = await loadRolloutState(dir);
378
- const out = {};
379
- for (const service of services) {
380
- const active = state.services[service]?.active;
381
- if (active)
382
- out[service] = servingState(active);
383
- }
384
- return out;
385
- }