@wpelevator/tug 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +10 -11
  2. package/dist/agent-jobs.d.ts +41 -0
  3. package/dist/agent-jobs.js +161 -0
  4. package/dist/agent.d.ts +1 -3
  5. package/dist/agent.js +58 -35
  6. package/dist/backup-config.d.ts +47 -0
  7. package/dist/backup-config.js +256 -0
  8. package/dist/backup-destination.d.ts +22 -0
  9. package/dist/backup-destination.js +72 -0
  10. package/dist/backup-job.d.ts +32 -0
  11. package/dist/backup-job.js +352 -0
  12. package/dist/backup-restore.d.ts +67 -0
  13. package/dist/backup-restore.js +297 -0
  14. package/dist/backup-scheduler.d.ts +54 -0
  15. package/dist/backup-scheduler.js +403 -0
  16. package/dist/build.d.ts +2 -3
  17. package/dist/build.js +1 -3
  18. package/dist/cli-build.d.ts +3 -3
  19. package/dist/cli-build.js +18 -6
  20. package/dist/cli-config.d.ts +45 -5
  21. package/dist/cli-config.js +269 -19
  22. package/dist/cli-live-transport.d.ts +1 -1
  23. package/dist/cli-live-transport.js +2 -2
  24. package/dist/cli-live.d.ts +2 -0
  25. package/dist/cli-live.js +1 -0
  26. package/dist/cli.js +217 -49
  27. package/dist/compose.d.ts +1 -2
  28. package/dist/compose.js +7 -6
  29. package/dist/container-spec.d.ts +3 -2
  30. package/dist/container-spec.js +9 -34
  31. package/dist/control.d.ts +4 -2
  32. package/dist/control.js +591 -381
  33. package/dist/cron.d.ts +17 -0
  34. package/dist/cron.js +124 -0
  35. package/dist/db-driver.d.ts +20 -0
  36. package/dist/db-driver.js +11 -0
  37. package/dist/db-node.d.ts +3 -0
  38. package/dist/db-node.js +60 -0
  39. package/dist/db.d.ts +79 -6
  40. package/dist/db.js +47 -26
  41. package/dist/docker-api.d.ts +7 -0
  42. package/dist/docker-api.js +15 -3
  43. package/dist/live.d.ts +8 -0
  44. package/dist/live.js +84 -0
  45. package/dist/protocol.d.ts +121 -12
  46. package/dist/protocol.js +7 -0
  47. package/dist/registry.d.ts +17 -0
  48. package/dist/registry.js +133 -0
  49. package/dist/templates/control-compose.yml +0 -4
  50. package/dist/templates/node-install.sh +0 -1
  51. package/dist/tokens.d.ts +7 -7
  52. package/dist/tokens.js +34 -21
  53. package/package.json +2 -3
@@ -24,6 +24,40 @@ export interface IngressSpec {
24
24
  cert?: string;
25
25
  key?: string;
26
26
  }
27
+ /** What a backup captures: the output of a command run in a service, or the files of a volume. */
28
+ export type BackupType = 'command' | 'volume';
29
+ /** A restic keep policy; at least one key is required. */
30
+ export interface BackupRetention {
31
+ last?: number;
32
+ hourly?: number;
33
+ daily?: number;
34
+ weekly?: number;
35
+ monthly?: number;
36
+ yearly?: number;
37
+ }
38
+ /** `x-tug.backup` on a service or top-level volume, as an object. */
39
+ export interface BackupSpec {
40
+ /** Five-field cron in UTC, or `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`. */
41
+ schedule?: string;
42
+ retention?: BackupRetention;
43
+ /** Seconds of deterministic per-target delay after the scheduled minute. */
44
+ jitter?: number;
45
+ /** Hard limit for one run, such as `30m` or `2h`. */
46
+ timeout?: string | number;
47
+ /** Services only: run in the service's container; its standard output is the backup. A string runs through `sh -c`. */
48
+ command?: string | string[];
49
+ /** Services only: run in the service container to restore; the stored file arrives on its standard input. */
50
+ restore?: string | string[];
51
+ /** Services only: name of the file inside the snapshot (default `<service>.out`). */
52
+ filename?: string;
53
+ /** Services only: text that must appear in the last 4 KiB of output, so a command cut short is not kept as a good backup. */
54
+ verify?: string;
55
+ /** Volumes only: restic exclude patterns. */
56
+ exclude?: string[];
57
+ /** Services to hold during a restore. */
58
+ writers?: string[];
59
+ }
60
+ export type BackupConfig = boolean | string | BackupSpec;
27
61
  export type RolloutStrategy = 'recreate' | 'rolling';
28
62
  /** `x-tug.rollout`: how a service is replaced on a new deployment. `recreate` (default) is `docker compose up -d`. */
29
63
  export interface RolloutSpec {
@@ -39,11 +73,14 @@ export interface ServiceExtension {
39
73
  health?: HealthCheck;
40
74
  ingress?: IngressSpec;
41
75
  rollout?: RolloutSpec;
76
+ backup?: BackupConfig;
42
77
  }
43
78
  /** The top-level `x-tug` extension of a Compose file. */
44
79
  export interface ProjectExtension {
45
80
  /** `auto` creates a rollback deployment when a deployment fails on health; `manual` (default) only reports it. */
46
81
  rollback?: 'auto' | 'manual';
82
+ /** Defaults for every `x-tug.backup` target; they enable nothing by themselves. */
83
+ backup?: Pick<BackupSpec, 'schedule' | 'retention'>;
47
84
  }
48
85
  /** The `x-tug` extension on a top-level Compose volume. */
49
86
  export interface VolumeExtension {
@@ -51,10 +88,7 @@ export interface VolumeExtension {
51
88
  driver?: string;
52
89
  persistent?: boolean;
53
90
  placement?: Placement;
54
- /** NFS exporter address; control fills in the exporter's overlay IP (or node address) at deploy time. */
55
- address?: string;
56
- /** Directory exported by the NFS server on the exporter node (default `/srv/tug/<volume>`). */
57
- export?: string;
91
+ backup?: BackupConfig;
58
92
  }
59
93
  export type BuildConfig = string | {
60
94
  context?: string;
@@ -130,7 +164,74 @@ export declare const ACTIVE_DEPLOYMENT_STATES: readonly ["uploading", "building"
130
164
  /** Deployment states a CLI wait loop stops on. */
131
165
  export declare const TERMINAL_DEPLOYMENT_STATES: readonly ["succeeded", "failed", "build_failed", "cancelled"];
132
166
  export type DeploymentState = (typeof ACTIVE_DEPLOYMENT_STATES)[number] | (typeof TERMINAL_DEPLOYMENT_STATES)[number];
133
- export type CommandType = 'diagnostics.run' | 'service.restart' | 'logs.fetch' | 'volume.delete' | 'wg.rotate-key';
167
+ export type CommandType = 'diagnostics.run' | 'service.restart' | 'logs.fetch' | 'volume.delete' | 'wg.rotate-key' | 'backup.check' | 'backup.run' | 'backup.restore' | 'backup.list';
168
+ /** Commands whose helper containers need the backup destination's credentials. */
169
+ export declare const BACKUP_COMMAND_TYPES: readonly CommandType[];
170
+ /** `GET /agent/v1/backup/credentials`: environment for the restic helper of one running backup command. */
171
+ export interface BackupCredentialsResponse {
172
+ env: Record<string, string>;
173
+ }
174
+ /** Why a command may be refused or a run marked missed; recorded on the run. */
175
+ export type BackupMissReason = 'node_offline' | 'control_down' | 'deadline';
176
+ export type JobPhase = 'queued' | 'pulling' | 'checking' | 'dumping' | 'uploading' | 'forgetting' | 'restoring';
177
+ /** Payload of `backup.check`, `backup.run` and `backup.restore`. Restic credentials are never in a payload. */
178
+ export interface BackupJobPayload {
179
+ runId: string;
180
+ project: string;
181
+ /** `service:db` or `volume:uploads`; empty for `backup.check`. */
182
+ target: string;
183
+ type: BackupType;
184
+ /** The project's restic repository, `<destination>/<project>`. */
185
+ repository: string;
186
+ resticImage: string;
187
+ /** Used as restic's `--host`, so each node keeps its own snapshot chain. */
188
+ node: string;
189
+ tags: string[];
190
+ retention?: BackupRetention;
191
+ /** Command targets: what to run in the service's container, and how to name and check its output. */
192
+ command?: {
193
+ service: string;
194
+ argv: string[];
195
+ filename: string;
196
+ verify?: string;
197
+ /** Run in the service container with the snapshot's file on stdin when restoring. */
198
+ restoreArgv?: string[];
199
+ };
200
+ /** Volume targets: the Docker volume to mount read-only; it must already exist on the node. */
201
+ volume?: {
202
+ name: string;
203
+ dockerVolume: string;
204
+ exclude?: string[];
205
+ };
206
+ limits: {
207
+ memory: string;
208
+ timeoutSec: number;
209
+ };
210
+ restore?: {
211
+ snapshotId: string;
212
+ mode: 'in-place' | 'scratch';
213
+ scratchVolume?: string;
214
+ };
215
+ }
216
+ export interface BackupJobResult {
217
+ runId: string;
218
+ snapshotId?: string;
219
+ addedBytes?: number;
220
+ totalBytes?: number;
221
+ durationMs: number;
222
+ resticVersion?: string;
223
+ /** Repository state for `backup.check`. */
224
+ repository?: 'ok' | 'initialized';
225
+ warnings?: string[];
226
+ }
227
+ /** Live jobs in `StatusReport.jobs`, so control can tell "running" from "lost". */
228
+ export interface JobStatus {
229
+ commandId: string;
230
+ runId: string;
231
+ phase: JobPhase;
232
+ startedAt: string;
233
+ bytes?: number;
234
+ }
134
235
  /** A `commands` table row as delivered in `GET /agent/v1/work`. */
135
236
  export interface Command {
136
237
  id: string;
@@ -173,7 +274,7 @@ export type CommandResultPayload = {
173
274
  deleted: true;
174
275
  } | {
175
276
  publicKey: string;
176
- } | {
277
+ } | BackupJobResult | {
177
278
  error: string;
178
279
  };
179
280
  export interface CommandResult {
@@ -206,6 +307,8 @@ export type LiveRequest = {
206
307
  tty: boolean;
207
308
  columns?: number;
208
309
  rows?: number;
310
+ /** Run the command in a new container cloned from the service's, instead of in the running one. */
311
+ oneOff?: boolean;
209
312
  } | {
210
313
  operation: 'logs';
211
314
  project: string;
@@ -327,6 +430,8 @@ export interface WorkResponse {
327
430
  ingress?: IngressWork;
328
431
  /** Host addresses for published ports that name no host IP. */
329
432
  ports?: PortBinding;
433
+ /** Per project, services that must stay stopped because a restore is rewriting their data. */
434
+ holds?: Record<string, string[]>;
330
435
  }
331
436
  /**
332
437
  * Where a node's published ports listen when a `ports` entry names no host IP. Entries with an
@@ -393,7 +498,9 @@ export interface IngressStatus {
393
498
  error?: string;
394
499
  }
395
500
  export type ProjectHealth = 'serving' | 'skipped' | 'failed';
396
- export type RolloutState = 'pending' | 'pulling' | 'starting' | 'health_checking' | 'serving';
501
+ export type RolloutState = 'pending' | 'pulling' | 'starting' | 'health_checking' | 'serving'
502
+ /** Stopped on purpose while a restore rewrites its data. */
503
+ | 'held';
397
504
  export interface ServiceState {
398
505
  rolloutState: RolloutState;
399
506
  image?: string;
@@ -419,8 +526,14 @@ export interface StatusReport {
419
526
  projects: ProjectStatus[];
420
527
  commandResults: CommandResult[];
421
528
  ingress?: IngressStatus;
529
+ /** Backup jobs still running on this node; absent when none. */
530
+ jobs?: JobStatus[];
422
531
  }
423
532
  export interface SecretsResponse {
533
+ registryAuth?: Record<string, {
534
+ username: string;
535
+ password: string;
536
+ }>;
424
537
  secrets: Record<string, string>;
425
538
  versions: Record<string, number>;
426
539
  }
@@ -447,7 +560,6 @@ export interface NodeInfo {
447
560
  export interface RegisterResponse {
448
561
  node: NodeInfo;
449
562
  agentToken: string;
450
- registryToken: string;
451
563
  }
452
564
  /** Body of `POST /agent/v1/enroll`. */
453
565
  export interface EnrollRequest {
@@ -461,16 +573,13 @@ export interface EnrollRequest {
461
573
  labels?: string[];
462
574
  }
463
575
  /** Response of `POST /agent/v1/enroll`. */
464
- export interface EnrollResponse extends RegisterResponse {
465
- registry: string;
466
- }
576
+ export type EnrollResponse = RegisterResponse;
467
577
  /** Response of `POST /api/v1/nodes/enroll-token`. */
468
578
  export interface EnrollTokenResponse {
469
579
  enrollToken: string;
470
580
  name: string | null;
471
581
  usesRemaining: number;
472
582
  expiresAt: string;
473
- registry: string;
474
583
  }
475
584
  /** The base64url-encoded JSON payload of TUG_JOIN_KEY. */
476
585
  export interface JoinKey {
package/dist/protocol.js CHANGED
@@ -16,6 +16,13 @@ export const TERMINAL_DEPLOYMENT_STATES = [
16
16
  'build_failed',
17
17
  'cancelled',
18
18
  ];
19
+ /** Commands whose helper containers need the backup destination's credentials. */
20
+ export const BACKUP_COMMAND_TYPES = [
21
+ 'backup.check',
22
+ 'backup.run',
23
+ 'backup.restore',
24
+ 'backup.list',
25
+ ];
19
26
  export const LIVE_MAX_FRAME_BYTES = 256 * 1024;
20
27
  export const LIVE_MAX_BUFFERED_BYTES = 1024 * 1024;
21
28
  export const LIVE_MAX_SESSIONS = 64;
@@ -0,0 +1,17 @@
1
+ export declare const REGISTRY_AUTH_SECRET = "TUG_REGISTRY_AUTH";
2
+ export interface RegistryCredential {
3
+ username: string;
4
+ password: string;
5
+ }
6
+ export type RegistryCredentials = Record<string, RegistryCredential>;
7
+ /** A project secret containing registry-host -> username/password entries; never a desired-state field. */
8
+ export declare function parseRegistryCredentials(value?: string): RegistryCredentials;
9
+ export declare function imageLocation(image: string): {
10
+ host: string;
11
+ repository: string;
12
+ tag: string;
13
+ digest: string;
14
+ name: string;
15
+ };
16
+ /** Pin the complete index, so amd64 and arm64 agents select from the same immutable manifest. */
17
+ export declare function resolveImage(image: string, credentials?: RegistryCredentials, request?: typeof fetch): Promise<string>;
@@ -0,0 +1,133 @@
1
+ import { createHash } from 'node:crypto';
2
+ export const REGISTRY_AUTH_SECRET = 'TUG_REGISTRY_AUTH';
3
+ /** A project secret containing registry-host -> username/password entries; never a desired-state field. */
4
+ export function parseRegistryCredentials(value) {
5
+ if (!value)
6
+ return {};
7
+ try {
8
+ const entries = JSON.parse(value);
9
+ if (!entries || typeof entries !== 'object' || Array.isArray(entries))
10
+ throw new Error();
11
+ const credentials = {};
12
+ for (const [host, auth] of Object.entries(entries)) {
13
+ if (!/^[a-zA-Z0-9.:[\]-]+$/.test(host) ||
14
+ !auth ||
15
+ typeof auth !== 'object' ||
16
+ typeof auth.username !== 'string' ||
17
+ typeof auth.password !== 'string')
18
+ throw new Error();
19
+ credentials[host] = { username: auth.username, password: auth.password };
20
+ }
21
+ return credentials;
22
+ }
23
+ catch {
24
+ throw new Error(`${REGISTRY_AUTH_SECRET} must be a JSON object mapping registry hosts to username/password objects`);
25
+ }
26
+ }
27
+ export function imageLocation(image) {
28
+ const match = /^(?<name>[^@\s]+?)(?:@(?<digest>sha256:[a-f0-9]{64}))?$/.exec(image);
29
+ if (!match?.groups)
30
+ throw new Error('invalid image reference');
31
+ const { name, digest } = match.groups;
32
+ const slash = name.indexOf('/');
33
+ const qualified = slash >= 0 && /[.:]|^localhost$/.test(name.slice(0, slash));
34
+ let host = qualified ? name.slice(0, slash) : 'docker.io';
35
+ if (host === 'index.docker.io')
36
+ host = 'docker.io';
37
+ let repository = qualified ? name.slice(slash + 1) : name;
38
+ const colon = repository.lastIndexOf(':');
39
+ const tag = colon >= 0 ? repository.slice(colon + 1) : 'latest';
40
+ if (colon >= 0)
41
+ repository = repository.slice(0, colon);
42
+ if (host === 'docker.io' && !repository.includes('/'))
43
+ repository = `library/${repository}`;
44
+ if (!/^[a-z0-9]+(?:[._-]+[a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-]+[a-z0-9]+)*)*$/.test(repository) ||
45
+ !/^[\w][\w.-]{0,127}$/.test(tag) ||
46
+ !/^[a-zA-Z0-9.:[\]-]+$/.test(host))
47
+ throw new Error('invalid image reference');
48
+ return { host, repository, tag, digest, name };
49
+ }
50
+ const accept = [
51
+ 'application/vnd.oci.image.index.v1+json',
52
+ 'application/vnd.docker.distribution.manifest.list.v2+json',
53
+ 'application/vnd.oci.image.manifest.v1+json',
54
+ 'application/vnd.docker.distribution.manifest.v2+json',
55
+ ].join(', ');
56
+ /** Pin the complete index, so amd64 and arm64 agents select from the same immutable manifest. */
57
+ export async function resolveImage(image, credentials = {}, request = fetch) {
58
+ const location = imageLocation(image);
59
+ if (location.digest)
60
+ return image;
61
+ const { host, repository, tag, name } = location;
62
+ const endpoint = host === 'docker.io' ? 'registry-1.docker.io' : host;
63
+ const url = `https://${endpoint}/v2/${repository}/manifests/${tag}`;
64
+ const auth = credentials[host];
65
+ const basic = auth
66
+ ? `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`
67
+ : undefined;
68
+ const get = (authorization) => request(url, {
69
+ headers: { Accept: accept, ...(authorization ? { Authorization: authorization } : {}) },
70
+ redirect: 'error',
71
+ signal: AbortSignal.timeout(30_000),
72
+ });
73
+ try {
74
+ let response = await get();
75
+ if (response.status === 401) {
76
+ const challenge = response.headers.get('www-authenticate') || '';
77
+ await response.body?.cancel();
78
+ if (/^Basic\s/i.test(challenge) && basic)
79
+ response = await get(basic);
80
+ else if (/^Bearer\s/i.test(challenge)) {
81
+ const params = Object.fromEntries([...challenge.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
82
+ const tokenUrl = new URL(params.realm);
83
+ if (tokenUrl.protocol !== 'https:' || tokenUrl.username || tokenUrl.password)
84
+ throw new Error();
85
+ if (params.service)
86
+ tokenUrl.searchParams.set('service', params.service);
87
+ tokenUrl.searchParams.set('scope', `repository:${repository}:pull`);
88
+ const tokenResponse = await request(tokenUrl, {
89
+ headers: basic ? { Authorization: basic } : {},
90
+ redirect: 'error',
91
+ signal: AbortSignal.timeout(30_000),
92
+ });
93
+ if (!tokenResponse.ok) {
94
+ await tokenResponse.body?.cancel();
95
+ throw new Error();
96
+ }
97
+ const body = (await tokenResponse.json());
98
+ const token = body.token || body.access_token;
99
+ if (!token || typeof token !== 'string')
100
+ throw new Error();
101
+ response = await get(`Bearer ${token}`);
102
+ }
103
+ else
104
+ throw new Error();
105
+ }
106
+ if (!response.ok) {
107
+ await response.body?.cancel();
108
+ throw new Error();
109
+ }
110
+ // Hash the exact bytes instead of trusting a registry's digest header. Bound manifest memory use.
111
+ const chunks = [];
112
+ let size = 0;
113
+ for await (const chunk of response.body) {
114
+ size += chunk.length;
115
+ if (size > 8 * 1024 * 1024)
116
+ throw new Error();
117
+ chunks.push(chunk);
118
+ }
119
+ const bytes = Buffer.concat(chunks);
120
+ const manifest = JSON.parse(bytes.toString());
121
+ if (manifest.schemaVersion !== 2 ||
122
+ !(Array.isArray(manifest.manifests) || (manifest.config && Array.isArray(manifest.layers))))
123
+ throw new Error();
124
+ const digest = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
125
+ const reported = response.headers.get('docker-content-digest');
126
+ if (reported && reported !== digest)
127
+ throw new Error();
128
+ return `${name}@${digest}`;
129
+ }
130
+ catch {
131
+ throw new Error(`cannot resolve image ${image}; check registry availability and project ${REGISTRY_AUTH_SECRET} credentials`);
132
+ }
133
+ }
@@ -8,12 +8,8 @@ services:
8
8
  TUG_DATA_DIR: /data
9
9
  TUG_OPERATOR_TOKEN: {{OPERATOR_TOKEN}}
10
10
  TUG_MESH: {{MESH}}
11
- TUG_REGISTRY: {{REGISTRY_HOST}}
12
- TUG_REGISTRY_AUTH_PATH: {{REGISTRY_AUTH_PATH}}
13
- TUG_REGISTRY_CONTAINER: {{REGISTRY_CONTAINER}}
14
11
  ports:
15
12
  - "{{PORT}}:8080"
16
13
  volumes:
17
14
  - /var/lib/tug-control:/data
18
- - /var/lib/tug-registry-auth:/registry-auth
19
15
  - /var/run/docker.sock:/var/run/docker.sock
@@ -3,6 +3,5 @@ mkdir -p /etc/tug /var/lib/tug
3
3
  cat > /etc/tug/agent.env <<'ENV'
4
4
  {{ENV}}ENV
5
5
  chmod 0600 /etc/tug/agent.env
6
- {{DOCKER_LOGIN}}
7
6
  docker rm -f tug-agent >/dev/null 2>&1 || true
8
7
  docker run -d --name tug-agent --restart unless-stopped --env-file /etc/tug/agent.env {{RUN_FLAGS}}-v /var/run/docker.sock:/var/run/docker.sock -v /var/lib/tug:/var/lib/tug {{IMAGE_SH}} agent
package/dist/tokens.d.ts CHANGED
@@ -14,7 +14,7 @@ export declare const PROJECT_NAME_PATTERN: RegExp;
14
14
  /**
15
15
  * The single source of truth for who may call what. Every `/api/v1` route must appear here: createApp refuses to
16
16
  * 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.
17
+ * credential check and are not behind the token guard. docs/access-control.md mirrors this table.
18
18
  */
19
19
  export declare const ROUTE_ROLES: Record<string, RouteRole>;
20
20
  /** The authenticated caller of an API request. `projects` is null when the credential is not project-scoped. */
@@ -60,14 +60,14 @@ export declare function parseTokenRequest(body: unknown): {
60
60
  expiresAt: string | null;
61
61
  };
62
62
  /** Creates a token. The plaintext is returned once; only its hash is stored. */
63
- export declare function createToken(db: Db, request: ReturnType<typeof parseTokenRequest>, createdBy: string): Promise<TokenInfo & {
63
+ export declare function createToken(db: Db, request: ReturnType<typeof parseTokenRequest>, createdBy: string): TokenInfo & {
64
64
  token: string;
65
- }>;
66
- export declare function listTokens(db: Db): Promise<TokenInfo[]>;
65
+ };
66
+ export declare function listTokens(db: Db): TokenInfo[];
67
67
  /** Revokes a token immediately. Returns false if no such token exists; revoking twice keeps the first revocation time. */
68
- export declare function revokeToken(db: Db, name: string): Promise<boolean>;
68
+ export declare function revokeToken(db: Db, name: string): boolean;
69
69
  /** Resolves a bearer value to an actor by hash lookup: the root operator token first, then named tokens. */
70
- export declare function authenticate(db: Db, raw: string): Promise<Actor | null>;
70
+ export declare function authenticate(db: Db, raw: string): Actor | null;
71
71
  /**
72
72
  * Which project a request targets: a name, `null` for control-wide data (admin only), or `undefined` when it cannot be
73
73
  * resolved (for example an unknown ID). Unresolvable targets are denied to scoped tokens without saying why, so they
@@ -88,4 +88,4 @@ export declare function recordEvent(db: Db, event: {
88
88
  project?: string | null;
89
89
  deploymentId?: string | null;
90
90
  metadata?: Record<string, unknown>;
91
- }): Promise<void>;
91
+ }): void;
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,11 +39,19 @@ 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',
45
+ 'POST /api/v1/backups/run': 'deploy',
46
+ 'GET /api/v1/backups': 'read',
47
+ 'POST /api/v1/restores': 'admin',
48
+ 'GET /api/v1/restores': 'read',
49
+ 'POST /api/v1/restores/:id/release': 'admin',
50
+ 'POST /api/v1/restores/:id/resume': 'admin',
51
+ 'GET /api/v1/backup/destination': 'admin',
52
+ 'POST /api/v1/backup/destination': 'admin',
53
+ 'DELETE /api/v1/backup/destination': 'admin',
54
+ 'GET /api/v1/backup/key': 'admin',
46
55
  'POST /api/v1/control/backups/run': 'admin',
47
56
  'POST /api/v1/control/restore': 'admin',
48
57
  'GET /api/v1/diagnostics': 'admin',
@@ -50,7 +59,6 @@ export const ROUTE_ROLES = {
50
59
  'POST /api/v1/tokens': 'admin',
51
60
  'GET /api/v1/tokens': 'admin',
52
61
  'DELETE /api/v1/tokens/:name': 'admin',
53
- 'POST /api/v1/registry/auth': 'public',
54
62
  };
55
63
  const statusError = (status, message) => Object.assign(new Error(message), { status });
56
64
  export const isRole = (value) => typeof value === 'string' && ROLES.includes(value);
@@ -98,46 +106,51 @@ const info = (row) => ({
98
106
  revokedAt: row.revoked_at,
99
107
  });
100
108
  /** Creates a token. The plaintext is returned once; only its hash is stored. */
101
- export async function createToken(db, request, createdBy) {
109
+ export function createToken(db, request, createdBy) {
102
110
  const value = token(TOKEN_PREFIX);
103
111
  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);
112
+ 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
113
  }
106
114
  catch (e) {
107
- if (/UNIQUE constraint/.test(e.message))
115
+ if (isUniqueViolation(e))
108
116
  throw statusError(409, `token name ${request.name} is already used (revoked tokens keep their names for the audit trail)`);
109
117
  throw e;
110
118
  }
111
119
  return {
112
- ...info((await db.get('SELECT * FROM api_tokens WHERE name=?', request.name))),
120
+ ...info(db.get('SELECT * FROM api_tokens WHERE name=?', request.name)),
113
121
  token: value,
114
122
  };
115
123
  }
116
- export async function listTokens(db) {
117
- return (await db.all('SELECT * FROM api_tokens ORDER BY created_at, name')).map(info);
124
+ export function listTokens(db) {
125
+ return db.all('SELECT * FROM api_tokens ORDER BY created_at, name').map(info);
118
126
  }
119
127
  /** 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);
128
+ export function revokeToken(db, name) {
129
+ const row = db.get('SELECT * FROM api_tokens WHERE name=?', name);
122
130
  if (!row)
123
131
  return false;
124
- await db.run('UPDATE api_tokens SET revoked_at=? WHERE id=? AND revoked_at IS NULL', now(), row.id);
132
+ db.run('UPDATE api_tokens SET revoked_at=? WHERE id=? AND revoked_at IS NULL', now(), row.id);
125
133
  return true;
126
134
  }
127
135
  /** 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) {
136
+ export function authenticate(db, raw) {
129
137
  if (!raw)
130
138
  return null;
131
139
  const hash = hashToken(raw);
132
- const operator = await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
140
+ const operator = db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
133
141
  if (operator && hash === operator.value)
134
142
  return { name: OPERATOR_ACTOR, role: 'admin', projects: null };
135
- const row = await db.get('SELECT * FROM api_tokens WHERE token_hash=?', hash);
143
+ const row = db.get('SELECT * FROM api_tokens WHERE token_hash=?', hash);
136
144
  if (!row || row.revoked_at || (row.expires_at && row.expires_at <= now()))
137
145
  return null;
138
- // Best effort and throttled: a slow or failing bookkeeping write must never fail or delay the request.
146
+ // Best effort and throttled: bookkeeping failures must never fail authentication.
139
147
  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(() => { });
148
+ try {
149
+ db.run('UPDATE api_tokens SET last_used_at=? WHERE id=?', now(), row.id);
150
+ }
151
+ catch {
152
+ /* Best-effort bookkeeping. */
153
+ }
141
154
  return {
142
155
  name: row.name,
143
156
  role: row.role,
@@ -156,7 +169,7 @@ export function assertProject(actor, project) {
156
169
  export function createGuard(db) {
157
170
  return (required, resolve) => async (req, res, next) => {
158
171
  try {
159
- const actor = await authenticate(db, (req.headers.authorization || '').replace(/^Bearer /, ''));
172
+ const actor = authenticate(db, (req.headers.authorization || '').replace(/^Bearer /, ''));
160
173
  if (!actor) {
161
174
  res.status(401).json({ error: 'unauthorized' });
162
175
  return;
@@ -186,6 +199,6 @@ export function createGuard(db) {
186
199
  };
187
200
  }
188
201
  /** 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);
202
+ export function recordEvent(db, event) {
203
+ 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
204
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/tug",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Portable VPS deployments with Docker Compose",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,6 +29,7 @@
29
29
  "test:integration": "bash tools/scripts/integration-test.sh",
30
30
  "test:integration:network": "bash tools/scripts/integration-network-test.sh",
31
31
  "test:integration:agent": "node --test 'tests/integration/**/*.test.ts'",
32
+ "test:integration:backups": "node --test tests/integration/backup-job.test.ts tests/integration/run-job.test.ts",
32
33
  "test:smoke:dist": "npm run build && CLI_ENTRY=dist/cli.js node --test src/cli.templates.test.ts",
33
34
  "changeset": "changeset",
34
35
  "version-packages": "changeset version && npm install --package-lock-only --ignore-scripts",
@@ -43,8 +44,6 @@
43
44
  "express": "^4.21.2",
44
45
  "js-yaml": "^4.1.0",
45
46
  "multer": "^2.2.0",
46
- "sqlite": "^5.1.1",
47
- "sqlite3": "^5.1.7",
48
47
  "tar": "^7.4.3",
49
48
  "ws": "^8.22.1"
50
49
  },