@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/cron.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** A small UTC cron parser for `x-tug.backup.schedule`: five fields, lists, ranges, steps and `@` aliases. */
|
|
2
|
+
export interface CronSchedule {
|
|
3
|
+
minutes: ReadonlySet<number>;
|
|
4
|
+
hours: ReadonlySet<number>;
|
|
5
|
+
days: ReadonlySet<number>;
|
|
6
|
+
months: ReadonlySet<number>;
|
|
7
|
+
weekdays: ReadonlySet<number>;
|
|
8
|
+
/** True when the field was `*`; cron matches day-of-month OR day-of-week when neither is. */
|
|
9
|
+
anyDay: boolean;
|
|
10
|
+
anyWeekday: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Parse a five-field cron expression or an `@hourly`-style alias. Throws a readable `Error` on bad input. */
|
|
13
|
+
export declare function parseCron(expression: string): CronSchedule;
|
|
14
|
+
/** The first matching minute strictly after `after` (UTC), or `null` when the schedule never fires (such as Feb 31). */
|
|
15
|
+
export declare function nextCronRun(schedule: CronSchedule | string, after: Date): Date | null;
|
|
16
|
+
/** Approximate gap between runs in milliseconds, from the next two matches after `from`; used to size deadlines and staleness. */
|
|
17
|
+
export declare function cronPeriodMs(schedule: CronSchedule | string, from: Date): number | null;
|
package/dist/cron.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** A small UTC cron parser for `x-tug.backup.schedule`: five fields, lists, ranges, steps and `@` aliases. */
|
|
2
|
+
const ALIASES = {
|
|
3
|
+
'@hourly': '0 * * * *',
|
|
4
|
+
'@daily': '0 0 * * *',
|
|
5
|
+
'@midnight': '0 0 * * *',
|
|
6
|
+
'@weekly': '0 0 * * 0',
|
|
7
|
+
'@monthly': '0 0 1 * *',
|
|
8
|
+
'@yearly': '0 0 1 1 *',
|
|
9
|
+
'@annually': '0 0 1 1 *',
|
|
10
|
+
};
|
|
11
|
+
const FIELDS = [
|
|
12
|
+
{ name: 'minute', min: 0, max: 59 },
|
|
13
|
+
{ name: 'hour', min: 0, max: 23 },
|
|
14
|
+
{ name: 'day of month', min: 1, max: 31 },
|
|
15
|
+
{ name: 'month', min: 1, max: 12 },
|
|
16
|
+
{ name: 'day of week', min: 0, max: 7 },
|
|
17
|
+
];
|
|
18
|
+
function parseNumber(text, field) {
|
|
19
|
+
if (!/^\d+$/.test(text))
|
|
20
|
+
throw new Error(`${field.name} "${text}" is not a number`);
|
|
21
|
+
const value = Number(text);
|
|
22
|
+
if (value < field.min || value > field.max)
|
|
23
|
+
throw new Error(`${field.name} ${value} is outside ${field.min}-${field.max}`);
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
function parseField(text, field) {
|
|
27
|
+
const values = new Set();
|
|
28
|
+
for (const part of text.split(',')) {
|
|
29
|
+
if (!part)
|
|
30
|
+
throw new Error(`${field.name} has an empty list item`);
|
|
31
|
+
const [range, stepText, extra] = part.split('/');
|
|
32
|
+
if (extra !== undefined)
|
|
33
|
+
throw new Error(`${field.name} "${part}" has more than one step`);
|
|
34
|
+
let step = 1;
|
|
35
|
+
if (stepText !== undefined) {
|
|
36
|
+
step = parseNumber(stepText, { name: `${field.name} step`, min: 1, max: field.max });
|
|
37
|
+
}
|
|
38
|
+
let low;
|
|
39
|
+
let high;
|
|
40
|
+
if (range === '*') {
|
|
41
|
+
low = field.min;
|
|
42
|
+
high = field.name === 'day of week' ? 6 : field.max;
|
|
43
|
+
}
|
|
44
|
+
else if (range.includes('-')) {
|
|
45
|
+
const [from, to, more] = range.split('-');
|
|
46
|
+
if (more !== undefined)
|
|
47
|
+
throw new Error(`${field.name} range "${range}" is malformed`);
|
|
48
|
+
low = parseNumber(from, field);
|
|
49
|
+
high = parseNumber(to, field);
|
|
50
|
+
if (low > high)
|
|
51
|
+
throw new Error(`${field.name} range "${range}" runs backwards`);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
low = parseNumber(range, field);
|
|
55
|
+
high = stepText === undefined ? low : field.max;
|
|
56
|
+
}
|
|
57
|
+
for (let value = low; value <= high; value += step)
|
|
58
|
+
values.add(value);
|
|
59
|
+
}
|
|
60
|
+
return values;
|
|
61
|
+
}
|
|
62
|
+
/** Parse a five-field cron expression or an `@hourly`-style alias. Throws a readable `Error` on bad input. */
|
|
63
|
+
export function parseCron(expression) {
|
|
64
|
+
if (typeof expression !== 'string')
|
|
65
|
+
throw new Error('schedule must be a string');
|
|
66
|
+
const text = ALIASES[expression.trim().toLowerCase()] ?? expression.trim();
|
|
67
|
+
const parts = text.split(/\s+/);
|
|
68
|
+
if (parts.length !== 5)
|
|
69
|
+
throw new Error(`schedule "${expression}" must have five fields (minute hour day month weekday) or be one of ${Object.keys(ALIASES).join(', ')}`);
|
|
70
|
+
const [minutes, hours, days, months, weekdays] = parts.map((part, index) => parseField(part, FIELDS[index]));
|
|
71
|
+
// Both 0 and 7 mean Sunday.
|
|
72
|
+
if (weekdays.delete(7))
|
|
73
|
+
weekdays.add(0);
|
|
74
|
+
return {
|
|
75
|
+
minutes,
|
|
76
|
+
hours,
|
|
77
|
+
days,
|
|
78
|
+
months,
|
|
79
|
+
weekdays,
|
|
80
|
+
anyDay: parts[2].startsWith('*'),
|
|
81
|
+
anyWeekday: parts[4].startsWith('*'),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function dayMatches(schedule, date) {
|
|
85
|
+
const dayOk = schedule.days.has(date.getUTCDate());
|
|
86
|
+
const weekdayOk = schedule.weekdays.has(date.getUTCDay());
|
|
87
|
+
if (!schedule.anyDay && !schedule.anyWeekday)
|
|
88
|
+
return dayOk || weekdayOk;
|
|
89
|
+
return dayOk && weekdayOk;
|
|
90
|
+
}
|
|
91
|
+
/** Longest gap between two matches is a Feb 29 across a skipped century leap year: eight years. */
|
|
92
|
+
const SEARCH_YEARS = 9;
|
|
93
|
+
/** The first matching minute strictly after `after` (UTC), or `null` when the schedule never fires (such as Feb 31). */
|
|
94
|
+
export function nextCronRun(schedule, after) {
|
|
95
|
+
const parsed = typeof schedule === 'string' ? parseCron(schedule) : schedule;
|
|
96
|
+
const next = new Date(Math.floor(after.getTime() / 60_000) * 60_000 + 60_000);
|
|
97
|
+
const limit = next.getUTCFullYear() + SEARCH_YEARS;
|
|
98
|
+
while (next.getUTCFullYear() <= limit) {
|
|
99
|
+
if (!parsed.months.has(next.getUTCMonth() + 1)) {
|
|
100
|
+
next.setUTCMonth(next.getUTCMonth() + 1, 1);
|
|
101
|
+
next.setUTCHours(0, 0, 0, 0);
|
|
102
|
+
}
|
|
103
|
+
else if (!dayMatches(parsed, next)) {
|
|
104
|
+
next.setUTCDate(next.getUTCDate() + 1);
|
|
105
|
+
next.setUTCHours(0, 0, 0, 0);
|
|
106
|
+
}
|
|
107
|
+
else if (!parsed.hours.has(next.getUTCHours())) {
|
|
108
|
+
next.setUTCHours(next.getUTCHours() + 1, 0, 0, 0);
|
|
109
|
+
}
|
|
110
|
+
else if (!parsed.minutes.has(next.getUTCMinutes())) {
|
|
111
|
+
next.setUTCMinutes(next.getUTCMinutes() + 1, 0, 0);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
return next;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
/** Approximate gap between runs in milliseconds, from the next two matches after `from`; used to size deadlines and staleness. */
|
|
120
|
+
export function cronPeriodMs(schedule, from) {
|
|
121
|
+
const first = nextCronRun(schedule, from);
|
|
122
|
+
const second = first && nextCronRun(schedule, first);
|
|
123
|
+
return first && second ? second.getTime() - first.getTime() : null;
|
|
124
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The portable subset shared by synchronous SQLite runtimes. Only anonymous ? bindings are supported. */
|
|
2
|
+
export type SqlValue = string | number | bigint | null | Uint8Array;
|
|
3
|
+
export type SqlRow = Record<string, SqlValue>;
|
|
4
|
+
export interface Db {
|
|
5
|
+
get<T = SqlRow>(sql: string, ...values: SqlValue[]): T | undefined;
|
|
6
|
+
all<T = SqlRow>(sql: string, ...values: SqlValue[]): T[];
|
|
7
|
+
run(sql: string, ...values: SqlValue[]): {
|
|
8
|
+
changes: number;
|
|
9
|
+
};
|
|
10
|
+
exec(sql: string): void;
|
|
11
|
+
close(): void;
|
|
12
|
+
}
|
|
13
|
+
export type ConnectDb = (filename: string, options: {
|
|
14
|
+
readOnly: boolean;
|
|
15
|
+
}) => Db;
|
|
16
|
+
export declare class DbError extends Error {
|
|
17
|
+
readonly kind: 'unique' | 'busy';
|
|
18
|
+
constructor(kind: DbError['kind'], cause: Error);
|
|
19
|
+
}
|
|
20
|
+
export declare function isUniqueViolation(error: unknown): boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export class DbError extends Error {
|
|
2
|
+
kind;
|
|
3
|
+
constructor(kind, cause) {
|
|
4
|
+
super(cause.message, { cause });
|
|
5
|
+
this.name = 'DbError';
|
|
6
|
+
this.kind = kind;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function isUniqueViolation(error) {
|
|
10
|
+
return error instanceof DbError && error.kind === 'unique';
|
|
11
|
+
}
|
package/dist/db-node.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { DbError } from "./db-driver.js";
|
|
3
|
+
function sqliteCall(operation) {
|
|
4
|
+
try {
|
|
5
|
+
return operation();
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
if (error instanceof Error && 'errcode' in error) {
|
|
9
|
+
const code = Number(error.errcode);
|
|
10
|
+
if (code === 1555 || code === 2067)
|
|
11
|
+
throw new DbError('unique', error);
|
|
12
|
+
if ((code & 255) === 5 || (code & 255) === 6)
|
|
13
|
+
throw new DbError('busy', error);
|
|
14
|
+
}
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Count anonymous placeholders outside SQL literals, quoted identifiers and comments. */
|
|
19
|
+
function checkBindings(sql, values) {
|
|
20
|
+
const tokens = sql.match(/'(?:''|[^'])*'|"(?:""|[^"])*"|`(?:``|[^`])*`|\[[^\]]*\]|--[^\r\n]*|\/\*[\s\S]*?\*\/|\?\d*|[:@$][\w]+/g) ?? [];
|
|
21
|
+
const parameters = tokens.filter((token) => /^[?:@$]/.test(token));
|
|
22
|
+
if (parameters.some((token) => token !== '?'))
|
|
23
|
+
throw new TypeError('Only anonymous ? SQL parameters are supported');
|
|
24
|
+
if (parameters.length !== values.length)
|
|
25
|
+
throw new TypeError(`Expected ${parameters.length} SQL bindings, received ${values.length}`);
|
|
26
|
+
for (const value of values) {
|
|
27
|
+
if (value !== null &&
|
|
28
|
+
typeof value !== 'string' &&
|
|
29
|
+
typeof value !== 'bigint' &&
|
|
30
|
+
!(typeof value === 'number' && Number.isFinite(value)) &&
|
|
31
|
+
!(value instanceof Uint8Array))
|
|
32
|
+
throw new TypeError('SQL bindings must be strings, finite numbers, bigints, null or Uint8Array');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** This module is the only runtime-specific adapter; replace it at build time for a future Bun bundle. */
|
|
36
|
+
export const connectNodeDb = (filename, { readOnly }) => {
|
|
37
|
+
const database = sqliteCall(() => new DatabaseSync(filename, { readOnly }));
|
|
38
|
+
const prepare = (sql, values) => {
|
|
39
|
+
checkBindings(sql, values);
|
|
40
|
+
return database.prepare(sql);
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
get: (sql, ...values) => sqliteCall(() => {
|
|
44
|
+
const row = prepare(sql, values).get(...values);
|
|
45
|
+
return row === undefined ? undefined : { ...row };
|
|
46
|
+
}),
|
|
47
|
+
all: (sql, ...values) => sqliteCall(() => prepare(sql, values)
|
|
48
|
+
.all(...values)
|
|
49
|
+
.map((row) => ({ ...row }))),
|
|
50
|
+
run: (sql, ...values) => sqliteCall(() => {
|
|
51
|
+
const { changes } = prepare(sql, values).run(...values);
|
|
52
|
+
const count = Number(changes);
|
|
53
|
+
if (!Number.isSafeInteger(count))
|
|
54
|
+
throw new RangeError('SQL change count exceeds the safe integer range');
|
|
55
|
+
return { changes: count };
|
|
56
|
+
}),
|
|
57
|
+
exec: (sql) => sqliteCall(() => database.exec(sql)),
|
|
58
|
+
close: () => sqliteCall(() => database.close()),
|
|
59
|
+
};
|
|
60
|
+
};
|
package/dist/db.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
export
|
|
1
|
+
import type { ConnectDb, Db } from './db-driver.ts';
|
|
2
|
+
export type { Db } from './db-driver.ts';
|
|
3
|
+
export declare const openDb: (filename: string) => Promise<Db>, openAgentDb: (filename: string) => Promise<Db>, openSnapshotDb: (filename: string) => Db;
|
|
4
4
|
export interface ProjectRow {
|
|
5
5
|
id: string;
|
|
6
6
|
name: string;
|
|
@@ -19,7 +19,6 @@ export interface NodeRow {
|
|
|
19
19
|
status: string;
|
|
20
20
|
labels_json: string | null;
|
|
21
21
|
agent_token_hash: string | null;
|
|
22
|
-
registry_token_hash: string | null;
|
|
23
22
|
last_seen_at: string | null;
|
|
24
23
|
created_at: string;
|
|
25
24
|
updated_at: string;
|
|
@@ -66,6 +65,76 @@ export interface NodeStatusRow {
|
|
|
66
65
|
health: string;
|
|
67
66
|
updated_at: string;
|
|
68
67
|
}
|
|
68
|
+
export interface BackupScheduleRow {
|
|
69
|
+
id: string;
|
|
70
|
+
project_id: string;
|
|
71
|
+
target: string;
|
|
72
|
+
type: string;
|
|
73
|
+
node_id: string | null;
|
|
74
|
+
config_json: string;
|
|
75
|
+
schedule: string;
|
|
76
|
+
jitter_seconds: number;
|
|
77
|
+
next_run_at: string;
|
|
78
|
+
last_run_at: string | null;
|
|
79
|
+
last_success_at: string | null;
|
|
80
|
+
last_status: string | null;
|
|
81
|
+
stale_notified_at: string | null;
|
|
82
|
+
deployment_id: string | null;
|
|
83
|
+
enabled: number;
|
|
84
|
+
created_at: string | null;
|
|
85
|
+
updated_at: string | null;
|
|
86
|
+
}
|
|
87
|
+
export interface BackupRunRow {
|
|
88
|
+
id: string;
|
|
89
|
+
schedule_id: string | null;
|
|
90
|
+
project_id: string;
|
|
91
|
+
target: string;
|
|
92
|
+
type: string;
|
|
93
|
+
node_id: string | null;
|
|
94
|
+
command_id: string | null;
|
|
95
|
+
trigger: string;
|
|
96
|
+
state: string;
|
|
97
|
+
scheduled_for: string | null;
|
|
98
|
+
queued_at: string | null;
|
|
99
|
+
started_at: string | null;
|
|
100
|
+
finished_at: string | null;
|
|
101
|
+
snapshot_id: string | null;
|
|
102
|
+
size_bytes: number | null;
|
|
103
|
+
added_bytes: number | null;
|
|
104
|
+
duration_ms: number | null;
|
|
105
|
+
restic_version: string | null;
|
|
106
|
+
error: string | null;
|
|
107
|
+
actor: string | null;
|
|
108
|
+
deployment_version: string | null;
|
|
109
|
+
metadata_json: string | null;
|
|
110
|
+
}
|
|
111
|
+
export interface RestoreRunRow {
|
|
112
|
+
id: string;
|
|
113
|
+
project_id: string;
|
|
114
|
+
target: string;
|
|
115
|
+
node_id: string | null;
|
|
116
|
+
snapshot_id: string;
|
|
117
|
+
mode: 'in-place' | 'scratch';
|
|
118
|
+
scratch_volume: string | null;
|
|
119
|
+
safety_backup: number;
|
|
120
|
+
safety_run_id: string | null;
|
|
121
|
+
writers_json: string;
|
|
122
|
+
state: string;
|
|
123
|
+
command_id: string | null;
|
|
124
|
+
actor: string | null;
|
|
125
|
+
started_at: string | null;
|
|
126
|
+
state_changed_at: string | null;
|
|
127
|
+
finished_at: string | null;
|
|
128
|
+
error: string | null;
|
|
129
|
+
}
|
|
130
|
+
export interface BackupDestinationRow {
|
|
131
|
+
id: 'default';
|
|
132
|
+
url: string;
|
|
133
|
+
env_names_json: string;
|
|
134
|
+
checked_at: string | null;
|
|
135
|
+
check_result_json: string | null;
|
|
136
|
+
updated_at: string;
|
|
137
|
+
}
|
|
69
138
|
export interface SecretRow {
|
|
70
139
|
id: string;
|
|
71
140
|
project_id: string;
|
|
@@ -120,5 +189,9 @@ export interface AppliedStateRow {
|
|
|
120
189
|
service_states_json: string;
|
|
121
190
|
secret_versions_json: string;
|
|
122
191
|
}
|
|
123
|
-
|
|
124
|
-
export declare function
|
|
192
|
+
/** Shared policy for every driver, including read-only restore validation. */
|
|
193
|
+
export declare function databaseAccess(connect: ConnectDb): {
|
|
194
|
+
openDb: (filename: string) => Promise<Db>;
|
|
195
|
+
openAgentDb: (filename: string) => Promise<Db>;
|
|
196
|
+
openSnapshotDb: (filename: string) => Db;
|
|
197
|
+
};
|
package/dist/db.js
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { open } from 'sqlite';
|
|
1
|
+
import { connectNodeDb } from "./db-node.js";
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { ensureDir, now } from "./util.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
// One composition point for all writable and snapshot connections.
|
|
5
|
+
export const { openDb, openAgentDb, openSnapshotDb } = databaseAccess(connectNodeDb);
|
|
6
|
+
function configure(db, writable, initialize) {
|
|
7
|
+
try {
|
|
8
|
+
db.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 1000;');
|
|
9
|
+
if (writable)
|
|
10
|
+
db.exec('PRAGMA journal_mode = WAL;');
|
|
11
|
+
initialize?.(db);
|
|
12
|
+
return db;
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
try {
|
|
16
|
+
db.close();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* Preserve the initialization failure. */
|
|
20
|
+
}
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
11
23
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
24
|
+
/** Shared policy for every driver, including read-only restore validation. */
|
|
25
|
+
export function databaseAccess(connect) {
|
|
26
|
+
function openSnapshotDb(filename) {
|
|
27
|
+
return configure(connect(filename, { readOnly: true }), false);
|
|
28
|
+
}
|
|
29
|
+
async function openDb(filename) {
|
|
30
|
+
await ensureDir(path.dirname(filename));
|
|
31
|
+
return configure(connect(filename, { readOnly: false }), true, initializeControl);
|
|
32
|
+
}
|
|
33
|
+
async function openAgentDb(filename) {
|
|
34
|
+
await ensureDir(path.dirname(filename));
|
|
35
|
+
return configure(connect(filename, { readOnly: false }), true, (db) => db.exec(`
|
|
17
36
|
CREATE TABLE IF NOT EXISTS applied_state(
|
|
18
37
|
project TEXT PRIMARY KEY,
|
|
19
38
|
version TEXT,
|
|
@@ -27,14 +46,15 @@ CREATE TABLE IF NOT EXISTS event_state(
|
|
|
27
46
|
fingerprint TEXT NOT NULL,
|
|
28
47
|
phase TEXT NOT NULL
|
|
29
48
|
);
|
|
30
|
-
`);
|
|
31
|
-
|
|
49
|
+
`));
|
|
50
|
+
}
|
|
51
|
+
return { openDb, openAgentDb, openSnapshotDb };
|
|
32
52
|
}
|
|
33
|
-
|
|
34
|
-
|
|
53
|
+
function initializeControl(db) {
|
|
54
|
+
db.exec(`
|
|
35
55
|
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
36
56
|
CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, current_deployment_id TEXT, created_at TEXT, updated_at TEXT);
|
|
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, labels_json TEXT, agent_token_hash TEXT,
|
|
57
|
+
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, labels_json TEXT, agent_token_hash TEXT, last_seen_at TEXT, created_at TEXT, updated_at TEXT);
|
|
38
58
|
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);
|
|
39
59
|
CREATE TABLE IF NOT EXISTS enroll_tokens(token_hash TEXT PRIMARY KEY, name TEXT, uses_remaining INTEGER, expires_at TEXT, created_at TEXT);
|
|
40
60
|
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);
|
|
@@ -50,14 +70,15 @@ CREATE INDEX IF NOT EXISTS events_project_created ON events(project_id, created_
|
|
|
50
70
|
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
71
|
CREATE INDEX IF NOT EXISTS events_by_time ON events(created_at);
|
|
52
72
|
CREATE TABLE IF NOT EXISTS locks(name TEXT PRIMARY KEY, owner TEXT, expires_at TEXT, created_at TEXT);
|
|
73
|
+
CREATE TABLE IF NOT EXISTS backup_destination(id TEXT PRIMARY KEY CHECK(id='default'), url TEXT NOT NULL, env_names_json TEXT NOT NULL, checked_at TEXT, check_result_json TEXT, updated_at TEXT);
|
|
74
|
+
CREATE TABLE IF NOT EXISTS backup_schedules(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, target TEXT NOT NULL, type TEXT NOT NULL, node_id TEXT, config_json TEXT NOT NULL, schedule TEXT NOT NULL, jitter_seconds INTEGER NOT NULL, next_run_at TEXT NOT NULL, last_run_at TEXT, last_success_at TEXT, last_status TEXT, stale_notified_at TEXT, deployment_id TEXT, enabled INTEGER NOT NULL DEFAULT 1, created_at TEXT, updated_at TEXT, UNIQUE(project_id, target));
|
|
75
|
+
CREATE TABLE IF NOT EXISTS backup_runs(id TEXT PRIMARY KEY, schedule_id TEXT, project_id TEXT NOT NULL, target TEXT NOT NULL, type TEXT NOT NULL, node_id TEXT, command_id TEXT, trigger TEXT NOT NULL, state TEXT NOT NULL, scheduled_for TEXT, queued_at TEXT, started_at TEXT, finished_at TEXT, snapshot_id TEXT, size_bytes INTEGER, added_bytes INTEGER, duration_ms INTEGER, restic_version TEXT, error TEXT, actor TEXT, deployment_version TEXT, metadata_json TEXT);
|
|
76
|
+
CREATE TABLE IF NOT EXISTS restore_runs(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, target TEXT NOT NULL, node_id TEXT, snapshot_id TEXT NOT NULL, mode TEXT NOT NULL, scratch_volume TEXT, safety_backup INTEGER NOT NULL DEFAULT 1, safety_run_id TEXT, writers_json TEXT NOT NULL DEFAULT '[]', state TEXT NOT NULL, command_id TEXT, actor TEXT, started_at TEXT, state_changed_at TEXT, finished_at TEXT, error TEXT);
|
|
77
|
+
CREATE TABLE IF NOT EXISTS service_holds(project_id TEXT NOT NULL, service TEXT NOT NULL, reason TEXT NOT NULL, restore_id TEXT, created_at TEXT, PRIMARY KEY(project_id, service));
|
|
78
|
+
CREATE UNIQUE INDEX IF NOT EXISTS backup_runs_slot ON backup_runs(schedule_id, scheduled_for) WHERE trigger='schedule';
|
|
79
|
+
CREATE INDEX IF NOT EXISTS backup_runs_target ON backup_runs(project_id, target, finished_at);
|
|
53
80
|
`);
|
|
54
|
-
|
|
55
|
-
await db.exec('ALTER TABLE nodes ADD COLUMN labels_json TEXT');
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
/* column already exists */
|
|
59
|
-
}
|
|
60
|
-
const row = await db.get('SELECT value FROM meta WHERE key = ?', 'schema_created_at');
|
|
81
|
+
const row = db.get('SELECT value FROM meta WHERE key = ?', 'schema_created_at');
|
|
61
82
|
if (!row)
|
|
62
|
-
|
|
83
|
+
db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'schema_created_at', now());
|
|
63
84
|
}
|
package/dist/docker-api.d.ts
CHANGED
|
@@ -33,6 +33,13 @@ export interface ContainerInspect {
|
|
|
33
33
|
Image?: string;
|
|
34
34
|
Labels?: Record<string, string>;
|
|
35
35
|
Env?: string[];
|
|
36
|
+
User?: string;
|
|
37
|
+
WorkingDir?: string;
|
|
38
|
+
};
|
|
39
|
+
HostConfig?: {
|
|
40
|
+
NetworkMode?: string;
|
|
41
|
+
Binds?: string[];
|
|
42
|
+
Mounts?: unknown[];
|
|
36
43
|
};
|
|
37
44
|
State?: {
|
|
38
45
|
Status?: string;
|
package/dist/docker-api.js
CHANGED
|
@@ -19,8 +19,14 @@ export const isNotFound = (error) => error instanceof DockerError && error.statu
|
|
|
19
19
|
/** Splits a reference into `fromImage` and `tag` for `POST /images/create`. */
|
|
20
20
|
export function splitImageRef(ref) {
|
|
21
21
|
const digest = ref.indexOf('@');
|
|
22
|
-
if (digest >= 0)
|
|
23
|
-
|
|
22
|
+
if (digest >= 0) {
|
|
23
|
+
const name = ref.slice(0, digest);
|
|
24
|
+
const colon = name.lastIndexOf(':');
|
|
25
|
+
return {
|
|
26
|
+
fromImage: colon > name.lastIndexOf('/') ? name.slice(0, colon) : name,
|
|
27
|
+
tag: ref.slice(digest + 1),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
24
30
|
const colon = ref.lastIndexOf(':');
|
|
25
31
|
if (colon > ref.lastIndexOf('/'))
|
|
26
32
|
return { fromImage: ref.slice(0, colon), tag: ref.slice(colon + 1) };
|
|
@@ -396,8 +402,14 @@ export class DockerEngine {
|
|
|
396
402
|
const { fromImage, tag } = splitImageRef(ref);
|
|
397
403
|
const res = await this.request('POST', '/images/create', {
|
|
398
404
|
query: { fromImage, tag },
|
|
405
|
+
// Docker uses Go's padded base64.URLEncoding; Node's base64url encoding omits required '='.
|
|
399
406
|
headers: auth
|
|
400
|
-
? {
|
|
407
|
+
? {
|
|
408
|
+
'x-registry-auth': Buffer.from(JSON.stringify(auth))
|
|
409
|
+
.toString('base64')
|
|
410
|
+
.replaceAll('+', '-')
|
|
411
|
+
.replaceAll('/', '_'),
|
|
412
|
+
}
|
|
401
413
|
: {},
|
|
402
414
|
});
|
|
403
415
|
await this.progress(res, `pull ${ref}`);
|
package/dist/live.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type LiveRequest = {
|
|
|
18
18
|
tty?: boolean;
|
|
19
19
|
columns?: number;
|
|
20
20
|
rows?: number;
|
|
21
|
+
/** `container` is only the template: run the command in a new container with its image, environment and mounts. */
|
|
22
|
+
oneOff?: boolean;
|
|
21
23
|
} | {
|
|
22
24
|
operation: 'logs';
|
|
23
25
|
sessionId: string;
|
|
@@ -81,3 +83,9 @@ export declare class LiveOperationEngine {
|
|
|
81
83
|
private output;
|
|
82
84
|
private finish;
|
|
83
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Runs `command` in a new container cloned from `template`: the same image, environment (secrets included),
|
|
88
|
+
* user, working directory, network and mounts, with no published ports and none of the service's labels, so
|
|
89
|
+
* reconciliation never mistakes it for the service. The container is removed when the command ends.
|
|
90
|
+
*/
|
|
91
|
+
export declare function startOneOff(docker: DockerApi, template: string, command: string[]): Promise<LiveProcess>;
|
package/dist/live.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import { LIVE_MAX_FRAME_BYTES } from "./protocol.js";
|
|
3
|
+
import { LABEL_JOB } from "./container-spec.js";
|
|
3
4
|
const identifier = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,255}$/;
|
|
4
5
|
function object(value) {
|
|
5
6
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -22,6 +23,8 @@ function validate(value) {
|
|
|
22
23
|
!value.command.every((arg) => typeof arg === 'string' && !arg.includes('\0') && Buffer.byteLength(arg) <= 32768) ||
|
|
23
24
|
value.command[0].length === 0)
|
|
24
25
|
throw new Error('Invalid exec command');
|
|
26
|
+
if (value.oneOff !== undefined && typeof value.oneOff !== 'boolean')
|
|
27
|
+
throw new Error('Invalid one-off flag');
|
|
25
28
|
if (value.interactive !== undefined && typeof value.interactive !== 'boolean')
|
|
26
29
|
throw new Error('Invalid interactive flag');
|
|
27
30
|
if (value.tty !== undefined && typeof value.tty !== 'boolean')
|
|
@@ -187,6 +190,8 @@ export class LiveOperationEngine {
|
|
|
187
190
|
tail: request.tail ?? 100,
|
|
188
191
|
timestamps: request.timestamps,
|
|
189
192
|
});
|
|
193
|
+
if (request.operation === 'exec' && request.oneOff)
|
|
194
|
+
return startOneOff(docker, request.container, request.command);
|
|
190
195
|
const proc = await docker.execStart(request.container, {
|
|
191
196
|
cmd: request.command,
|
|
192
197
|
stdin: request.interactive !== false,
|
|
@@ -274,3 +279,82 @@ export class LiveOperationEngine {
|
|
|
274
279
|
});
|
|
275
280
|
}
|
|
276
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Runs `command` in a new container cloned from `template`: the same image, environment (secrets included),
|
|
284
|
+
* user, working directory, network and mounts, with no published ports and none of the service's labels, so
|
|
285
|
+
* reconciliation never mistakes it for the service. The container is removed when the command ends.
|
|
286
|
+
*/
|
|
287
|
+
export async function startOneOff(docker, template, command) {
|
|
288
|
+
const source = await docker.inspectContainer(template);
|
|
289
|
+
const image = source.Config?.Image;
|
|
290
|
+
if (!image)
|
|
291
|
+
throw new Error('the service container has no image to run from');
|
|
292
|
+
const host = source.HostConfig ?? {};
|
|
293
|
+
const name = `tug-run-${Math.random().toString(36).slice(2, 10)}`;
|
|
294
|
+
const id = await docker.createContainer(name, {
|
|
295
|
+
Image: image,
|
|
296
|
+
Env: source.Config?.Env,
|
|
297
|
+
User: source.Config?.User || undefined,
|
|
298
|
+
WorkingDir: source.Config?.WorkingDir || undefined,
|
|
299
|
+
// Bypass the image's entrypoint script, like `docker exec`, so the command is exactly what was typed.
|
|
300
|
+
Entrypoint: command,
|
|
301
|
+
Cmd: [],
|
|
302
|
+
Labels: { [LABEL_JOB]: 'run' },
|
|
303
|
+
HostConfig: {
|
|
304
|
+
NetworkMode: host.NetworkMode,
|
|
305
|
+
...(host.Binds?.length ? { Binds: host.Binds } : {}),
|
|
306
|
+
...(host.Mounts?.length ? { Mounts: host.Mounts } : {}),
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
const remove = () => docker.removeContainer(id).catch(() => { });
|
|
310
|
+
try {
|
|
311
|
+
await docker.startContainer(id);
|
|
312
|
+
const logs = await docker.logs(id, { follow: true });
|
|
313
|
+
let killed = false;
|
|
314
|
+
const proc = Object.assign(new EventEmitter(), {
|
|
315
|
+
stdin: null,
|
|
316
|
+
stdout: logs.stdout,
|
|
317
|
+
stderr: logs.stderr,
|
|
318
|
+
kill: () => {
|
|
319
|
+
killed = true;
|
|
320
|
+
logs.kill();
|
|
321
|
+
void remove();
|
|
322
|
+
return true;
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
const drained = (stream) => new Promise((resolve) => {
|
|
326
|
+
if (stream.readableEnded || stream.destroyed)
|
|
327
|
+
return resolve();
|
|
328
|
+
stream.once('end', resolve);
|
|
329
|
+
stream.once('close', resolve);
|
|
330
|
+
});
|
|
331
|
+
// The exit code comes from the container, not from the log stream, which only ends once it is consumed.
|
|
332
|
+
void (async () => {
|
|
333
|
+
let code = -1;
|
|
334
|
+
while (!killed) {
|
|
335
|
+
const state = (await docker.inspectContainer(id).catch(() => undefined))?.State;
|
|
336
|
+
if (!state)
|
|
337
|
+
break;
|
|
338
|
+
if (!state.Running && state.Status !== 'created') {
|
|
339
|
+
code = state.ExitCode ?? -1;
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
343
|
+
}
|
|
344
|
+
if (killed)
|
|
345
|
+
return;
|
|
346
|
+
// Give the consumer a moment to read the last output before the container goes away.
|
|
347
|
+
await Promise.race([
|
|
348
|
+
Promise.all([drained(logs.stdout), drained(logs.stderr)]),
|
|
349
|
+
new Promise((resolve) => setTimeout(resolve, 1500)),
|
|
350
|
+
]);
|
|
351
|
+
await remove();
|
|
352
|
+
proc.emit('close', code, null);
|
|
353
|
+
})();
|
|
354
|
+
return proc;
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
await remove();
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}
|