@wpelevator/tug 0.1.0 → 0.2.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/README.md CHANGED
@@ -6,13 +6,14 @@ Deploy Docker Compose projects to one or more ordinary Linux VPSes through a sma
6
6
 
7
7
  ## Features
8
8
 
9
- - Control API with bearer-token auth and SQLite metadata.
10
- - Nodes join over SSH (`node install`) or enroll themselves with a single `docker run` (`node enroll`), with per-node agent and registry tokens. Agents only dial out, so nodes behind NAT work.
9
+ - Control API with bearer-token auth, scoped `read`/`deploy`/`admin` API tokens for CI and teammates, an audit trail naming who acted, and SQLite metadata.
10
+ - Nodes join over SSH (`node-install`) or enroll themselves with a single `docker run` (`node-enroll`), with per-node agent and registry tokens. Agents only dial out, so nodes behind NAT work.
11
11
  - Agents pull desired state from control, apply a generated per-node Compose file, run TCP/HTTP/Docker health checks and report status.
12
12
  - `x-tug` placement of services and volumes on nodes, with immutable desired-state records per deployment.
13
- - Optional remote Docker builds on the control host from an uploaded source tarball.
13
+ - Build application images on control from uploaded source, or locally with `deploy --build-local`. Agents download saved images directly from control by default; a bring-your-own registry is optional.
14
14
  - Secrets encrypted at rest, released only to the nodes whose services reference them, with targeted service recreation when only a secret changes.
15
15
  - Deployment convergence, per-project operation locks, cancellation, rollback, per-service rollout events and log fetching through the agents.
16
+ - Opt-in zero-downtime rolling rollouts for ingress-fronted services (`x-tug.rollout`) and automatic rollback of failed deployments (`x-tug.rollback: auto`). See [Compose extensions](docs/compose.md#rolling-rollouts).
16
17
  - Per-node registry pull tokens enforced by an htpasswd-backed registry you provide, with rotation and revocation.
17
18
  - Optional WireGuard mesh between nodes, health-aware cross-node service names and a managed Caddy ingress with automatic HTTPS, each opt-in. See [Networking](docs/networking.md).
18
19
  - Control-plane backup and restore, and `tug doctor` diagnostics.
@@ -27,10 +28,10 @@ npm install -g @wpelevator/tug
27
28
 
28
29
  The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your hosts.
29
30
 
30
- 1. Install the control plane on a Docker host, pointing it at a registry you already run: `tug control install root@control.example.com --registry registry.example.com`.
31
+ 1. Install the control plane: `tug control-install root@control.example.com --install-docker`. The flag installs missing Docker Engine and Compose on supported Debian, Ubuntu, Fedora and RHEL-compatible hosts; omit it when Docker is ready. Put HTTPS in front of the control API as described in [Control plane](docs/control-plane.md).
31
32
  2. Point the CLI at it: `tug init --control-url https://control.example.com --token "$TUG_TOKEN"`.
32
- 3. Set the secrets your services need: `tug secret:set DB_PASSWORD`.
33
- 4. Add nodes, either `tug node install root@app1.example.com --name app1 --control-url https://control.example.com --registry registry.example.com`, or `tug node enroll --name app1` and run the printed `docker run` on the host.
33
+ 3. Set the secrets your services need: `tug secret-set DB_PASSWORD`.
34
+ 4. Add nodes with `tug node-install root@app1.example.com --name app1 --install-docker`, or `tug node-enroll --name app1` and run the printed Docker command. For a bare cloud host, `tug node-enroll --name app1 --install-docker` prints cloud-init user-data.
34
35
  5. Deploy: `tug deploy -f docker-compose.yml`. It waits until every node reports the new version healthy.
35
36
  6. Operate with `tug status`, `events`, `logs`, `restart`, `rollback` and `doctor`.
36
37
 
@@ -39,12 +40,15 @@ The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your h
39
40
  - [Architecture](docs/architecture.md): components, reconciliation, workflow diagrams, secrets and runtime paths.
40
41
  - [Compose extensions](docs/compose.md): `x-tug` placement, health checks, volumes and limitations.
41
42
  - [Control plane](docs/control-plane.md): install, TLS, registry credentials, backup and restore.
43
+ - [Access control](docs/access-control.md): scoped API tokens, roles, audit trail and a GitHub Actions deploy example.
42
44
  - [Networking](docs/networking.md): WireGuard mesh, service DNS, ingress and TLS, firewall notes.
43
45
  - [Nodes](docs/nodes.md): SSH install, self-service enrollment, registration and status.
44
46
  - [CLI reference](docs/cli.md): every `tug` command and option.
45
47
  - [Configuration](docs/configuration.md): CLI, control and agent environment variables.
46
48
  - [Development](docs/development.md): local setup, CI, Compose stack, host mode and the integration test.
49
+ - [Testing](docs/testing.md): test layers, the numbers rollouts are held to, and the backlog of mocked tests.
47
50
  - [Releases](docs/releases.md): changesets, npm and Docker image publishing.
51
+ - [Standalone binary](docs/standalone-binary.md): proposed Bun-compiled CLI binaries and Homebrew distribution.
48
52
  - [Roadmap](docs/roadmap.md): planned milestones toward parity with and beyond uncloud.
49
53
 
50
54
  ## Development
package/dist/agent.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { parseDuration } from './util.ts';
2
3
  import type { DockerRunner, HostRunner, HttpClient } from './runners.ts';
3
- import type { Command, CommandResult, DesiredState, HealthCheck, ProjectStatus, ServiceEndpoint } from './protocol.ts';
4
+ import type { Command, CommandResult, DesiredState, HealthCheck, ProjectStatus, ImageArtifact, ServiceEndpoint } from './protocol.ts';
4
5
  export interface AgentContext {
5
6
  docker: DockerRunner;
6
7
  http: HttpClient;
@@ -14,12 +15,16 @@ export interface AgentContext {
14
15
  /** Manage the WireGuard mesh (generate a key, report it, reconcile `wg0`). */
15
16
  mesh?: boolean;
16
17
  caddyImage?: string;
18
+ /** Host port range (`LOW-HIGH`) for rolling-rollout revisions; default 20000-20999. */
19
+ rolloutPorts?: string;
17
20
  }
18
21
  export declare function defaultContext(): AgentContext;
19
- export declare function containerIp(ctx: AgentContext, project: string, service: string, cwd: string): Promise<string>;
20
- export declare function waitForHealth(ctx: AgentContext, project: string, service: string, health: HealthCheck | null | undefined, cwd: string): Promise<void>;
21
- export declare function parseDuration(value: string | number): number;
22
+ export declare function containerIp(ctx: AgentContext, project: string, service: string, cwd: string, composeFile?: string): Promise<string>;
23
+ export declare function waitForHealth(ctx: AgentContext, project: string, service: string, health: HealthCheck | null | undefined, cwd: string, composeFile?: string): Promise<void>;
24
+ export { parseDuration };
22
25
  export declare function executeCommand(command: Command, ctx?: AgentContext): Promise<CommandResult>;
26
+ export declare function ensureArtifact(ctx: AgentContext, artifact: ImageArtifact): Promise<void>;
27
+ export declare function pruneManagedImages(ctx: AgentContext, retained: string[]): Promise<void>;
23
28
  export declare function reconcile(desiredState: DesiredState, ctx?: AgentContext, endpoints?: Record<string, ServiceEndpoint[]>): Promise<ProjectStatus>;
24
29
  /** Reports this node's WireGuard public key when control has not seen the current one yet. */
25
30
  export declare function reportWgKey(ctx: AgentContext): Promise<void>;
@@ -33,4 +38,3 @@ interface Creds {
33
38
  }
34
39
  export declare function loadOrEnroll(): Promise<Creds>;
35
40
  export declare function main(): Promise<void>;
36
- export {};
package/dist/agent.js CHANGED
@@ -1,10 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs/promises';
3
+ import { createWriteStream } from 'node:fs';
4
+ import { Readable } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { validateImageArchive, verifyDownload } from "./artifacts.js";
3
7
  import path from 'node:path';
4
8
  import { spawn, spawnSync } from 'node:child_process';
5
9
  import net from 'node:net';
6
- import { composeForNode, dumpYaml, serviceSecretVersions } from "./compose.js";
7
- import { ensureDir, errMessage, now } from "./util.js";
10
+ import { composeForNode, dumpYaml, isRolling, serviceSecretVersions } from "./compose.js";
11
+ import { activeRollingStates, composeBase, hasLegacyContainers, reconcileRolling, retireAll } from "./rollout.js";
12
+ import { ensureDir, errMessage, now, parseDuration } from "./util.js";
8
13
  import { openAgentDb } from "./db.js";
9
14
  import { createDockerRunner, createHostRunner } from "./runners.js";
10
15
  import { CADDY_CONTAINER, reconcileIngress, removeIngress } from "./ingress.js";
@@ -19,7 +24,7 @@ const INTERVAL = Number(process.env.TUG_POLL_INTERVAL_MS || 5000);
19
24
  // Opt-in WireGuard mesh: needs `wg`/`ip` in the agent image, host networking and NET_ADMIN.
20
25
  const MESH = process.env.TUG_MESH === 'true';
21
26
  export function defaultContext() {
22
- return { docker: createDockerRunner(), http: fetch, nodeName: NODE_NAME, stateDir: STATE_DIR, apply: APPLY, controlUrl: CONTROL_URL, token: TOKEN, host: createHostRunner(), mesh: MESH, caddyImage: process.env.TUG_CADDY_IMAGE };
27
+ return { docker: createDockerRunner(), http: fetch, nodeName: NODE_NAME, stateDir: STATE_DIR, apply: APPLY, controlUrl: CONTROL_URL, token: TOKEN, host: createHostRunner(), mesh: MESH, caddyImage: process.env.TUG_CADDY_IMAGE, rolloutPorts: process.env.TUG_ROLLOUT_PORTS };
23
28
  }
24
29
  const dbsByStateDir = new Map();
25
30
  async function agentDb(stateDir) {
@@ -43,8 +48,10 @@ async function writeEnv(file, secrets) {
43
48
  const body = Object.entries(secrets).map(([k, v]) => `${k}=${String(v).replace(/\n/g, '\\n')}`).join('\n') + '\n';
44
49
  await fs.writeFile(file, body, { mode: 0o600 });
45
50
  }
46
- export async function containerIp(ctx, project, service, cwd) {
47
- const ids = (await ctx.docker(['compose', '-p', project, 'ps', '-q', service], { cwd })).out.trim().split(/\s+/).filter(Boolean);
51
+ /** `docker compose` prefix for a project; rolling revision projects pass their own Compose file. */
52
+ const composeCmd = (project, cwd, composeFile) => composeFile ? composeBase(project, cwd, composeFile) : ['compose', '-p', project];
53
+ export async function containerIp(ctx, project, service, cwd, composeFile) {
54
+ const ids = (await ctx.docker([...composeCmd(project, cwd, composeFile), 'ps', '-q', service], { cwd })).out.trim().split(/\s+/).filter(Boolean);
48
55
  if (!ids.length)
49
56
  throw new Error(`${service}: no container found`);
50
57
  const inspected = JSON.parse((await ctx.docker(['inspect', ids[0]], { cwd })).out)[0];
@@ -58,7 +65,7 @@ function tcpCheck(host, port, timeoutMs = 2000) {
58
65
  s.on('error', reject);
59
66
  });
60
67
  }
61
- export async function waitForHealth(ctx, project, service, health, cwd) {
68
+ export async function waitForHealth(ctx, project, service, health, cwd, composeFile) {
62
69
  if (!ctx.apply || !health)
63
70
  return;
64
71
  const retries = Number(health.retries || 10);
@@ -67,7 +74,7 @@ export async function waitForHealth(ctx, project, service, health, cwd) {
67
74
  for (let i = 0; i < retries; i++) {
68
75
  try {
69
76
  if (health.type === 'docker') {
70
- const id = (await ctx.docker(['compose', '-p', project, 'ps', '-q', service], { cwd })).out.trim().split(/\s+/)[0];
77
+ const id = (await ctx.docker([...composeCmd(project, cwd, composeFile), 'ps', '-q', service], { cwd })).out.trim().split(/\s+/)[0];
71
78
  const state = JSON.parse((await ctx.docker(['inspect', id], { cwd })).out)[0]?.State;
72
79
  if (!state?.Running)
73
80
  throw new Error(`${service}: not running`);
@@ -75,10 +82,10 @@ export async function waitForHealth(ctx, project, service, health, cwd) {
75
82
  throw new Error(`${service}: docker health ${state.Health.Status}`);
76
83
  }
77
84
  else if (health.type === 'tcp') {
78
- await tcpCheck(await containerIp(ctx, project, service, cwd), health.port, parseDuration(health.timeout || '2s'));
85
+ await tcpCheck(await containerIp(ctx, project, service, cwd, composeFile), health.port, parseDuration(health.timeout || '2s'));
79
86
  }
80
87
  else if (health.type === 'http') {
81
- const ip = await containerIp(ctx, project, service, cwd);
88
+ const ip = await containerIp(ctx, project, service, cwd, composeFile);
82
89
  const res = await ctx.http(`http://${ip}:${health.port || 80}${health.path || '/'}`, { signal: AbortSignal.timeout(parseDuration(health.timeout || '2s')) });
83
90
  if (!res.ok)
84
91
  throw new Error(`${service}: http ${res.status}`);
@@ -90,17 +97,22 @@ export async function waitForHealth(ctx, project, service, health, cwd) {
90
97
  await new Promise(r => setTimeout(r, intervalMs));
91
98
  }
92
99
  }
93
- throw last || new Error(`${service}: health check failed`);
100
+ throw new Error(`${service}: ${describeHealthCheck(health)} failed after ${retries} attempt${retries === 1 ? '' : 's'}: ${describeError(last)}`);
94
101
  }
95
- export function parseDuration(value) {
96
- if (typeof value === 'number')
97
- return value;
98
- const m = String(value).match(/^(\d+)(ms|s|m)?$/);
99
- if (!m)
100
- return 2000;
101
- const n = Number(m[1]);
102
- return m[2] === 'm' ? n * 60000 : m[2] === 'ms' ? n : n * 1000;
102
+ /** Node's fetch reports every network failure as "fetch failed"; the system error that explains it is on `cause`. */
103
+ function describeError(error) {
104
+ const e = error;
105
+ const cause = e?.cause?.code || e?.cause?.message;
106
+ return cause ? `${e?.message ?? 'error'} (${cause})` : (e?.message ?? 'no response');
103
107
  }
108
+ function describeHealthCheck(health) {
109
+ if (health.type === 'http')
110
+ return `http health check on port ${health.port || 80}${health.path || '/'}`;
111
+ if (health.type === 'tcp')
112
+ return `tcp health check on port ${health.port}`;
113
+ return 'docker health check';
114
+ }
115
+ export { parseDuration };
104
116
  export async function executeCommand(command, ctx = defaultContext()) {
105
117
  const payload = typeof command.payload_json === 'string' ? JSON.parse(command.payload_json || '{}') : command.payload || {};
106
118
  if (command.type === 'diagnostics.run') {
@@ -175,59 +187,186 @@ export async function executeCommand(command, ctx = defaultContext()) {
175
187
  }
176
188
  return { commandId: command.id, state: 'failed', result: { error: `unsupported command ${command.type}` } };
177
189
  }
190
+ async function managedImageFile(ctx) { await ensureDir(ctx.stateDir); return path.join(ctx.stateDir, 'managed-images.json'); }
191
+ async function managedImages(ctx) {
192
+ try {
193
+ const refs = JSON.parse(await fs.readFile(await managedImageFile(ctx), 'utf8'));
194
+ return Array.isArray(refs) ? refs.filter((ref) => typeof ref === 'string' && ref.startsWith('tug-build/')) : [];
195
+ }
196
+ catch {
197
+ return [];
198
+ }
199
+ }
200
+ export async function ensureArtifact(ctx, artifact) {
201
+ const identityFile = path.join(ctx.stateDir, 'image-identities.json');
202
+ let identities = {};
203
+ try {
204
+ identities = JSON.parse(await fs.readFile(identityFile, 'utf8'));
205
+ }
206
+ catch { }
207
+ const remember = async (daemonId) => {
208
+ identities[artifact.imageRef] = { daemonId, artifactId: artifact.imageId, sha256: artifact.sha256 };
209
+ await fs.writeFile(identityFile, JSON.stringify(identities), { mode: 0o600 });
210
+ const refs = await managedImages(ctx);
211
+ if (!refs.includes(artifact.imageRef))
212
+ await fs.writeFile(await managedImageFile(ctx), JSON.stringify([...refs, artifact.imageRef]), { mode: 0o600 });
213
+ };
214
+ const inspect = async () => JSON.parse((await ctx.docker(['image', 'inspect', artifact.imageRef])).out)[0];
215
+ try {
216
+ const image = await inspect();
217
+ if ((image?.Id === artifact.imageId || (identities[artifact.imageRef]?.daemonId === image?.Id && identities[artifact.imageRef]?.artifactId === artifact.imageId && identities[artifact.imageRef]?.sha256 === artifact.sha256)) && `${image.Os}/${image.Architecture}` === artifact.platform) {
218
+ await remember(image.Id);
219
+ return;
220
+ }
221
+ }
222
+ catch { }
223
+ const host = JSON.parse((await ctx.docker(['info', '--format', '{{json .}}'])).out);
224
+ const arch = { x86_64: 'amd64', aarch64: 'arm64' }[host.Architecture] || host.Architecture;
225
+ if (host.OSType && host.Architecture && `${host.OSType}/${arch}` !== artifact.platform)
226
+ throw new Error(`image platform ${artifact.platform} does not match node ${host.OSType}/${arch}; build for the target platform`);
227
+ const downloadDir = await fs.mkdtemp(path.join(ctx.stateDir, 'image-download-'));
228
+ const file = path.join(downloadDir, 'image.tar.gz');
229
+ try {
230
+ const response = await ctx.http(`${ctx.controlUrl}/agent/v1/images/${encodeURIComponent(artifact.buildId)}`, { headers: authHeaders(ctx), redirect: 'error', signal: AbortSignal.timeout(10 * 60 * 1000) });
231
+ if (!response.ok || !response.body)
232
+ throw new Error(`image download failed: HTTP ${response.status}`);
233
+ await pipeline(Readable.fromWeb(response.body), verifyDownload(artifact), createWriteStream(file, { mode: 0o600 }));
234
+ const archive = await validateImageArchive(file, artifact.imageRef);
235
+ if (archive.imageId !== artifact.imageId || archive.platform !== artifact.platform)
236
+ throw new Error('downloaded archive image metadata differs from desired artifact');
237
+ await ctx.docker(['load', '-i', file]);
238
+ const image = await inspect();
239
+ if (!image?.Id || `${image.Os}/${image.Architecture}` !== artifact.platform)
240
+ throw new Error('loaded image identity or platform does not match desired artifact');
241
+ await remember(image.Id);
242
+ }
243
+ finally {
244
+ await fs.rm(downloadDir, { recursive: true, force: true });
245
+ }
246
+ }
247
+ export async function pruneManagedImages(ctx, retained) {
248
+ if (!ctx.apply)
249
+ return;
250
+ const keep = new Set(retained);
251
+ const remaining = [];
252
+ for (const ref of await managedImages(ctx)) {
253
+ if (keep.has(ref)) {
254
+ remaining.push(ref);
255
+ continue;
256
+ }
257
+ try {
258
+ const used = (await ctx.docker(['ps', '-aq', '--filter', `ancestor=${ref}`])).out.trim();
259
+ if (used) {
260
+ remaining.push(ref);
261
+ continue;
262
+ }
263
+ await ctx.docker(['image', 'rm', ref]);
264
+ }
265
+ catch {
266
+ remaining.push(ref);
267
+ }
268
+ }
269
+ await fs.writeFile(await managedImageFile(ctx), JSON.stringify(remaining), { mode: 0o600 });
270
+ }
178
271
  export async function reconcile(desiredState, ctx = defaultContext(), endpoints = {}) {
179
272
  const dir = path.join(ctx.stateDir, 'projects', desiredState.project);
180
273
  await ensureDir(dir);
181
274
  const plannedCompose = composeForNode(desiredState, ctx.nodeName, {});
182
275
  const serviceNames = Object.keys(plannedCompose.services || {});
183
- if (!serviceNames.length)
184
- return { project: desiredState.project, observedVersion: desiredState.version, health: 'skipped', services: [], serviceStates: {} };
276
+ if (!serviceNames.length) {
277
+ if (ctx.apply)
278
+ await retireAll(ctx, dir, desiredState).catch(() => { });
279
+ return { project: desiredState.project, observedVersion: desiredState.version, ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}), health: 'skipped', services: [], serviceStates: {} };
280
+ }
185
281
  const serviceStates = Object.fromEntries(serviceNames.map(service => [service, { rolloutState: 'pending', image: desiredState.services[service]?.image }]));
186
282
  const emit = (service, rolloutState, message = rolloutState) => api(ctx, '/agent/v1/events', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: desiredState.project, level: 'info', type: `service.${rolloutState}`, message, metadata: { service, version: desiredState.version, node: ctx.nodeName } }) }).catch(() => { });
187
283
  const sec = await api(ctx, `/agent/v1/secrets?project=${encodeURIComponent(desiredState.project)}&services=${encodeURIComponent(serviceNames.join(','))}`);
188
284
  const currentSecretVersions = serviceSecretVersions(desiredState.compose, serviceNames, sec.versions || {});
189
285
  const db = await agentDb(ctx.stateDir);
190
- const previousRow = await db.get('SELECT version, secret_versions_json FROM applied_state WHERE project = ?', desiredState.project);
191
- const previous = previousRow ? { version: previousRow.version, secretVersions: JSON.parse(previousRow.secret_versions_json) } : {};
192
- const secretChangedServices = serviceNames.filter(service => JSON.stringify(previous.secretVersions?.[service] || {}) !== JSON.stringify(currentSecretVersions[service] || {}));
193
- const sameDesiredVersion = previous.version === desiredState.version;
194
- const nodeCompose = composeForNode(desiredState, ctx.nodeName, sec.secrets || {}, endpoints);
286
+ const previousRow = await db.get('SELECT version, revision, secret_versions_json FROM applied_state WHERE project = ?', desiredState.project);
287
+ const previous = previousRow ? { version: previousRow.version, revision: previousRow.revision, secretVersions: JSON.parse(previousRow.secret_versions_json) } : {};
288
+ const rolling = serviceNames.filter(service => isRolling(desiredState.compose.services[service]));
289
+ const rollingSet = new Set(rolling);
290
+ const mainServices = serviceNames.filter(service => !rollingSet.has(service));
291
+ const secretChangedServices = mainServices.filter(service => JSON.stringify(previous.secretVersions?.[service] || {}) !== JSON.stringify(currentSecretVersions[service] || {}));
292
+ const sameDesiredVersion = desiredState.revision ? previous.revision === desiredState.revision : previous.version === desiredState.version;
293
+ // Rolling services run in their own revision projects; the main project holds only the recreated ones.
294
+ const nodeCompose = composeForNode(desiredState, ctx.nodeName, sec.secrets || {}, endpoints, rollingSet);
195
295
  await fs.writeFile(path.join(dir, 'docker-compose.yml'), dumpYaml(nodeCompose));
196
296
  await writeEnv(path.join(dir, '.env'), sec.secrets || {});
297
+ const failedStatus = (error) => ({ project: desiredState.project, observedVersion: desiredState.version, ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}), health: 'failed', services: serviceNames, serviceStates, error });
197
298
  if (ctx.apply) {
198
- for (const service of serviceNames) {
199
- serviceStates[service].rolloutState = 'pulling';
200
- await emit(service, 'pulling');
201
- }
202
- await ctx.docker(['compose', '-p', desiredState.project, 'pull'], { cwd: dir });
203
- for (const service of serviceNames) {
204
- serviceStates[service].rolloutState = 'starting';
205
- await emit(service, 'starting');
299
+ try {
300
+ for (const service of mainServices) {
301
+ serviceStates[service].rolloutState = 'pulling';
302
+ await emit(service, 'pulling');
303
+ }
304
+ for (const service of mainServices) {
305
+ const artifact = desiredState.services[service].artifact;
306
+ if (artifact)
307
+ await ensureArtifact(ctx, artifact);
308
+ }
309
+ const pullServices = mainServices.filter(service => !desiredState.services[service].artifact);
310
+ if (pullServices.length)
311
+ await ctx.docker(['compose', '-p', desiredState.project, 'pull', ...(pullServices.length === mainServices.length ? [] : pullServices)], { cwd: dir });
312
+ for (const service of mainServices) {
313
+ serviceStates[service].rolloutState = 'starting';
314
+ await emit(service, 'starting');
315
+ }
316
+ if (mainServices.length) {
317
+ // Services that just became rolling still run here until their replacement takes over; keep them.
318
+ const keepLegacy = rolling.length > 0 && await hasLegacyContainers(ctx, desiredState.project, rolling);
319
+ await ctx.docker(['compose', '-p', desiredState.project, 'up', '-d', ...(keepLegacy ? [] : ['--remove-orphans'])], { cwd: dir });
320
+ }
321
+ if (sameDesiredVersion && secretChangedServices.length) {
322
+ for (const service of secretChangedServices)
323
+ await emit(service, 'secret_recreate', 'secret version changed; recreating service');
324
+ await ctx.docker(['compose', '-p', desiredState.project, 'up', '-d', '--no-deps', '--force-recreate', ...secretChangedServices], { cwd: dir });
325
+ }
326
+ for (const service of mainServices) {
327
+ serviceStates[service].rolloutState = 'health_checking';
328
+ await emit(service, 'health_checking');
329
+ await waitForHealth(ctx, desiredState.project, service, desiredState.services[service]?.health, dir);
330
+ serviceStates[service].rolloutState = 'serving';
331
+ serviceStates[service].healthyAt = now();
332
+ await emit(service, 'serving');
333
+ }
206
334
  }
207
- await ctx.docker(['compose', '-p', desiredState.project, 'up', '-d', '--remove-orphans'], { cwd: dir });
208
- if (sameDesiredVersion && secretChangedServices.length) {
209
- for (const service of secretChangedServices)
210
- await emit(service, 'secret_recreate', 'secret version changed; recreating service');
211
- await ctx.docker(['compose', '-p', desiredState.project, 'up', '-d', '--no-deps', '--force-recreate', ...secretChangedServices], { cwd: dir });
335
+ catch (e) {
336
+ // Rolling services are untouched by a failure here, so keep reporting their current revision as serving.
337
+ if (!rolling.length)
338
+ throw e;
339
+ Object.assign(serviceStates, await activeRollingStates(dir, rolling));
340
+ return failedStatus(errMessage(e));
212
341
  }
213
- for (const service of serviceNames) {
214
- serviceStates[service].rolloutState = 'health_checking';
215
- await emit(service, 'health_checking');
216
- await waitForHealth(ctx, desiredState.project, service, desiredState.services[service]?.health, dir);
217
- serviceStates[service].rolloutState = 'serving';
218
- serviceStates[service].healthyAt = now();
219
- await emit(service, 'serving');
342
+ if (rolling.length) {
343
+ const rolled = await reconcileRolling(ctx, {
344
+ desired: desiredState, dir, services: rolling, secretVersions: currentSecretVersions,
345
+ full: composeForNode(desiredState, ctx.nodeName, sec.secrets || {}, endpoints),
346
+ deps: {
347
+ waitForHealth: (project, service, composeFile) => waitForHealth(ctx, project, service, desiredState.services[service]?.health, dir, composeFile),
348
+ ensureArtifact: async (service) => { const artifact = desiredState.services[service].artifact; if (artifact)
349
+ await ensureArtifact(ctx, artifact); },
350
+ emit
351
+ }
352
+ });
353
+ Object.assign(serviceStates, rolled.serviceStates);
354
+ const errors = Object.entries(rolled.errors);
355
+ if (errors.length)
356
+ return failedStatus(errors.map(([service, error]) => `${service}: ${error}`).join('; '));
220
357
  }
358
+ else
359
+ await retireAll(ctx, dir, desiredState);
221
360
  }
222
361
  else {
223
362
  for (const service of serviceNames)
224
363
  serviceStates[service].rolloutState = 'serving';
225
364
  }
226
- await db.run(`INSERT INTO applied_state(project, version, applied_at, service_states_json, secret_versions_json)
227
- VALUES(?,?,?,?,?)
228
- ON CONFLICT(project) DO UPDATE SET version=excluded.version, applied_at=excluded.applied_at,
229
- service_states_json=excluded.service_states_json, secret_versions_json=excluded.secret_versions_json`, desiredState.project, desiredState.version, now(), JSON.stringify(serviceStates), JSON.stringify(currentSecretVersions));
230
- return { project: desiredState.project, observedVersion: desiredState.version, health: 'serving', services: serviceNames, serviceStates };
365
+ await db.run(`INSERT INTO applied_state(project, version, revision, applied_at, service_states_json, secret_versions_json)
366
+ VALUES(?,?,?,?,?,?)
367
+ ON CONFLICT(project) DO UPDATE SET version=excluded.version, revision=excluded.revision, applied_at=excluded.applied_at,
368
+ service_states_json=excluded.service_states_json, secret_versions_json=excluded.secret_versions_json`, desiredState.project, desiredState.version, desiredState.revision || null, now(), JSON.stringify(serviceStates), JSON.stringify(currentSecretVersions));
369
+ return { project: desiredState.project, observedVersion: desiredState.version, ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}), health: 'serving', services: serviceNames, serviceStates };
231
370
  }
232
371
  /** Reports this node's WireGuard public key when control has not seen the current one yet. */
233
372
  export async function reportWgKey(ctx) {
@@ -265,7 +404,7 @@ export async function pollOnce(ctx = defaultContext()) {
265
404
  projects.push(await reconcile(desired, ctx, work.serviceEndpoints?.[desired.project]));
266
405
  }
267
406
  catch (e) {
268
- projects.push({ project: desired.project, observedVersion: desired.version, health: 'failed', error: errMessage(e) });
407
+ projects.push({ project: desired.project, observedVersion: desired.version, ...(desired.revision ? { observedRevision: desired.revision } : {}), health: 'failed', error: errMessage(e) });
269
408
  }
270
409
  }
271
410
  let ingress;
@@ -288,6 +427,8 @@ export async function pollOnce(ctx = defaultContext()) {
288
427
  const report = { node: ctx.nodeName, projects, commandResults };
289
428
  if (ingress)
290
429
  report.ingress = ingress;
430
+ if (work.retainedImages && projects.every(project => project.health !== 'failed'))
431
+ await pruneManagedImages(ctx, work.retainedImages);
291
432
  await api(ctx, '/agent/v1/status', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(report) });
292
433
  return projects;
293
434
  }
@@ -0,0 +1,2 @@
1
+ import * as tar from 'tar';
2
+ export declare function scanArchive(file: string, inspect: (entry: tar.ReadEntry) => void): Promise<void>;
@@ -0,0 +1,20 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import * as tar from 'tar';
3
+ export async function scanArchive(file, inspect) {
4
+ await new Promise((resolve, reject) => {
5
+ const input = createReadStream(file);
6
+ const parser = new tar.Parser({ strict: true, onReadEntry(entry) {
7
+ try {
8
+ inspect(entry);
9
+ entry.resume();
10
+ }
11
+ catch (error) {
12
+ parser.abort(error);
13
+ }
14
+ } });
15
+ input.once('error', reject);
16
+ parser.once('error', error => { input.destroy(); reject(error); });
17
+ parser.once('end', resolve);
18
+ input.pipe(parser);
19
+ });
20
+ }
@@ -0,0 +1,23 @@
1
+ import { Transform } from 'node:stream';
2
+ import type { RunResult } from './build.ts';
3
+ export type ImageRunner = (cmd: string, args: string[]) => Promise<RunResult>;
4
+ export interface ImageInfo {
5
+ imageId: string;
6
+ platform: string;
7
+ }
8
+ export interface ArchiveInfo extends ImageInfo {
9
+ sha256: string;
10
+ sizeBytes: number;
11
+ }
12
+ export declare function inspectImage(imageRef: string, runner?: ImageRunner): Promise<ImageInfo>;
13
+ export declare function hashArchive(file: string): Promise<{
14
+ sha256: string;
15
+ sizeBytes: number;
16
+ }>;
17
+ export declare function saveImageArchive(imageRef: string, file: string): Promise<ArchiveInfo>;
18
+ /** Validate Docker save metadata before Docker can import attacker-selected repository tags. */
19
+ export declare function validateImageArchive(file: string, expectedRef: string, maxExpandedBytes?: number, requireManagedReference?: boolean): Promise<ArchiveInfo>;
20
+ export declare function verifyDownload(expected: {
21
+ sha256: string;
22
+ sizeBytes: number;
23
+ }): Transform;