@wpelevator/tug 0.2.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 CHANGED
@@ -47,6 +47,7 @@ The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your h
47
47
  - [Configuration](docs/configuration.md): CLI, control and agent environment variables.
48
48
  - [Development](docs/development.md): local setup, CI, Compose stack, host mode and the integration test.
49
49
  - [Testing](docs/testing.md): test layers, the numbers rollouts are held to, and the backlog of mocked tests.
50
+ - [Observability](docs/observability.md): what can be observed today, and the proposed logs, metrics and alerting.
50
51
  - [Releases](docs/releases.md): changesets, npm and Docker image publishing.
51
52
  - [Standalone binary](docs/standalone-binary.md): proposed Bun-compiled CLI binaries and Homebrew distribution.
52
53
  - [Roadmap](docs/roadmap.md): planned milestones toward parity with and beyond uncloud.
package/dist/agent.js CHANGED
@@ -7,6 +7,7 @@ import { validateImageArchive, verifyDownload } from "./artifacts.js";
7
7
  import path from 'node:path';
8
8
  import { spawn, spawnSync } from 'node:child_process';
9
9
  import net from 'node:net';
10
+ import { createHash } from 'node:crypto';
10
11
  import { composeForNode, dumpYaml, isRolling, serviceSecretVersions } from "./compose.js";
11
12
  import { activeRollingStates, composeBase, hasLegacyContainers, reconcileRolling, retireAll } from "./rollout.js";
12
13
  import { ensureDir, errMessage, now, parseDuration } from "./util.js";
@@ -279,7 +280,7 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
279
280
  return { project: desiredState.project, observedVersion: desiredState.version, ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}), health: 'skipped', services: [], serviceStates: {} };
280
281
  }
281
282
  const serviceStates = Object.fromEntries(serviceNames.map(service => [service, { rolloutState: 'pending', image: desiredState.services[service]?.image }]));
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(() => { });
283
+ const emit = (service, rolloutState, message = rolloutState, level = 'info') => api(ctx, '/agent/v1/events', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: desiredState.project, level, type: `service.${rolloutState}`, message, metadata: { service, version: desiredState.version, node: ctx.nodeName } }) }).catch(() => { });
283
284
  const sec = await api(ctx, `/agent/v1/secrets?project=${encodeURIComponent(desiredState.project)}&services=${encodeURIComponent(serviceNames.join(','))}`);
284
285
  const currentSecretVersions = serviceSecretVersions(desiredState.compose, serviceNames, sec.versions || {});
285
286
  const db = await agentDb(ctx.stateDir);
@@ -295,11 +296,20 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
295
296
  await fs.writeFile(path.join(dir, 'docker-compose.yml'), dumpYaml(nodeCompose));
296
297
  await writeEnv(path.join(dir, '.env'), sec.secrets || {});
297
298
  const failedStatus = (error) => ({ project: desiredState.project, observedVersion: desiredState.version, ...(desiredState.revision ? { observedRevision: desiredState.revision } : {}), health: 'failed', services: serviceNames, serviceStates, error });
299
+ // Lifecycle events say what changed, not that the agent is still polling. They are sent when the desired revision,
300
+ // this node's rendered configuration or the secrets change, when an apply fails (once) and when it recovers (once).
301
+ const eventFingerprint = createHash('sha256').update(JSON.stringify([desiredState.revision || desiredState.version, dumpYaml(nodeCompose), mainServices.map(service => currentSecretVersions[service] || {})])).digest('hex').slice(0, 16);
302
+ const knownEvents = mainServices.length ? await db.get('SELECT fingerprint, phase FROM event_state WHERE project = ?', desiredState.project) : undefined;
303
+ const announce = knownEvents?.fingerprint !== eventFingerprint;
304
+ const wasFailed = !announce && knownEvents?.phase === 'failed';
305
+ const markEvents = (phase) => db.run('INSERT INTO event_state(project, fingerprint, phase) VALUES(?,?,?) ON CONFLICT(project) DO UPDATE SET fingerprint=excluded.fingerprint, phase=excluded.phase', desiredState.project, eventFingerprint, phase);
306
+ let failing;
298
307
  if (ctx.apply) {
299
308
  try {
300
309
  for (const service of mainServices) {
301
310
  serviceStates[service].rolloutState = 'pulling';
302
- await emit(service, 'pulling');
311
+ if (announce)
312
+ await emit(service, 'pulling');
303
313
  }
304
314
  for (const service of mainServices) {
305
315
  const artifact = desiredState.services[service].artifact;
@@ -311,7 +321,8 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
311
321
  await ctx.docker(['compose', '-p', desiredState.project, 'pull', ...(pullServices.length === mainServices.length ? [] : pullServices)], { cwd: dir });
312
322
  for (const service of mainServices) {
313
323
  serviceStates[service].rolloutState = 'starting';
314
- await emit(service, 'starting');
324
+ if (announce)
325
+ await emit(service, 'starting');
315
326
  }
316
327
  if (mainServices.length) {
317
328
  // Services that just became rolling still run here until their replacement takes over; keep them.
@@ -325,14 +336,25 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
325
336
  }
326
337
  for (const service of mainServices) {
327
338
  serviceStates[service].rolloutState = 'health_checking';
328
- await emit(service, 'health_checking');
339
+ if (announce)
340
+ await emit(service, 'health_checking');
341
+ failing = service;
329
342
  await waitForHealth(ctx, desiredState.project, service, desiredState.services[service]?.health, dir);
343
+ failing = undefined;
330
344
  serviceStates[service].rolloutState = 'serving';
331
345
  serviceStates[service].healthyAt = now();
332
- await emit(service, 'serving');
346
+ if (announce || wasFailed)
347
+ await emit(service, 'serving', wasFailed ? 'serving again after a failure' : 'serving');
333
348
  }
349
+ if (mainServices.length && (announce || wasFailed))
350
+ await markEvents('serving');
334
351
  }
335
352
  catch (e) {
353
+ if (mainServices.length && !wasFailed) {
354
+ // Announce a failure once; retries of the same failure stay quiet until it recovers or the inputs change.
355
+ await emit(failing ?? mainServices.join(', '), 'failed', errMessage(e), 'error');
356
+ await markEvents('failed');
357
+ }
336
358
  // Rolling services are untouched by a failure here, so keep reporting their current revision as serving.
337
359
  if (!rolling.length)
338
360
  throw e;
package/dist/cli.js CHANGED
@@ -311,7 +311,7 @@ program.command('events').description('list recent events with the token that ca
311
311
  const query = `limit=${encodeURIComponent(o.limit)}${project ? `&project=${encodeURIComponent(project)}` : ''}`;
312
312
  console.log(JSON.stringify(await request('GET', `/api/v1/events?${query}`), null, 2));
313
313
  });
314
- program.command('doctor').description('run local and control-plane diagnostics').option('--nodes', 'also queue diagnostics on registered agents').option('--node <name>', 'specific node for agent diagnostics').action(async (o) => {
314
+ program.command('doctor').description('run local and control-plane diagnostics').option('--nodes', 'also run diagnostics on every registered agent and wait').option('--node <name>', 'run diagnostics on one agent and wait').option('--timeout <seconds>', 'wait timeout for node diagnostics', '60').action(async (o) => {
315
315
  const local = [];
316
316
  for (const [name, cmd, args] of [['node', 'node', ['--version']], ['docker', 'docker', ['version', '--format', '{{.Server.Version}}']], ['docker_compose', 'docker', ['compose', 'version']]]) {
317
317
  try {
@@ -323,8 +323,17 @@ program.command('doctor').description('run local and control-plane diagnostics')
323
323
  }
324
324
  const controlDiagnostics = await request('GET', '/api/v1/diagnostics');
325
325
  const output = { ok: local.every(c => c.ok) && controlDiagnostics.ok, local, control: controlDiagnostics };
326
- if (o.nodes || o.node)
327
- output.nodeDiagnostics = await request('POST', '/api/v1/diagnostics/nodes', { node: o.node || null });
326
+ if (o.nodes || o.node) {
327
+ const queued = await request('POST', '/api/v1/diagnostics/nodes', { node: o.node || null });
328
+ const commands = [];
329
+ for (const command of queued.commands) {
330
+ const finished = await waitForCommand(command.commandId, Number(o.timeout));
331
+ commands.push({ nodeId: command.nodeId, state: finished.state, result: JSON.parse(finished.result_json || '{}') });
332
+ }
333
+ output.nodeDiagnostics = { commands };
334
+ if (commands.some(command => command.state !== 'succeeded'))
335
+ output.ok = false;
336
+ }
328
337
  console.log(JSON.stringify(output, null, 2));
329
338
  });
330
339
  program.command('rollback').option('--force').option('--no-wait', 'return immediately after activating rollback').option('--timeout <seconds>', 'wait timeout', '300').action(async (o) => {
package/dist/control.d.ts CHANGED
@@ -19,6 +19,7 @@ export interface ControlImageOperations {
19
19
  }
20
20
  export declare function createApp(options?: {
21
21
  images?: Partial<ControlImageOperations>;
22
- openDatabase?: typeof openDb;
22
+ openDatabase?: typeof openDb; /** Minimum time between event prunes triggered by agent reports; default one hour. */
23
+ eventPruneIntervalMs?: number;
23
24
  }): Promise<import("express-serve-static-core").Express>;
24
25
  export declare function listen(app: express.Express, port?: number): Promise<import('node:http').Server | https.Server>;
package/dist/control.js CHANGED
@@ -21,6 +21,33 @@ import { buildIngressWork, resolveServiceEndpoints } from "./ingress.js";
21
21
  import { ACTIVE_DEPLOYMENT_STATES } from "./protocol.js";
22
22
  import { AUTO_ROLLBACK_ACTOR, ROUTE_ROLES, assertProject, bodyProject, createGuard, createToken, listTokens, parseTokenRequest, queryProject, recordEvent, revokeToken } from "./tokens.js";
23
23
  const httpError = (status, message) => Object.assign(new Error(message), { status });
24
+ const missingProject = (project) => `project ${project} has no desired state`;
25
+ const missingService = (project, service) => `service ${service} not found in project ${project}`;
26
+ /** Collapse a node report into the deployment error `tug deploy` prints. Compose failures are multi-line; keep the reason, not the progress log. */
27
+ function deploymentFailureMessage(rows) {
28
+ const limit = 500;
29
+ const oneLine = (value) => {
30
+ const flat = value.replace(/\s+/g, ' ').trim();
31
+ return flat.length > limit ? `${flat.slice(0, limit)}...` : flat;
32
+ };
33
+ const details = rows.map(row => {
34
+ let status = {};
35
+ try {
36
+ status = JSON.parse(row.status_json);
37
+ }
38
+ catch { /* a bad report still names the node */ }
39
+ const parts = [];
40
+ if (typeof status.error === 'string' && status.error.trim())
41
+ parts.push(oneLine(status.error));
42
+ for (const [service, state] of Object.entries(status.serviceStates || {})) {
43
+ if (typeof state?.rolloutError === 'string' && state.rolloutError.trim())
44
+ parts.push(`${service}: ${oneLine(state.rolloutError)}`);
45
+ }
46
+ const detail = parts.join('; ');
47
+ return detail ? `${row.node_id}: ${detail}` : row.node_id;
48
+ });
49
+ return `one or more assigned nodes reported failed health: ${details.join('; ')}`;
50
+ }
24
51
  /** Versions of rollback deployments start with this; `tug deploy` may not use it, which is how automatic rollback tells them apart. */
25
52
  const ROLLBACK_VERSION_PREFIX = 'rollback-';
26
53
  const ACTIVE_STATES_SQL = ACTIVE_DEPLOYMENT_STATES.map(s => `'${s}'`).join(',');
@@ -90,6 +117,34 @@ export async function createApp(options = {}) {
90
117
  await reloadRegistry();
91
118
  }
92
119
  const db = await (options.openDatabase || openDb)(dbFile);
120
+ // Events are an operator-facing log, not a record of truth, so keep them bounded. `0` turns a limit off; anything that is
121
+ // not a whole number falls back to the default.
122
+ const envCount = (name, fallback) => {
123
+ const raw = process.env[name];
124
+ if (raw === undefined || raw === '')
125
+ return fallback;
126
+ const value = Number(raw);
127
+ if (Number.isInteger(value) && value >= 0)
128
+ return value;
129
+ console.warn(`${name}=${JSON.stringify(raw)} is not a whole number; using ${fallback}`);
130
+ return fallback;
131
+ };
132
+ const EVENT_RETENTION_DAYS = envCount('TUG_EVENT_RETENTION_DAYS', 30);
133
+ const EVENT_MAX_ROWS = envCount('TUG_EVENT_MAX_ROWS', 100_000);
134
+ const eventPruneIntervalMs = options.eventPruneIntervalMs ?? 60 * 60 * 1000;
135
+ let lastEventPrune = Date.now();
136
+ async function pruneEvents() {
137
+ lastEventPrune = Date.now();
138
+ let removed = 0;
139
+ if (EVENT_RETENTION_DAYS > 0)
140
+ removed += (await db.run('DELETE FROM events WHERE created_at < ?', new Date(Date.now() - EVENT_RETENTION_DAYS * 86_400_000).toISOString())).changes ?? 0;
141
+ if (EVENT_MAX_ROWS > 0)
142
+ removed += (await db.run('DELETE FROM events WHERE id IN (SELECT id FROM events ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)', EVENT_MAX_ROWS)).changes ?? 0;
143
+ if (removed)
144
+ console.log(`removed ${removed} old events (kept ${EVENT_RETENTION_DAYS || 'any'} days, at most ${EVENT_MAX_ROWS || 'unlimited'} rows)`);
145
+ return removed;
146
+ }
147
+ await pruneEvents().catch(e => console.error(`event pruning failed: ${errMessage(e)}`));
93
148
  const masterKey = await loadOrCreateMasterKey(masterKeyFile);
94
149
  const operator = await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
95
150
  if (!operator) {
@@ -184,9 +239,9 @@ export async function createApp(options = {}) {
184
239
  const statuses = await db.all('SELECT node_id,observed_version,health,status_json FROM node_statuses WHERE project_id=?', projectId);
185
240
  const byNode = new Map(statuses.map(s => [s.node_id, s]));
186
241
  const reportsRevision = (node) => state.revision ? JSON.parse(byNode.get(node)?.status_json || '{}').observedRevision === state.revision : byNode.get(node)?.observed_version === state.version;
187
- const failed = [...required].some(node => reportsRevision(node) && byNode.get(node)?.health === 'failed');
188
- if (failed) {
189
- const marked = await db.run('UPDATE deployments SET state=?,error_message=?,updated_at=? WHERE id=? AND state=?', 'failed', 'one or more assigned nodes reported failed health', now(), project.current_deployment_id, 'rolling_out');
242
+ const failedNodes = [...required].filter(node => reportsRevision(node) && byNode.get(node)?.health === 'failed').map(node => byNode.get(node));
243
+ if (failedNodes.length) {
244
+ const marked = await db.run('UPDATE deployments SET state=?,error_message=?,updated_at=? WHERE id=? AND state=?', 'failed', deploymentFailureMessage(failedNodes), now(), project.current_deployment_id, 'rolling_out');
190
245
  if (marked.changes === 1)
191
246
  scheduleAutoRollback(projectId, project.current_deployment_id, state);
192
247
  }
@@ -877,11 +932,11 @@ export async function createApp(options = {}) {
877
932
  throw httpError(400, 'service required');
878
933
  const ds = await currentDesired(project);
879
934
  if (!ds)
880
- throw httpError(404, 'project has no desired state');
935
+ throw httpError(404, missingProject(project));
881
936
  const state = JSON.parse(ds.state_json);
882
937
  const svc = state.services?.[service];
883
938
  if (!svc)
884
- throw httpError(404, 'service not found');
939
+ throw httpError(404, missingService(project, service));
885
940
  const nodeName = svc.placement?.nodes?.[0];
886
941
  if (!nodeName)
887
942
  throw httpError(400, 'service has no explicit node placement');
@@ -912,11 +967,11 @@ export async function createApp(options = {}) {
912
967
  return res.status(400).json({ error: 'service required' });
913
968
  const ds = await currentDesired(project);
914
969
  if (!ds)
915
- return res.status(404).json({ error: 'project has no desired state' });
970
+ return res.status(404).json({ error: missingProject(project) });
916
971
  const state = JSON.parse(ds.state_json);
917
972
  const svc = state.services?.[service];
918
973
  if (!svc)
919
- return res.status(404).json({ error: 'service not found' });
974
+ return res.status(404).json({ error: missingService(project, service) });
920
975
  const nodeName = svc.placement?.nodes?.[0];
921
976
  if (!nodeName)
922
977
  return res.status(400).json({ error: 'service has no explicit node placement' });
@@ -986,6 +1041,8 @@ export async function createApp(options = {}) {
986
1041
  });
987
1042
  api('post', '/api/v1/diagnostics/nodes', null, async (req, res) => {
988
1043
  const nodes = req.body.node ? await db.all('SELECT id FROM nodes WHERE id=? OR name=?', req.body.node, req.body.node) : await db.all('SELECT id FROM nodes');
1044
+ if (req.body.node && !nodes.length)
1045
+ return res.status(404).json({ error: `node ${req.body.node} not found` });
989
1046
  const ts = now();
990
1047
  const commands = [];
991
1048
  for (const node of nodes) {
@@ -1069,8 +1126,14 @@ export async function createApp(options = {}) {
1069
1126
  await db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', ts, 'online', req.node.id);
1070
1127
  res.json({ ok: true });
1071
1128
  });
1072
- post('/agent/v1/events', authAgent, async (req, res) => { const evs = req.body.events || [req.body]; for (const ev of evs)
1073
- await db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at) VALUES(?,?,?,?,?,?,?,?,?)', id('evt'), ev.projectId || null, ev.deploymentId || null, req.node.id, ev.level || 'info', ev.type || 'event', ev.message || '', JSON.stringify(ev.metadata || {}), now()); res.json({ ok: true }); });
1129
+ post('/agent/v1/events', authAgent, async (req, res) => {
1130
+ const evs = req.body.events || [req.body];
1131
+ for (const ev of evs)
1132
+ await db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at) VALUES(?,?,?,?,?,?,?,?,?)', id('evt'), ev.projectId || null, ev.deploymentId || null, req.node.id, ev.level || 'info', ev.type || 'event', ev.message || '', JSON.stringify(ev.metadata || {}), now());
1133
+ if (Date.now() - lastEventPrune >= eventPruneIntervalMs)
1134
+ await pruneEvents().catch(e => console.error(`event pruning failed: ${errMessage(e)}`));
1135
+ res.json({ ok: true });
1136
+ });
1074
1137
  get('/agent/v1/secrets', authAgent, async (req, res) => {
1075
1138
  const project = String(req.query.project || 'site');
1076
1139
  let rows = await db.all('SELECT name,encrypted_value,version FROM secrets WHERE project_id=?', project);
package/dist/db.d.ts CHANGED
@@ -105,6 +105,12 @@ export interface LockRow {
105
105
  expires_at: string;
106
106
  created_at: string;
107
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
+ }
108
114
  export interface AppliedStateRow {
109
115
  project: string;
110
116
  version: string;
package/dist/db.js CHANGED
@@ -22,6 +22,11 @@ CREATE TABLE IF NOT EXISTS applied_state(
22
22
  service_states_json TEXT,
23
23
  secret_versions_json TEXT
24
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
+ );
25
30
  `);
26
31
  return db;
27
32
  }
@@ -43,6 +48,7 @@ CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY, node_id TEXT, project_i
43
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);
44
49
  CREATE INDEX IF NOT EXISTS events_project_created ON events(project_id, created_at);
45
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);
46
52
  CREATE TABLE IF NOT EXISTS locks(name TEXT PRIMARY KEY, owner TEXT, expires_at TEXT, created_at TEXT);
47
53
  `);
48
54
  const row = await db.get('SELECT value FROM meta WHERE key = ?', 'schema_created_at');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/tug",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Portable VPS deployments with Docker Compose",
5
5
  "repository": {
6
6
  "type": "git",