@wpelevator/tug 0.1.0 → 0.2.1
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 +11 -6
- package/dist/agent.d.ts +9 -5
- package/dist/agent.js +215 -52
- package/dist/archive.d.ts +2 -0
- package/dist/archive.js +20 -0
- package/dist/artifacts.d.ts +23 -0
- package/dist/artifacts.js +238 -0
- package/dist/build.js +17 -2
- package/dist/cli-build.d.ts +15 -0
- package/dist/cli-build.js +50 -0
- package/dist/cli-config.d.ts +20 -0
- package/dist/cli-config.js +47 -0
- package/dist/cli-install.d.ts +7 -0
- package/dist/cli-install.js +31 -0
- package/dist/cli.js +191 -93
- package/dist/compose.d.ts +12 -2
- package/dist/compose.js +85 -7
- package/dist/control.d.ts +15 -1
- package/dist/control.js +583 -144
- package/dist/db.d.ts +8 -0
- package/dist/db.js +12 -3
- package/dist/ingress.js +5 -3
- package/dist/protocol.d.ts +44 -0
- package/dist/rollout.d.ts +72 -0
- package/dist/rollout.js +319 -0
- package/dist/templates/control-install.sh +1 -1
- package/dist/templates/install-docker.sh +85 -0
- package/dist/templates/node-install.sh +1 -1
- package/dist/tokens.d.ts +91 -0
- package/dist/tokens.js +165 -0
- package/dist/util.d.ts +3 -0
- package/dist/util.js +11 -0
- package/package.json +5 -2
package/dist/db.d.ts
CHANGED
|
@@ -46,6 +46,7 @@ export interface DeploymentRow {
|
|
|
46
46
|
created_at: string;
|
|
47
47
|
updated_at: string;
|
|
48
48
|
error_message: string | null;
|
|
49
|
+
actor: string | null;
|
|
49
50
|
}
|
|
50
51
|
export interface DesiredStateRow {
|
|
51
52
|
id: string;
|
|
@@ -104,9 +105,16 @@ export interface LockRow {
|
|
|
104
105
|
expires_at: string;
|
|
105
106
|
created_at: string;
|
|
106
107
|
}
|
|
108
|
+
/** What the agent last announced for a project's recreated services, so unchanged polls stay silent. */
|
|
109
|
+
export interface EventStateRow {
|
|
110
|
+
project: string;
|
|
111
|
+
fingerprint: string;
|
|
112
|
+
phase: 'serving' | 'failed';
|
|
113
|
+
}
|
|
107
114
|
export interface AppliedStateRow {
|
|
108
115
|
project: string;
|
|
109
116
|
version: string;
|
|
117
|
+
revision: string | null;
|
|
110
118
|
applied_at: string;
|
|
111
119
|
service_states_json: string;
|
|
112
120
|
secret_versions_json: string;
|
package/dist/db.js
CHANGED
|
@@ -17,10 +17,16 @@ export async function openAgentDb(filename) {
|
|
|
17
17
|
CREATE TABLE IF NOT EXISTS applied_state(
|
|
18
18
|
project TEXT PRIMARY KEY,
|
|
19
19
|
version TEXT,
|
|
20
|
+
revision TEXT,
|
|
20
21
|
applied_at TEXT,
|
|
21
22
|
service_states_json TEXT,
|
|
22
23
|
secret_versions_json TEXT
|
|
23
24
|
);
|
|
25
|
+
CREATE TABLE IF NOT EXISTS event_state(
|
|
26
|
+
project TEXT PRIMARY KEY,
|
|
27
|
+
fingerprint TEXT NOT NULL,
|
|
28
|
+
phase TEXT NOT NULL
|
|
29
|
+
);
|
|
24
30
|
`);
|
|
25
31
|
return db;
|
|
26
32
|
}
|
|
@@ -31,15 +37,18 @@ CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NU
|
|
|
31
37
|
CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, public_address TEXT, overlay_ip TEXT UNIQUE, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, agent_token_hash TEXT, registry_token_hash TEXT, last_seen_at TEXT, created_at TEXT, updated_at TEXT);
|
|
32
38
|
CREATE TABLE IF NOT EXISTS wg_peers(node_id TEXT PRIMARY KEY, public_key TEXT NOT NULL UNIQUE, overlay_ip TEXT NOT NULL, endpoint TEXT, allowed_ips TEXT NOT NULL, updated_at TEXT);
|
|
33
39
|
CREATE TABLE IF NOT EXISTS enroll_tokens(token_hash TEXT PRIMARY KEY, name TEXT, uses_remaining INTEGER, expires_at TEXT, created_at TEXT);
|
|
34
|
-
CREATE TABLE IF NOT EXISTS deployments(id TEXT PRIMARY KEY, project_id TEXT, version TEXT, state TEXT, created_at TEXT, updated_at TEXT, error_message TEXT);
|
|
40
|
+
CREATE TABLE IF NOT EXISTS deployments(id TEXT PRIMARY KEY, project_id TEXT, version TEXT, state TEXT, created_at TEXT, updated_at TEXT, error_message TEXT, actor TEXT);
|
|
35
41
|
CREATE TABLE IF NOT EXISTS desired_states(id TEXT PRIMARY KEY, project_id TEXT, deployment_id TEXT, version TEXT, state_json TEXT, created_at TEXT);
|
|
36
42
|
CREATE TABLE IF NOT EXISTS node_statuses(id TEXT PRIMARY KEY, node_id TEXT, project_id TEXT, observed_version TEXT, status_json TEXT, health TEXT, updated_at TEXT, UNIQUE(node_id, project_id));
|
|
37
|
-
CREATE TABLE IF NOT EXISTS builds(id TEXT PRIMARY KEY, project_id TEXT, deployment_id TEXT, service TEXT, source_hash TEXT, image_ref TEXT, state TEXT, logs_path TEXT, created_at TEXT, updated_at TEXT);
|
|
43
|
+
CREATE TABLE IF NOT EXISTS builds(id TEXT PRIMARY KEY, project_id TEXT, deployment_id TEXT, service TEXT, source_hash TEXT, image_ref TEXT, image_id TEXT, platform TEXT, sha256 TEXT, size_bytes INTEGER, artifact_path TEXT, state TEXT, logs_path TEXT, created_at TEXT, updated_at TEXT);
|
|
38
44
|
CREATE TABLE IF NOT EXISTS secrets(id TEXT PRIMARY KEY, project_id TEXT, name TEXT, encrypted_value TEXT, version INTEGER, created_at TEXT, updated_at TEXT, UNIQUE(project_id, name));
|
|
39
45
|
CREATE TABLE IF NOT EXISTS volumes(id TEXT PRIMARY KEY, project_id TEXT, name TEXT, type TEXT, driver TEXT, node_id TEXT, config_json TEXT, created_at TEXT, updated_at TEXT, UNIQUE(project_id, name));
|
|
40
46
|
CREATE TABLE IF NOT EXISTS backups(id TEXT PRIMARY KEY, project_id TEXT, target TEXT, node_id TEXT, state TEXT, artifact_ref TEXT, metadata_json TEXT, created_at TEXT, completed_at TEXT);
|
|
41
47
|
CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY, node_id TEXT, project_id TEXT, type TEXT, state TEXT, payload_json TEXT, result_json TEXT, attempts INTEGER DEFAULT 0, run_after TEXT, deadline TEXT, created_at TEXT, updated_at TEXT);
|
|
42
|
-
CREATE TABLE IF NOT EXISTS events(id TEXT PRIMARY KEY, project_id TEXT, deployment_id TEXT, node_id TEXT, level TEXT, type TEXT, message TEXT, metadata_json TEXT, created_at TEXT);
|
|
48
|
+
CREATE TABLE IF NOT EXISTS events(id TEXT PRIMARY KEY, project_id TEXT, deployment_id TEXT, node_id TEXT, level TEXT, type TEXT, message TEXT, metadata_json TEXT, created_at TEXT, actor TEXT);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS events_project_created ON events(project_id, created_at);
|
|
50
|
+
CREATE TABLE IF NOT EXISTS api_tokens(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, token_hash TEXT UNIQUE NOT NULL, role TEXT NOT NULL, projects_json TEXT, created_at TEXT, created_by TEXT, last_used_at TEXT, expires_at TEXT, revoked_at TEXT);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS events_by_time ON events(created_at);
|
|
43
52
|
CREATE TABLE IF NOT EXISTS locks(name TEXT PRIMARY KEY, owner TEXT, expires_at TEXT, created_at TEXT);
|
|
44
53
|
`);
|
|
45
54
|
const row = await db.get('SELECT value FROM meta WHERE key = ?', 'schema_created_at');
|
package/dist/ingress.js
CHANGED
|
@@ -26,9 +26,11 @@ export function resolveServiceEndpoints(states, nodes, statuses, staleBefore) {
|
|
|
26
26
|
const address = node && (node.overlayIp || node.address);
|
|
27
27
|
if (!node || !address || !node.lastSeenAt || node.lastSeenAt < staleBefore)
|
|
28
28
|
continue;
|
|
29
|
-
|
|
29
|
+
const serviceState = reported.get(`${nodeName}\0${state.project}`)?.serviceStates?.[service];
|
|
30
|
+
if (serviceState?.rolloutState !== 'serving')
|
|
30
31
|
continue;
|
|
31
|
-
|
|
32
|
+
// Rolling services publish each revision on its own host port; the report names the serving one.
|
|
33
|
+
endpoints.push({ node: nodeName, address, ...(serviceState.port ? { port: serviceState.port } : {}) });
|
|
32
34
|
}
|
|
33
35
|
if (endpoints.length)
|
|
34
36
|
(out[state.project] ||= {})[service] = endpoints.sort((a, b) => a.node.localeCompare(b.node));
|
|
@@ -63,7 +65,7 @@ export function buildIngressWork({ states, endpoints, node, secrets, acmeEmail }
|
|
|
63
65
|
warnings.push(`${label}: ingress.port ${ingress.port} is not published; skipped`);
|
|
64
66
|
continue;
|
|
65
67
|
}
|
|
66
|
-
const upstreams = (endpoints[project]?.[service] || []).map(e => `${e.node === node ? '127.0.0.1' : e.address}:${port}`);
|
|
68
|
+
const upstreams = (endpoints[project]?.[service] || []).map(e => `${e.node === node ? '127.0.0.1' : e.address}:${e.port ?? port}`);
|
|
67
69
|
if (!upstreams.length) {
|
|
68
70
|
warnings.push(`${label}: no serving upstream for ${host}; route withheld`);
|
|
69
71
|
continue;
|
package/dist/protocol.d.ts
CHANGED
|
@@ -22,12 +22,25 @@ export interface IngressSpec {
|
|
|
22
22
|
cert?: string;
|
|
23
23
|
key?: string;
|
|
24
24
|
}
|
|
25
|
+
export type RolloutStrategy = 'recreate' | 'rolling';
|
|
26
|
+
/** `x-tug.rollout`: how a service is replaced on a new deployment. `recreate` (default) is `docker compose up -d`. */
|
|
27
|
+
export interface RolloutSpec {
|
|
28
|
+
strategy?: RolloutStrategy;
|
|
29
|
+
/** How long the previous revision keeps running after the new one takes over (default `30s`); covers the ingress switch. */
|
|
30
|
+
drain?: string | number;
|
|
31
|
+
}
|
|
25
32
|
/** The `x-tug` extension on a Compose service. */
|
|
26
33
|
export interface ServiceExtension {
|
|
27
34
|
placement?: Placement;
|
|
28
35
|
replicas?: number;
|
|
29
36
|
health?: HealthCheck;
|
|
30
37
|
ingress?: IngressSpec;
|
|
38
|
+
rollout?: RolloutSpec;
|
|
39
|
+
}
|
|
40
|
+
/** The top-level `x-tug` extension of a Compose file. */
|
|
41
|
+
export interface ProjectExtension {
|
|
42
|
+
/** `auto` creates a rollback deployment when a deployment fails on health; `manual` (default) only reports it. */
|
|
43
|
+
rollback?: 'auto' | 'manual';
|
|
31
44
|
}
|
|
32
45
|
/** The `x-tug` extension on a top-level Compose volume. */
|
|
33
46
|
export interface VolumeExtension {
|
|
@@ -59,6 +72,7 @@ export interface ComposeService {
|
|
|
59
72
|
[key: string]: unknown;
|
|
60
73
|
})[];
|
|
61
74
|
extra_hosts?: string[] | Record<string, string>;
|
|
75
|
+
depends_on?: string[] | Record<string, unknown>;
|
|
62
76
|
'x-tug'?: ServiceExtension;
|
|
63
77
|
[key: string]: unknown;
|
|
64
78
|
}
|
|
@@ -69,9 +83,28 @@ export interface ComposeVolume {
|
|
|
69
83
|
export interface ComposeFile {
|
|
70
84
|
services: Record<string, ComposeService>;
|
|
71
85
|
volumes?: Record<string, ComposeVolume>;
|
|
86
|
+
'x-tug'?: ProjectExtension;
|
|
72
87
|
[key: string]: unknown;
|
|
73
88
|
}
|
|
89
|
+
export interface ImageArtifact {
|
|
90
|
+
buildId: string;
|
|
91
|
+
imageRef: string;
|
|
92
|
+
imageId: string;
|
|
93
|
+
platform: string;
|
|
94
|
+
sha256: string;
|
|
95
|
+
sizeBytes: number;
|
|
96
|
+
}
|
|
97
|
+
export interface LocalImageUpload {
|
|
98
|
+
service: string;
|
|
99
|
+
imageRef: string;
|
|
100
|
+
imageId: string;
|
|
101
|
+
platform: string;
|
|
102
|
+
sha256: string;
|
|
103
|
+
sizeBytes: number;
|
|
104
|
+
field: string;
|
|
105
|
+
}
|
|
74
106
|
export interface ServiceSpec {
|
|
107
|
+
artifact?: ImageArtifact;
|
|
75
108
|
image?: string;
|
|
76
109
|
build: BuildConfig | null;
|
|
77
110
|
placement: Placement;
|
|
@@ -79,6 +112,7 @@ export interface ServiceSpec {
|
|
|
79
112
|
health: HealthCheck | null;
|
|
80
113
|
}
|
|
81
114
|
export interface DesiredState {
|
|
115
|
+
revision?: string;
|
|
82
116
|
project: string;
|
|
83
117
|
version: string;
|
|
84
118
|
services: Record<string, ServiceSpec>;
|
|
@@ -143,6 +177,7 @@ export interface WorkResponse {
|
|
|
143
177
|
nodeId: string;
|
|
144
178
|
desiredStates: DesiredState[];
|
|
145
179
|
commands: Command[];
|
|
180
|
+
retainedImages?: string[];
|
|
146
181
|
/** WireGuard mesh config for this node; present only when the mesh is enabled on control. */
|
|
147
182
|
network?: NetworkConfig;
|
|
148
183
|
/** Healthy cross-node endpoints per project and service; present only when service DNS is enabled. */
|
|
@@ -175,6 +210,8 @@ export interface WgKeyRequest {
|
|
|
175
210
|
export interface ServiceEndpoint {
|
|
176
211
|
node: string;
|
|
177
212
|
address: string;
|
|
213
|
+
/** Host port of the serving revision; set only for rolling services, whose port changes per revision. */
|
|
214
|
+
port?: number;
|
|
178
215
|
}
|
|
179
216
|
/** project -> service -> endpoints of nodes whose copy of the service reports `serving`. */
|
|
180
217
|
export type ServiceEndpoints = Record<string, Record<string, ServiceEndpoint[]>>;
|
|
@@ -208,10 +245,17 @@ export interface ServiceState {
|
|
|
208
245
|
rolloutState: RolloutState;
|
|
209
246
|
image?: string;
|
|
210
247
|
healthyAt?: string;
|
|
248
|
+
/** Rolling services: published host port of the revision that is serving. */
|
|
249
|
+
port?: number;
|
|
250
|
+
/** Rolling services: hash identifying the serving revision. */
|
|
251
|
+
rolloutRevision?: string;
|
|
252
|
+
/** Rolling services: why the newest revision was rejected while this (older) one keeps serving. */
|
|
253
|
+
rolloutError?: string;
|
|
211
254
|
}
|
|
212
255
|
export interface ProjectStatus {
|
|
213
256
|
project: string;
|
|
214
257
|
observedVersion: string;
|
|
258
|
+
observedRevision?: string;
|
|
215
259
|
health: ProjectHealth;
|
|
216
260
|
services?: string[];
|
|
217
261
|
serviceStates?: Record<string, ServiceState>;
|
|
@@ -0,0 +1,72 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
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
|
+
}
|