@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.
- package/README.md +10 -11
- package/dist/agent-jobs.d.ts +41 -0
- package/dist/agent-jobs.js +161 -0
- package/dist/agent.d.ts +1 -3
- package/dist/agent.js +58 -35
- package/dist/backup-config.d.ts +47 -0
- package/dist/backup-config.js +256 -0
- package/dist/backup-destination.d.ts +22 -0
- package/dist/backup-destination.js +72 -0
- package/dist/backup-job.d.ts +32 -0
- package/dist/backup-job.js +352 -0
- package/dist/backup-restore.d.ts +67 -0
- package/dist/backup-restore.js +297 -0
- package/dist/backup-scheduler.d.ts +54 -0
- package/dist/backup-scheduler.js +403 -0
- package/dist/build.d.ts +2 -3
- package/dist/build.js +1 -3
- package/dist/cli-build.d.ts +3 -3
- package/dist/cli-build.js +18 -6
- package/dist/cli-config.d.ts +45 -5
- package/dist/cli-config.js +269 -19
- package/dist/cli-live-transport.d.ts +1 -1
- package/dist/cli-live-transport.js +2 -2
- package/dist/cli-live.d.ts +2 -0
- package/dist/cli-live.js +1 -0
- package/dist/cli.js +217 -49
- package/dist/compose.d.ts +1 -2
- package/dist/compose.js +7 -6
- package/dist/container-spec.d.ts +3 -2
- package/dist/container-spec.js +9 -34
- package/dist/control.d.ts +4 -2
- package/dist/control.js +591 -381
- package/dist/cron.d.ts +17 -0
- package/dist/cron.js +124 -0
- package/dist/db-driver.d.ts +20 -0
- package/dist/db-driver.js +11 -0
- package/dist/db-node.d.ts +3 -0
- package/dist/db-node.js +60 -0
- package/dist/db.d.ts +79 -6
- package/dist/db.js +47 -26
- package/dist/docker-api.d.ts +7 -0
- package/dist/docker-api.js +15 -3
- package/dist/live.d.ts +8 -0
- package/dist/live.js +84 -0
- package/dist/protocol.d.ts +121 -12
- package/dist/protocol.js +7 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +133 -0
- package/dist/templates/control-compose.yml +0 -4
- package/dist/templates/node-install.sh +0 -1
- package/dist/tokens.d.ts +7 -7
- package/dist/tokens.js +34 -21
- package/package.json +2 -3
package/dist/protocol.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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>;
|
package/dist/registry.js
ADDED
|
@@ -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
|
|
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):
|
|
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):
|
|
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):
|
|
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):
|
|
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
|
-
}):
|
|
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
|
|
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
|
|
109
|
+
export function createToken(db, request, createdBy) {
|
|
102
110
|
const value = token(TOKEN_PREFIX);
|
|
103
111
|
try {
|
|
104
|
-
|
|
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 (
|
|
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(
|
|
120
|
+
...info(db.get('SELECT * FROM api_tokens WHERE name=?', request.name)),
|
|
113
121
|
token: value,
|
|
114
122
|
};
|
|
115
123
|
}
|
|
116
|
-
export
|
|
117
|
-
return
|
|
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
|
|
121
|
-
const row =
|
|
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
|
-
|
|
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
|
|
136
|
+
export function authenticate(db, raw) {
|
|
129
137
|
if (!raw)
|
|
130
138
|
return null;
|
|
131
139
|
const hash = hashToken(raw);
|
|
132
|
-
const operator =
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
|
190
|
-
|
|
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.
|
|
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
|
},
|