@wpelevator/tug 0.5.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 +3 -3
- package/dist/agent-jobs.d.ts +41 -0
- package/dist/agent-jobs.js +161 -0
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +50 -5
- 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/cli-build.d.ts +1 -1
- package/dist/cli-build.js +15 -2
- 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 +215 -16
- package/dist/compose.js +5 -2
- package/dist/container-spec.d.ts +3 -2
- package/dist/container-spec.js +9 -34
- package/dist/control.js +253 -19
- package/dist/cron.d.ts +17 -0
- package/dist/cron.js +124 -0
- package/dist/db.d.ts +70 -0
- package/dist/db.js +7 -0
- package/dist/docker-api.d.ts +7 -0
- package/dist/live.d.ts +8 -0
- package/dist/live.js +84 -0
- package/dist/protocol.d.ts +116 -7
- package/dist/protocol.js +7 -0
- package/dist/tokens.js +10 -0
- package/package.json +2 -1
package/dist/control.js
CHANGED
|
@@ -21,7 +21,10 @@ import { sentinelNames } from "./normalize.js";
|
|
|
21
21
|
import { ensureDir, errMessage, hashToken, id, now, token } from "./util.js";
|
|
22
22
|
import { DEFAULT_MESH_CIDR, DEFAULT_WG_PORT, WG_INTERFACE, WG_KEEPALIVE, allocateOverlayIp, isWgKey, parseCidr, wgEndpoint, } from "./mesh.js";
|
|
23
23
|
import { buildIngressWork, resolveServiceEndpoints } from "./ingress.js";
|
|
24
|
-
import { ACTIVE_DEPLOYMENT_STATES, LIVE_MAX_FRAME_BYTES, LIVE_MAX_BUFFERED_BYTES, LIVE_MAX_SESSIONS, LIVE_START_TIMEOUT_MS, } from "./protocol.js";
|
|
24
|
+
import { ACTIVE_DEPLOYMENT_STATES, LIVE_MAX_FRAME_BYTES, LIVE_MAX_BUFFERED_BYTES, LIVE_MAX_SESSIONS, LIVE_START_TIMEOUT_MS, BACKUP_COMMAND_TYPES, } from "./protocol.js";
|
|
25
|
+
import { BACKUP_SECRET_PROJECT, isReservedProject, parseBackupDestination, projectRepository, repositoryPassword, redactSecrets, } from "./backup-destination.js";
|
|
26
|
+
import { createBackupService } from "./backup-scheduler.js";
|
|
27
|
+
import { createRestoreService } from "./backup-restore.js";
|
|
25
28
|
import { AUTO_ROLLBACK_ACTOR, ROUTE_ROLES, PROJECT_NAME_PATTERN, authenticate, hasRole, assertProject, bodyProject, createGuard, createToken, listTokens, parseTokenRequest, queryProject, recordEvent, revokeToken, } from "./tokens.js";
|
|
26
29
|
class WakeHub {
|
|
27
30
|
cursors = new Map();
|
|
@@ -119,7 +122,18 @@ function parseLiveRequest(value) {
|
|
|
119
122
|
Number(value.tail) <= 10_000)
|
|
120
123
|
return value;
|
|
121
124
|
if (value.operation === 'exec' &&
|
|
122
|
-
frameKeys(value, [
|
|
125
|
+
frameKeys(value, [
|
|
126
|
+
...common,
|
|
127
|
+
'service',
|
|
128
|
+
'command',
|
|
129
|
+
'interactive',
|
|
130
|
+
'tty',
|
|
131
|
+
'columns',
|
|
132
|
+
'rows',
|
|
133
|
+
'oneOff',
|
|
134
|
+
]) &&
|
|
135
|
+
(value.oneOff === undefined || value.oneOff === true) &&
|
|
136
|
+
(value.oneOff !== true || (value.interactive === false && value.tty === false)) &&
|
|
123
137
|
Array.isArray(value.command) &&
|
|
124
138
|
value.command.length > 0 &&
|
|
125
139
|
value.command.length <= 256 &&
|
|
@@ -705,6 +719,17 @@ export async function createApp(options = {}) {
|
|
|
705
719
|
const node = nodes.find((node) => node.name === (request.node || [...eligible][0]));
|
|
706
720
|
if (!node)
|
|
707
721
|
throw httpError(404, 'no eligible node for this operation');
|
|
722
|
+
if (request.operation === 'exec' && request.oneOff)
|
|
723
|
+
audit(actor, {
|
|
724
|
+
type: 'job.run',
|
|
725
|
+
project: request.project,
|
|
726
|
+
message: `${actor.name} ran a one-off ${request.service} container on ${node.name}: ${request.command.join(' ').slice(0, 200)}`,
|
|
727
|
+
metadata: {
|
|
728
|
+
service: request.service,
|
|
729
|
+
node: node.name,
|
|
730
|
+
command: request.command.slice(0, 64),
|
|
731
|
+
},
|
|
732
|
+
});
|
|
708
733
|
return node;
|
|
709
734
|
}, options.liveStartTimeoutMs);
|
|
710
735
|
streamRuntimes.set(app, {
|
|
@@ -958,10 +983,10 @@ export async function createApp(options = {}) {
|
|
|
958
983
|
function findNode(nameOrId) {
|
|
959
984
|
return db.get('SELECT id,name FROM nodes WHERE name=? OR id=?', nameOrId, nameOrId);
|
|
960
985
|
}
|
|
961
|
-
function queueCommand(nodeId, projectId, type, payload = {}) {
|
|
986
|
+
function queueCommand(nodeId, projectId, type, payload = {}, deadline = null) {
|
|
962
987
|
const commandId = id('cmd');
|
|
963
988
|
const ts = now();
|
|
964
|
-
db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', commandId, nodeId, projectId, type, 'pending', JSON.stringify(payload), 0, ts,
|
|
989
|
+
db.run('INSERT INTO commands(id,node_id,project_id,type,state,payload_json,attempts,run_after,deadline,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', commandId, nodeId, projectId, type, 'pending', JSON.stringify(payload), 0, ts, deadline, ts, ts);
|
|
965
990
|
wakeHub.wake(nodeId, projectId ?? undefined);
|
|
966
991
|
return commandId;
|
|
967
992
|
}
|
|
@@ -1060,8 +1085,188 @@ export async function createApp(options = {}) {
|
|
|
1060
1085
|
});
|
|
1061
1086
|
res.json({ ok: true, name: req.params.name });
|
|
1062
1087
|
});
|
|
1088
|
+
const backupDestination = () => db.get("SELECT * FROM backup_destination WHERE id='default'");
|
|
1089
|
+
const backupEnv = () => {
|
|
1090
|
+
const row = backupDestination();
|
|
1091
|
+
if (!row)
|
|
1092
|
+
return {};
|
|
1093
|
+
const env = {};
|
|
1094
|
+
for (const name of JSON.parse(row.env_names_json)) {
|
|
1095
|
+
const secret = db.get('SELECT encrypted_value FROM secrets WHERE project_id=? AND name=?', BACKUP_SECRET_PROJECT, name);
|
|
1096
|
+
if (secret)
|
|
1097
|
+
env[name] = decrypt(secret.encrypted_value, masterKey);
|
|
1098
|
+
}
|
|
1099
|
+
return env;
|
|
1100
|
+
};
|
|
1101
|
+
const backups = createBackupService({
|
|
1102
|
+
db,
|
|
1103
|
+
queueCommand,
|
|
1104
|
+
resticImage: process.env.TUG_RESTIC_IMAGE,
|
|
1105
|
+
// Defensive: the agent already redacts, but a stored error must never carry a destination credential.
|
|
1106
|
+
redact: (text) => redactSecrets(text, Object.values(backupEnv())),
|
|
1107
|
+
});
|
|
1108
|
+
app.locals.backups = backups;
|
|
1109
|
+
const restores = createRestoreService({ db, backups, wake: (project) => wakeAll(project) });
|
|
1110
|
+
app.locals.restores = restores;
|
|
1111
|
+
const backupTickMs = Number(process.env.TUG_BACKUP_TICK_MS) || 30_000;
|
|
1112
|
+
const backupTimer = setInterval(() => {
|
|
1113
|
+
try {
|
|
1114
|
+
backups.tick();
|
|
1115
|
+
restores.advance();
|
|
1116
|
+
}
|
|
1117
|
+
catch (e) {
|
|
1118
|
+
console.error(`backup scheduler failed: ${errMessage(e)}`);
|
|
1119
|
+
}
|
|
1120
|
+
}, backupTickMs);
|
|
1121
|
+
backupTimer.unref();
|
|
1122
|
+
try {
|
|
1123
|
+
backups.tick(); // catch up on slots missed while control was down
|
|
1124
|
+
restores.advance(); // re-adopt unfinished restores and keep their projects locked
|
|
1125
|
+
}
|
|
1126
|
+
catch (e) {
|
|
1127
|
+
console.error(`backup scheduler failed: ${errMessage(e)}`);
|
|
1128
|
+
}
|
|
1129
|
+
api('post', '/api/v1/backups/run', bodyProject, async (req, res) => {
|
|
1130
|
+
const project = String(req.body.project || 'site');
|
|
1131
|
+
const targets = req.body.targets;
|
|
1132
|
+
if (targets !== undefined &&
|
|
1133
|
+
(!Array.isArray(targets) || targets.some((t) => typeof t !== 'string')))
|
|
1134
|
+
return res.status(400).json({ error: 'targets must be a list of service or volume names' });
|
|
1135
|
+
if (!db.get('SELECT 1 FROM projects WHERE id=?', project))
|
|
1136
|
+
return res.status(404).json({ error: `project ${project} not found` });
|
|
1137
|
+
const result = backups.runNow(project, targets, req.actor.name);
|
|
1138
|
+
if (!result.runs.length)
|
|
1139
|
+
return res.status(result.errors.length ? 409 : 404).json({
|
|
1140
|
+
error: result.errors.join('; ') || `project ${project} has no backup targets`,
|
|
1141
|
+
});
|
|
1142
|
+
res.json(result);
|
|
1143
|
+
});
|
|
1144
|
+
api('post', '/api/v1/restores', bodyProject, async (req, res) => {
|
|
1145
|
+
const body = req.body ?? {};
|
|
1146
|
+
const project = String(body.project || 'site');
|
|
1147
|
+
if (typeof body.target !== 'string' || !body.target)
|
|
1148
|
+
return res
|
|
1149
|
+
.status(400)
|
|
1150
|
+
.json({ error: 'target is required, such as service:db or volume:uploads' });
|
|
1151
|
+
const target = body.target.includes(':')
|
|
1152
|
+
? body.target
|
|
1153
|
+
: (db.get('SELECT target FROM backup_schedules WHERE project_id=? AND enabled=1 AND target LIKE ?', project, `%:${body.target}`)?.target ?? body.target);
|
|
1154
|
+
const request = {
|
|
1155
|
+
project,
|
|
1156
|
+
target,
|
|
1157
|
+
from: typeof body.from === 'string' ? body.from : undefined,
|
|
1158
|
+
scratch: body.scratch === true,
|
|
1159
|
+
safetyBackup: body.safetyBackup !== false,
|
|
1160
|
+
writers: Array.isArray(body.writers) ? body.writers.map(String) : undefined,
|
|
1161
|
+
noWriters: body.noWriters === true,
|
|
1162
|
+
actor: req.actor.name,
|
|
1163
|
+
};
|
|
1164
|
+
const plan = restores.plan(request);
|
|
1165
|
+
if ('error' in plan)
|
|
1166
|
+
return res.status(plan.status).json({ error: plan.error });
|
|
1167
|
+
if (body.confirm !== true)
|
|
1168
|
+
return res.json({ confirmed: false, plan });
|
|
1169
|
+
const started = restores.start(request, plan);
|
|
1170
|
+
if ('error' in started)
|
|
1171
|
+
return res.status(started.status).json({ error: started.error });
|
|
1172
|
+
res.json({ confirmed: true, plan, ...started });
|
|
1173
|
+
});
|
|
1174
|
+
api('get', '/api/v1/restores', queryProject, async (req, res) => res.json({ restores: restores.list(String(req.query.project || 'site')) }));
|
|
1175
|
+
api('post', '/api/v1/restores/:id/release', null, async (req, res) => {
|
|
1176
|
+
const result = restores.releaseInterrupted(req.params.id);
|
|
1177
|
+
if ('error' in result)
|
|
1178
|
+
return res.status(result.status).json({ error: result.error });
|
|
1179
|
+
res.json(result);
|
|
1180
|
+
});
|
|
1181
|
+
api('post', '/api/v1/restores/:id/resume', null, async (req, res) => {
|
|
1182
|
+
const result = restores.resume(req.params.id);
|
|
1183
|
+
if ('error' in result)
|
|
1184
|
+
return res.status(result.status).json({ error: result.error });
|
|
1185
|
+
res.json(result);
|
|
1186
|
+
});
|
|
1187
|
+
api('get', '/api/v1/backups', queryProject, async (req, res) => {
|
|
1188
|
+
const project = String(req.query.project || 'site');
|
|
1189
|
+
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
|
|
1190
|
+
res.json({
|
|
1191
|
+
project,
|
|
1192
|
+
destinationConfigured: Boolean(backupDestination()),
|
|
1193
|
+
...backups.list(project, limit),
|
|
1194
|
+
});
|
|
1195
|
+
});
|
|
1196
|
+
api('get', '/api/v1/backup/destination', null, async (_req, res) => {
|
|
1197
|
+
const row = backupDestination();
|
|
1198
|
+
res.json(row
|
|
1199
|
+
? {
|
|
1200
|
+
configured: true,
|
|
1201
|
+
url: row.url,
|
|
1202
|
+
envNames: JSON.parse(row.env_names_json),
|
|
1203
|
+
checkedAt: row.checked_at,
|
|
1204
|
+
checkResult: row.check_result_json ? JSON.parse(row.check_result_json) : null,
|
|
1205
|
+
updatedAt: row.updated_at,
|
|
1206
|
+
}
|
|
1207
|
+
: { configured: false });
|
|
1208
|
+
});
|
|
1209
|
+
api('post', '/api/v1/backup/destination', null, async (req, res) => {
|
|
1210
|
+
let parsed;
|
|
1211
|
+
try {
|
|
1212
|
+
parsed = parseBackupDestination(req.body);
|
|
1213
|
+
}
|
|
1214
|
+
catch (e) {
|
|
1215
|
+
return res.status(400).json({ error: errMessage(e) });
|
|
1216
|
+
}
|
|
1217
|
+
const ts = now();
|
|
1218
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1219
|
+
try {
|
|
1220
|
+
db.run('DELETE FROM secrets WHERE project_id=?', BACKUP_SECRET_PROJECT);
|
|
1221
|
+
for (const [name, value] of Object.entries(parsed.env))
|
|
1222
|
+
db.run('INSERT INTO secrets(id,project_id,name,encrypted_value,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?)', id('sec'), BACKUP_SECRET_PROJECT, name, encrypt(value, masterKey), 1, ts, ts);
|
|
1223
|
+
db.run(`INSERT INTO backup_destination(id,url,env_names_json,checked_at,check_result_json,updated_at) VALUES('default',?,?,NULL,NULL,?)
|
|
1224
|
+
ON CONFLICT(id) DO UPDATE SET url=excluded.url,env_names_json=excluded.env_names_json,checked_at=NULL,check_result_json=NULL,updated_at=excluded.updated_at`, parsed.url, JSON.stringify(Object.keys(parsed.env).sort()), ts);
|
|
1225
|
+
db.exec('COMMIT');
|
|
1226
|
+
}
|
|
1227
|
+
catch (e) {
|
|
1228
|
+
db.exec('ROLLBACK');
|
|
1229
|
+
throw e;
|
|
1230
|
+
}
|
|
1231
|
+
audit(req.actor, {
|
|
1232
|
+
type: 'backup.destination_set',
|
|
1233
|
+
message: `backup destination set to ${parsed.url} by ${req.actor.name}`,
|
|
1234
|
+
metadata: { url: parsed.url, envNames: Object.keys(parsed.env).sort() },
|
|
1235
|
+
});
|
|
1236
|
+
res.json({ ok: true, url: parsed.url, envNames: Object.keys(parsed.env).sort() });
|
|
1237
|
+
});
|
|
1238
|
+
api('delete', '/api/v1/backup/destination', null, async (req, res) => {
|
|
1239
|
+
db.run('DELETE FROM secrets WHERE project_id=?', BACKUP_SECRET_PROJECT);
|
|
1240
|
+
const removed = db.run("DELETE FROM backup_destination WHERE id='default'");
|
|
1241
|
+
if (removed.changes)
|
|
1242
|
+
audit(req.actor, {
|
|
1243
|
+
type: 'backup.destination_removed',
|
|
1244
|
+
message: `backup destination removed by ${req.actor.name}`,
|
|
1245
|
+
});
|
|
1246
|
+
res.json({ ok: true, removed: removed.changes > 0 });
|
|
1247
|
+
});
|
|
1248
|
+
api('get', '/api/v1/backup/key', null, async (req, res) => {
|
|
1249
|
+
const project = String(req.query.project || '');
|
|
1250
|
+
if (!PROJECT_NAME_PATTERN.test(project) || isReservedProject(project))
|
|
1251
|
+
return res.status(400).json({ error: 'project is required' });
|
|
1252
|
+
if (!db.get('SELECT 1 FROM projects WHERE id=?', project))
|
|
1253
|
+
return res.status(404).json({ error: `project ${project} not found` });
|
|
1254
|
+
const destination = backupDestination();
|
|
1255
|
+
audit(req.actor, {
|
|
1256
|
+
type: 'backup.key_read',
|
|
1257
|
+
project,
|
|
1258
|
+
message: `repository password for ${project} read by ${req.actor.name}`,
|
|
1259
|
+
});
|
|
1260
|
+
res.json({
|
|
1261
|
+
project,
|
|
1262
|
+
repository: destination ? projectRepository(destination.url, project) : null,
|
|
1263
|
+
password: repositoryPassword(masterKey, project),
|
|
1264
|
+
});
|
|
1265
|
+
});
|
|
1063
1266
|
api('post', '/api/v1/secrets', bodyProject, async (req, res) => {
|
|
1064
1267
|
const { project = 'site', name, value } = req.body;
|
|
1268
|
+
if (isReservedProject(String(project)))
|
|
1269
|
+
return res.status(400).json({ error: `project names starting with _ are reserved` });
|
|
1065
1270
|
if (!name || value == null)
|
|
1066
1271
|
return res.status(400).json({ error: 'name and value required' });
|
|
1067
1272
|
let lock;
|
|
@@ -1087,11 +1292,15 @@ export async function createApp(options = {}) {
|
|
|
1087
1292
|
releaseLock(lock);
|
|
1088
1293
|
}
|
|
1089
1294
|
});
|
|
1090
|
-
api('get', '/api/v1/secrets', queryProject, async (req, res) =>
|
|
1091
|
-
|
|
1092
|
-
|
|
1295
|
+
api('get', '/api/v1/secrets', queryProject, async (req, res) => isReservedProject(String(req.query.project || 'site'))
|
|
1296
|
+
? res.status(400).json({ error: 'project names starting with _ are reserved' })
|
|
1297
|
+
: res.json({
|
|
1298
|
+
secrets: db.all('SELECT project_id,name,version,updated_at FROM secrets WHERE project_id=? ORDER BY name', String(req.query.project || 'site')),
|
|
1299
|
+
}));
|
|
1093
1300
|
api('delete', '/api/v1/secrets/:name', queryProject, async (req, res) => {
|
|
1094
1301
|
const project = String(req.query.project || 'site');
|
|
1302
|
+
if (isReservedProject(project))
|
|
1303
|
+
return res.status(400).json({ error: 'project names starting with _ are reserved' });
|
|
1095
1304
|
const removed = db.run('DELETE FROM secrets WHERE project_id=? AND name=?', project, req.params.name);
|
|
1096
1305
|
if (removed.changes) {
|
|
1097
1306
|
wakeAll(project);
|
|
@@ -1208,18 +1417,6 @@ export async function createApp(options = {}) {
|
|
|
1208
1417
|
}
|
|
1209
1418
|
}
|
|
1210
1419
|
const nodes = nodeRows.map((row) => ({ name: row.name }));
|
|
1211
|
-
for (const vol of Object.values(compose.volumes || {})) {
|
|
1212
|
-
const xe = vol?.['x-tug'];
|
|
1213
|
-
if (!xe ||
|
|
1214
|
-
(xe.type !== 'nfs' && xe.driver !== 'nfs') ||
|
|
1215
|
-
xe.address ||
|
|
1216
|
-
!xe.placement?.nodes?.[0])
|
|
1217
|
-
continue;
|
|
1218
|
-
const exporter = db.get('SELECT address,overlay_ip FROM nodes WHERE name=?', xe.placement.nodes[0]);
|
|
1219
|
-
const addr = (MESH && exporter?.overlay_ip) || exporter?.address;
|
|
1220
|
-
if (addr)
|
|
1221
|
-
xe.address = addr;
|
|
1222
|
-
}
|
|
1223
1420
|
try {
|
|
1224
1421
|
validateCompose(compose, nodes.map((n) => n.name));
|
|
1225
1422
|
}
|
|
@@ -1876,6 +2073,8 @@ export async function createApp(options = {}) {
|
|
|
1876
2073
|
const node = db.get('SELECT * FROM nodes WHERE id=?', req.node.id);
|
|
1877
2074
|
const allStates = currentDesiredStates();
|
|
1878
2075
|
const desiredStates = allStates.filter((st) => Object.values(st.services).some((svc) => (svc.placement?.nodes || []).includes(node.name) || !svc.placement?.nodes?.length));
|
|
2076
|
+
// A command past its deadline is never delivered late; the backup scheduler turns it into a missed run.
|
|
2077
|
+
db.run("UPDATE commands SET state='expired',updated_at=? WHERE node_id=? AND state='pending' AND deadline IS NOT NULL AND deadline<?", now(), node.id, now());
|
|
1879
2078
|
const commands = db.all('SELECT * FROM commands WHERE node_id=? AND state=?', node.id, 'pending');
|
|
1880
2079
|
if (commands.length)
|
|
1881
2080
|
db.run(`UPDATE commands SET state='running',attempts=attempts+1,updated_at=? WHERE node_id=? AND state='pending'`, now(), node.id);
|
|
@@ -1896,6 +2095,9 @@ export async function createApp(options = {}) {
|
|
|
1896
2095
|
if (network)
|
|
1897
2096
|
work.network = network;
|
|
1898
2097
|
work.ports = portBindingFor(node, desiredStates);
|
|
2098
|
+
const holds = restores.holds();
|
|
2099
|
+
if (Object.keys(holds).length)
|
|
2100
|
+
work.holds = holds;
|
|
1899
2101
|
if (SERVICE_DNS) {
|
|
1900
2102
|
const all = endpointsFor(allStates);
|
|
1901
2103
|
work.serviceEndpoints = Object.fromEntries(desiredStates.map((st) => [st.project, all[st.project] || {}]));
|
|
@@ -1911,13 +2113,23 @@ export async function createApp(options = {}) {
|
|
|
1911
2113
|
db.run(`INSERT INTO node_statuses(id,node_id,project_id,observed_version,status_json,health,updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(node_id,project_id) DO UPDATE SET observed_version=excluded.observed_version,status_json=excluded.status_json,health=excluded.health,updated_at=excluded.updated_at`, id('ns'), req.node.id, st.project, st.observedVersion, JSON.stringify(st), st.health || 'unknown', ts);
|
|
1912
2114
|
updateDeploymentConvergence(st.project);
|
|
1913
2115
|
}
|
|
2116
|
+
if (report.jobs?.length)
|
|
2117
|
+
backups.onHeartbeat(report.jobs);
|
|
1914
2118
|
for (const cr of (report.commandResults || [])) {
|
|
1915
2119
|
const result = cr.result || {};
|
|
2120
|
+
if (cr.commandId && cr.state)
|
|
2121
|
+
backups.onResult(cr.commandId, cr.state, result);
|
|
1916
2122
|
db.run('UPDATE commands SET state=?,result_json=?,updated_at=? WHERE id=? AND node_id=?', cr.state || 'succeeded', JSON.stringify(result), ts, cr.commandId ?? null, req.node.id);
|
|
1917
2123
|
}
|
|
1918
2124
|
if (report.ingress)
|
|
1919
2125
|
db.run('UPDATE nodes SET ingress_status_json=? WHERE id=?', JSON.stringify({ ...report.ingress, reportedAt: ts }), req.node.id);
|
|
1920
2126
|
db.run('UPDATE nodes SET last_seen_at=?,status=? WHERE id=?', ts, 'online', req.node.id);
|
|
2127
|
+
try {
|
|
2128
|
+
restores.advance();
|
|
2129
|
+
}
|
|
2130
|
+
catch (e) {
|
|
2131
|
+
console.error(`restore failed to advance: ${errMessage(e)}`);
|
|
2132
|
+
}
|
|
1921
2133
|
const nextEndpointSnapshot = JSON.stringify(endpointsFor(currentDesiredStates()));
|
|
1922
2134
|
if (nextEndpointSnapshot !== endpointSnapshot) {
|
|
1923
2135
|
endpointSnapshot = nextEndpointSnapshot;
|
|
@@ -1938,6 +2150,28 @@ export async function createApp(options = {}) {
|
|
|
1938
2150
|
}
|
|
1939
2151
|
res.json({ ok: true });
|
|
1940
2152
|
});
|
|
2153
|
+
// Credentials for a backup helper are released only while its command is running on the calling node, and
|
|
2154
|
+
// never ride in the command payload, events or results.
|
|
2155
|
+
get('/agent/v1/backup/credentials', authAgent, async (req, res) => {
|
|
2156
|
+
const command = db.get('SELECT node_id,project_id,type,state FROM commands WHERE id=?', String(req.query.command || ''));
|
|
2157
|
+
if (!command)
|
|
2158
|
+
return res.status(404).json({ error: 'command not found' });
|
|
2159
|
+
if (command.node_id !== req.node.id)
|
|
2160
|
+
return res.status(403).json({ error: 'command belongs to another node' });
|
|
2161
|
+
if (!BACKUP_COMMAND_TYPES.includes(command.type))
|
|
2162
|
+
return res.status(403).json({ error: 'command does not use backup credentials' });
|
|
2163
|
+
if (command.state !== 'running')
|
|
2164
|
+
return res.status(409).json({ error: `command is ${command.state}, not running` });
|
|
2165
|
+
if (!command.project_id || !backupDestination())
|
|
2166
|
+
return res.status(409).json({ error: 'no backup destination is configured' });
|
|
2167
|
+
const response = {
|
|
2168
|
+
env: {
|
|
2169
|
+
...backupEnv(),
|
|
2170
|
+
RESTIC_PASSWORD: repositoryPassword(masterKey, command.project_id),
|
|
2171
|
+
},
|
|
2172
|
+
};
|
|
2173
|
+
res.json(response);
|
|
2174
|
+
});
|
|
1941
2175
|
get('/agent/v1/secrets', authAgent, async (req, res) => {
|
|
1942
2176
|
const project = String(req.query.project || 'site');
|
|
1943
2177
|
let rows = db.all('SELECT name,encrypted_value,version FROM secrets WHERE project_id=?', project);
|
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
|
+
}
|
package/dist/db.d.ts
CHANGED
|
@@ -65,6 +65,76 @@ export interface NodeStatusRow {
|
|
|
65
65
|
health: string;
|
|
66
66
|
updated_at: string;
|
|
67
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
|
+
}
|
|
68
138
|
export interface SecretRow {
|
|
69
139
|
id: string;
|
|
70
140
|
project_id: string;
|
package/dist/db.js
CHANGED
|
@@ -70,6 +70,13 @@ CREATE INDEX IF NOT EXISTS events_project_created ON events(project_id, created_
|
|
|
70
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);
|
|
71
71
|
CREATE INDEX IF NOT EXISTS events_by_time ON events(created_at);
|
|
72
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);
|
|
73
80
|
`);
|
|
74
81
|
const row = db.get('SELECT value FROM meta WHERE key = ?', 'schema_created_at');
|
|
75
82
|
if (!row)
|
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/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>;
|