@rudderhq/server 0.7.4 → 0.7.6
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/dist/agent-runtimes/index.d.ts +1 -1
- package/dist/agent-runtimes/index.d.ts.map +1 -1
- package/dist/agent-runtimes/types.d.ts +1 -1
- package/dist/agent-runtimes/types.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/routes/chats.d.ts.map +1 -1
- package/dist/routes/chats.js +2 -4
- package/dist/routes/chats.js.map +1 -1
- package/dist/routes/orgs.d.ts.map +1 -1
- package/dist/routes/orgs.js +5 -0
- package/dist/routes/orgs.js.map +1 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/index.js.map +1 -1
- package/dist/services/rudder-plugins.d.ts.map +1 -1
- package/dist/services/rudder-plugins.js +1 -0
- package/dist/services/rudder-plugins.js.map +1 -1
- package/dist/services/rudder-plugins.test.js +10 -0
- package/dist/services/rudder-plugins.test.js.map +1 -1
- package/dist/services/runtime-kernel/assignment-run-guardrail.d.ts +65 -0
- package/dist/services/runtime-kernel/assignment-run-guardrail.d.ts.map +1 -0
- package/dist/services/runtime-kernel/assignment-run-guardrail.js +204 -0
- package/dist/services/runtime-kernel/assignment-run-guardrail.js.map +1 -0
- package/dist/services/runtime-kernel/assignment-run-guardrail.test.d.ts +2 -0
- package/dist/services/runtime-kernel/assignment-run-guardrail.test.d.ts.map +1 -0
- package/dist/services/runtime-kernel/assignment-run-guardrail.test.js +114 -0
- package/dist/services/runtime-kernel/assignment-run-guardrail.test.js.map +1 -0
- package/dist/services/runtime-kernel/heartbeat.execute.d.ts.map +1 -1
- package/dist/services/runtime-kernel/heartbeat.execute.js +117 -2
- package/dist/services/runtime-kernel/heartbeat.execute.js.map +1 -1
- package/dist/services/runtime-kernel/heartbeat.recovery.d.ts +1 -0
- package/dist/services/runtime-kernel/heartbeat.recovery.d.ts.map +1 -1
- package/dist/services/runtime-kernel/heartbeat.recovery.js +1 -0
- package/dist/services/runtime-kernel/heartbeat.recovery.js.map +1 -1
- package/dist/services/side-chats.d.ts +0 -2
- package/dist/services/side-chats.d.ts.map +1 -1
- package/dist/services/side-chats.js +4 -12
- package/dist/services/side-chats.js.map +1 -1
- package/dist/services/workspace-backup-v2.d.ts +127 -0
- package/dist/services/workspace-backup-v2.d.ts.map +1 -0
- package/dist/services/workspace-backup-v2.js +964 -0
- package/dist/services/workspace-backup-v2.js.map +1 -0
- package/dist/services/workspace-backup-v2.test.d.ts +2 -0
- package/dist/services/workspace-backup-v2.test.d.ts.map +1 -0
- package/dist/services/workspace-backup-v2.test.js +318 -0
- package/dist/services/workspace-backup-v2.test.js.map +1 -0
- package/dist/services/workspace-backups.d.ts +46 -1
- package/dist/services/workspace-backups.d.ts.map +1 -1
- package/dist/services/workspace-backups.js +579 -60
- package/dist/services/workspace-backups.js.map +1 -1
- package/package.json +14 -14
- package/ui-dist/assets/{LibraryLiveSurface-D4uCo0l0.js → LibraryLiveSurface-nSMExVEC.js} +1 -1
- package/ui-dist/assets/{abnfDiagram-VRR7QNED-FF2qcR6A.js → abnfDiagram-VRR7QNED-Av8TM585.js} +1 -1
- package/ui-dist/assets/{addon-fit-4gL7JXzc.js → addon-fit-DzFhQGnx.js} +1 -1
- package/ui-dist/assets/{arc-D5pLjh6B.js → arc-Bc-Gf4Fc.js} +1 -1
- package/ui-dist/assets/{architectureDiagram-ZJ3FMSHR-BvqY-HXg.js → architectureDiagram-ZJ3FMSHR-BOph0PHz.js} +1 -1
- package/ui-dist/assets/{blockDiagram-677ZJIJ3-BU6ZbJ0s.js → blockDiagram-677ZJIJ3-BCUhdmf3.js} +1 -1
- package/ui-dist/assets/{c4Diagram-LMCZKHZV-Cl5Gsovj.js → c4Diagram-LMCZKHZV-B_3pH6bx.js} +1 -1
- package/ui-dist/assets/channel-CyoUWdeD.js +1 -0
- package/ui-dist/assets/{chunk-2Q5K7J3B-4a2agt_p.js → chunk-2Q5K7J3B-C4gdcZxO.js} +1 -1
- package/ui-dist/assets/{chunk-32BRIVSS-CF0oOO2T.js → chunk-32BRIVSS-JxQV72by.js} +1 -1
- package/ui-dist/assets/{chunk-5VM5RSS4-CsSq2zX2.js → chunk-5VM5RSS4-f0zO1uV_.js} +1 -1
- package/ui-dist/assets/{chunk-EX3LRPZG-B6riETVZ.js → chunk-EX3LRPZG-Bbn2Dpmg.js} +1 -1
- package/ui-dist/assets/{chunk-JWPE2WC7-S-p1xu0r.js → chunk-JWPE2WC7-B118_FhP.js} +1 -1
- package/ui-dist/assets/{chunk-MOJQB5TN-mVE4v_LV.js → chunk-MOJQB5TN-CEcGkJ3p.js} +1 -1
- package/ui-dist/assets/{chunk-RYQCIY6F-CElbgpgs.js → chunk-RYQCIY6F-C-WY_ozP.js} +1 -1
- package/ui-dist/assets/{chunk-V7JOEXUC-8XM3povP.js → chunk-V7JOEXUC-DS5sjH7p.js} +1 -1
- package/ui-dist/assets/{chunk-VR4S4FIN-LrThp3ht.js → chunk-VR4S4FIN-COyRk9XW.js} +1 -1
- package/ui-dist/assets/{chunk-XXDRQBXY-D38vLb4t.js → chunk-XXDRQBXY-Cd2T_1Uf.js} +1 -1
- package/ui-dist/assets/classDiagram-OUVF2IWQ-BETmcliY.js +1 -0
- package/ui-dist/assets/classDiagram-v2-EOCWNBFH-BETmcliY.js +1 -0
- package/ui-dist/assets/{cose-bilkent-JH36ORCC-CmpZMaDV.js → cose-bilkent-JH36ORCC-iEFFQ2c7.js} +1 -1
- package/ui-dist/assets/{cynefin-VYW2F7L2-D3RnbBvI.js → cynefin-VYW2F7L2-Bp-sv4UP.js} +1 -1
- package/ui-dist/assets/{cynefinDiagram-TSTJHNR4-DZTcUojp.js → cynefinDiagram-TSTJHNR4-CET_eOH5.js} +1 -1
- package/ui-dist/assets/{dagre-VKFMJZFB-6jXSDi0S.js → dagre-VKFMJZFB-BkxxZr0z.js} +1 -1
- package/ui-dist/assets/{diagram-FQU43EPY-DIMm3mfS.js → diagram-FQU43EPY-2b0kz8hj.js} +1 -1
- package/ui-dist/assets/{diagram-G47NLZAW-Cyo22v0_.js → diagram-G47NLZAW-CWgvA-UY.js} +1 -1
- package/ui-dist/assets/{diagram-NH7WQ7WH-CHDV0nSL.js → diagram-NH7WQ7WH-DKoSLHQw.js} +1 -1
- package/ui-dist/assets/{diagram-OA4YK3LP-Jijx9vQO.js → diagram-OA4YK3LP-BtpJ7ObO.js} +1 -1
- package/ui-dist/assets/{diagram-WEI45ONY-CcL19HLE.js → diagram-WEI45ONY-cTRv44yL.js} +1 -1
- package/ui-dist/assets/{ebnfDiagram-CCIWWBDH-D94h-RFQ.js → ebnfDiagram-CCIWWBDH-BMwUwEZM.js} +1 -1
- package/ui-dist/assets/{erDiagram-Q63AITRT-B3ypUGZX.js → erDiagram-Q63AITRT-B9qW6I5c.js} +1 -1
- package/ui-dist/assets/{flowDiagram-23GEKE2U-DjCT6Y1o.js → flowDiagram-23GEKE2U-Cb1rJFcp.js} +1 -1
- package/ui-dist/assets/{ganttDiagram-NO4QXBWP-C3QVhTc9.js → ganttDiagram-NO4QXBWP-CiicdHPl.js} +1 -1
- package/ui-dist/assets/{gitGraphDiagram-IHSO6WYX-D0trBzU5.js → gitGraphDiagram-IHSO6WYX-Cge-AgEB.js} +1 -1
- package/ui-dist/assets/{graph-D1zFmVUP.js → graph-0wCGCEs4.js} +1 -1
- package/ui-dist/assets/{index-Bk_yesiO.js → index-5dD5l_7S.js} +1 -1
- package/ui-dist/assets/{index-BwutQLG1.js → index-BOnG5igl.js} +1 -1
- package/ui-dist/assets/{index-CiCRRUVV.js → index-BXR0UEnQ.js} +1 -1
- package/ui-dist/assets/{index-CB4zr_Ku.js → index-B_c6Qzui.js} +1 -1
- package/ui-dist/assets/{index-CFWOO6yn.js → index-BhIqb_sb.js} +1 -1
- package/ui-dist/assets/{index-BFktZ2tu.js → index-BjGW8ROf.js} +1 -1
- package/ui-dist/assets/{index-B0GEsWui.js → index-BqpG2FnL.js} +555 -555
- package/ui-dist/assets/{index-D8JG-IU9.js → index-C3ghnZSF.js} +1 -1
- package/ui-dist/assets/{index-DKCiVvJo.js → index-CB9X8t3F.js} +1 -1
- package/ui-dist/assets/{index-or2C22bw.js → index-CcDlhtvT.js} +1 -1
- package/ui-dist/assets/{index-CRolpP90.js → index-D1w6UIRX.js} +1 -1
- package/ui-dist/assets/{index-G2yfKEf_.js → index-DN9Yo10m.js} +1 -1
- package/ui-dist/assets/{index-CK8u9b07.js → index-DP30MjES.js} +1 -1
- package/ui-dist/assets/{index-B8bxQLfN.js → index-DXRvkZMv.js} +1 -1
- package/ui-dist/assets/{index-CC89UPa-.js → index-Dy-AXx6E.js} +1 -1
- package/ui-dist/assets/{infoDiagram-FWYZ7A6U-Co0nkcV4.js → infoDiagram-FWYZ7A6U-R2pgAXHR.js} +1 -1
- package/ui-dist/assets/{ishikawaDiagram-FXEZZL3T-CkIg1Jdu.js → ishikawaDiagram-FXEZZL3T-BJivveWk.js} +1 -1
- package/ui-dist/assets/{journeyDiagram-5HDEW3XC-AYUT6JuJ.js → journeyDiagram-5HDEW3XC-CLlN_hss.js} +1 -1
- package/ui-dist/assets/{kanban-definition-HUTT4EX6-C3H2XW08.js → kanban-definition-HUTT4EX6-CCRtDa5s.js} +1 -1
- package/ui-dist/assets/{layout-sZWyqNOu.js → layout-BgygArKO.js} +1 -1
- package/ui-dist/assets/{linear-BLTYou8N.js → linear-ZTGN1Bax.js} +1 -1
- package/ui-dist/assets/{map-zKBdHkX8.js → map-BheoFUge.js} +1 -1
- package/ui-dist/assets/{mermaid.core-DbjdFip8.js → mermaid.core-D4HYOaQr.js} +4 -4
- package/ui-dist/assets/{mindmap-definition-LN4V7U3C-DcDNQ4GQ.js → mindmap-definition-LN4V7U3C-B25TPfzP.js} +1 -1
- package/ui-dist/assets/{pegDiagram-2B236MQR-BGq1PngU.js → pegDiagram-2B236MQR-CWrV5tQE.js} +1 -1
- package/ui-dist/assets/{pieDiagram-ENE6RG2P-BfXWRzNb.js → pieDiagram-ENE6RG2P-BQTq4Jp6.js} +1 -1
- package/ui-dist/assets/{quadrantDiagram-ABIIQ3AL-AYMVAbo1.js → quadrantDiagram-ABIIQ3AL-C5TwZIzz.js} +1 -1
- package/ui-dist/assets/{railroadDiagram-RFXS5EU6-B0PoG0Ih.js → railroadDiagram-RFXS5EU6--wv7_omb.js} +1 -1
- package/ui-dist/assets/{requirementDiagram-TGXJPOKE-Cuuu9wfw.js → requirementDiagram-TGXJPOKE-D80O_sgV.js} +1 -1
- package/ui-dist/assets/{sankeyDiagram-HTMAVEWB-BceAc42f.js → sankeyDiagram-HTMAVEWB-Cz0SmDPj.js} +1 -1
- package/ui-dist/assets/{sequenceDiagram-DBY2YBRQ-cqJ71-Rw.js → sequenceDiagram-DBY2YBRQ-jmLYUOQ2.js} +1 -1
- package/ui-dist/assets/{sizeCapture-X5ZJPWSS-DQ6MibbA.js → sizeCapture-X5ZJPWSS-C1FqwoMN.js} +1 -1
- package/ui-dist/assets/{stateDiagram-2N3HPSRC-Bdl-XY7t.js → stateDiagram-2N3HPSRC-CZa7Dj75.js} +1 -1
- package/ui-dist/assets/stateDiagram-v2-6OUMAXLB-CEUECJmS.js +1 -0
- package/ui-dist/assets/{swimlanes-5IMT3BWC-Dpo2HDtE.js → swimlanes-5IMT3BWC-CbHndCqy.js} +2 -2
- package/ui-dist/assets/swimlanesDiagram-G3AALYLV-4TgYo_NT.js +8 -0
- package/ui-dist/assets/{timeline-definition-FHXFAJF6-BzmJ-xnC.js → timeline-definition-FHXFAJF6-CFOg8vuA.js} +1 -1
- package/ui-dist/assets/{vennDiagram-L72KCM5P-BF2EI7vn.js → vennDiagram-L72KCM5P-1dSr5CoN.js} +1 -1
- package/ui-dist/assets/{wardleyDiagram-EHGQE667-DM65x1i1.js → wardleyDiagram-EHGQE667-CAMneCxP.js} +1 -1
- package/ui-dist/assets/{xterm-DZP7bLZ5.js → xterm-M5ke8gUF.js} +1 -1
- package/ui-dist/assets/{xychartDiagram-FW5EYKEG-3Q2t9pdU.js → xychartDiagram-FW5EYKEG-DxVyl1x4.js} +1 -1
- package/ui-dist/index.html +1 -1
- package/ui-dist/assets/channel-HcmprA3I.js +0 -1
- package/ui-dist/assets/classDiagram-OUVF2IWQ-Dpe8LaLf.js +0 -1
- package/ui-dist/assets/classDiagram-v2-EOCWNBFH-Dpe8LaLf.js +0 -1
- package/ui-dist/assets/stateDiagram-v2-6OUMAXLB-Cjj7yPIO.js +0 -1
- package/ui-dist/assets/swimlanesDiagram-G3AALYLV-CPmIkNCu.js +0 -8
|
@@ -3,11 +3,13 @@ import { heartbeatRuns, workspaceBackups, } from "@rudderhq/db";
|
|
|
3
3
|
import { WORKSPACE_BACKUP_DEFAULT_INTERVAL_HOURS, WORKSPACE_BACKUP_DEFAULT_RETENTION_DAYS, WORKSPACE_BACKUP_OFFLINE_INTERVAL_HOURS, WORKSPACE_BACKUP_RUNNING_INTERVAL_HOURS, } from "@rudderhq/shared";
|
|
4
4
|
import { and, desc, eq, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
|
|
5
5
|
import crypto from "node:crypto";
|
|
6
|
+
import { createReadStream } from "node:fs";
|
|
6
7
|
import fs from "node:fs/promises";
|
|
7
8
|
import path from "node:path";
|
|
8
|
-
import { conflict, notFound, unprocessable } from "../errors.js";
|
|
9
|
+
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
|
|
9
10
|
import { ensureOrganizationWorkspaceLayout, resolveDefaultBackupDir, resolveOrganizationWorkspaceRoot, resolveRudderInstanceId, } from "../home-paths.js";
|
|
10
11
|
import { organizationService } from "./orgs.js";
|
|
12
|
+
import { createWorkspaceBackupV2File, createWorkspaceBackupV2Native, formatWorkspaceBackupV2NativeFallback, inspectWorkspaceBackupV2File, readWorkspaceBackupV2File, workspaceBackupV2NativeDiagnostic, } from "./workspace-backup-v2.js";
|
|
11
13
|
const ARTIFACT_VERSION = 1;
|
|
12
14
|
const MAX_PREVIEW_BYTES = 200_000;
|
|
13
15
|
const SKIPPED_ENTRY_NAMES = new Set([
|
|
@@ -40,6 +42,32 @@ const MAX_WARNING_COUNT = 200;
|
|
|
40
42
|
const SPARSE_WORKSPACE_RECOVERY_MAX_CURRENT_FILES = 25;
|
|
41
43
|
const SPARSE_WORKSPACE_RECOVERY_MAX_RATIO = 0.25;
|
|
42
44
|
const SPARSE_WORKSPACE_RECOVERY_MIN_BACKUP_FILES = 10;
|
|
45
|
+
export function isWorkspaceBackupV2Enabled() {
|
|
46
|
+
return process.env.RUDDER_WORKSPACE_BACKUP_V2_ENABLED === "true";
|
|
47
|
+
}
|
|
48
|
+
export function isWorkspaceBackupV2NativeEnabled() {
|
|
49
|
+
return isWorkspaceBackupV2Enabled() && process.env.RUDDER_WORKSPACE_BACKUP_V2_NATIVE === "true";
|
|
50
|
+
}
|
|
51
|
+
export class WorkspaceRestoreRecoveryRequiredError extends HttpError {
|
|
52
|
+
operationId;
|
|
53
|
+
recoveryCode;
|
|
54
|
+
constructor(receipt, recoveryCode, detail) {
|
|
55
|
+
super(409, "Workspace restore requires recovery before another restore can run.", {
|
|
56
|
+
code: "restore_recovery_required",
|
|
57
|
+
operationId: receipt.operationId,
|
|
58
|
+
orgId: receipt.orgId,
|
|
59
|
+
backupId: receipt.backupId,
|
|
60
|
+
recoveryCode,
|
|
61
|
+
detail,
|
|
62
|
+
workspaceRoot: receipt.workspaceRoot,
|
|
63
|
+
stagingRoot: receipt.stagingRoot,
|
|
64
|
+
rollbackRoot: receipt.rollbackRoot,
|
|
65
|
+
});
|
|
66
|
+
this.name = "WorkspaceRestoreRecoveryRequiredError";
|
|
67
|
+
this.operationId = receipt.operationId;
|
|
68
|
+
this.recoveryCode = recoveryCode;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
43
71
|
function timestamp(date = new Date()) {
|
|
44
72
|
const pad = (value) => String(value).padStart(2, "0");
|
|
45
73
|
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
|
@@ -339,6 +367,251 @@ async function removeDirectoryIfEmpty(directoryPath) {
|
|
|
339
367
|
throw error;
|
|
340
368
|
}
|
|
341
369
|
}
|
|
370
|
+
async function writeRestoreReceipt(filePath, value) {
|
|
371
|
+
const temporary = `${filePath}.${crypto.randomUUID()}.tmp`;
|
|
372
|
+
await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
373
|
+
const handle = await fs.open(temporary, "r+");
|
|
374
|
+
try {
|
|
375
|
+
await handle.sync();
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
await handle.close();
|
|
379
|
+
}
|
|
380
|
+
await fs.rename(temporary, filePath);
|
|
381
|
+
await syncDirectory(path.dirname(filePath));
|
|
382
|
+
}
|
|
383
|
+
async function workspaceTreeSha256(rootPath) {
|
|
384
|
+
const warnings = [];
|
|
385
|
+
const entries = await walkWorkspace(rootPath, rootPath, warnings);
|
|
386
|
+
return buildTreeHash(entries);
|
|
387
|
+
}
|
|
388
|
+
async function syncDirectory(directoryPath) {
|
|
389
|
+
const handle = await fs.open(directoryPath, "r");
|
|
390
|
+
try {
|
|
391
|
+
await handle.sync();
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
await handle.close();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function restoreRecoveryRequired(message, receipt, cause) {
|
|
398
|
+
const detail = cause instanceof Error ? cause.message : cause ? String(cause) : undefined;
|
|
399
|
+
return new WorkspaceRestoreRecoveryRequiredError(receipt, "rollback_failed", `${message}${detail ? `: ${detail}` : ""}`);
|
|
400
|
+
}
|
|
401
|
+
async function readRestoreReceipt(filePath) {
|
|
402
|
+
try {
|
|
403
|
+
const value = JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
404
|
+
if (value.version !== 1
|
|
405
|
+
|| typeof value.operationId !== "string"
|
|
406
|
+
|| typeof value.orgId !== "string"
|
|
407
|
+
|| typeof value.backupId !== "string"
|
|
408
|
+
|| !["prepared", "live_moved", "committed", "rolled_back", "recovery_required"].includes(value.phase ?? "")
|
|
409
|
+
|| typeof value.workspaceRoot !== "string"
|
|
410
|
+
|| typeof value.stagingRoot !== "string"
|
|
411
|
+
|| typeof value.rollbackRoot !== "string"
|
|
412
|
+
|| (value.liveTreeSha256 !== null && typeof value.liveTreeSha256 !== "string")
|
|
413
|
+
|| typeof value.stagingTreeSha256 !== "string"
|
|
414
|
+
|| (value.expectedTreeSha256 !== null && typeof value.expectedTreeSha256 !== "string")
|
|
415
|
+
|| typeof value.preRestoreBackupId !== "string") {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
return value;
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")
|
|
422
|
+
return null;
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function isOwnedRestoreRoot(root, workspaceRoot, operationId, kind) {
|
|
427
|
+
const parent = path.dirname(workspaceRoot);
|
|
428
|
+
return path.resolve(root) !== path.resolve(workspaceRoot)
|
|
429
|
+
&& path.resolve(root) === path.resolve(parent, `.rudder-workspace-restore-${kind}-${operationId}`);
|
|
430
|
+
}
|
|
431
|
+
/** Reconcile only validated receipts and their exact operation-owned roots. */
|
|
432
|
+
export async function reconcileWorkspaceRestoreReceipts() {
|
|
433
|
+
const receiptRoot = path.resolve(resolveDefaultBackupDir(), "workspace-restore-receipts");
|
|
434
|
+
const recovered = [];
|
|
435
|
+
const blocked = [];
|
|
436
|
+
let names;
|
|
437
|
+
try {
|
|
438
|
+
names = await fs.readdir(receiptRoot);
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")
|
|
442
|
+
return { recovered, blocked };
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
for (const name of names.filter((entry) => entry.endsWith(".json"))) {
|
|
446
|
+
const receiptPath = path.join(receiptRoot, name);
|
|
447
|
+
let receipt = null;
|
|
448
|
+
try {
|
|
449
|
+
receipt = await readRestoreReceipt(receiptPath);
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
blocked.push({ receiptPath, operationId: "unknown", error: error instanceof Error ? error.message : String(error) });
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
let canonicalWorkspaceRoot = null;
|
|
456
|
+
try {
|
|
457
|
+
if (receipt?.orgId)
|
|
458
|
+
canonicalWorkspaceRoot = path.resolve(resolveOrganizationWorkspaceRoot(receipt.orgId));
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
canonicalWorkspaceRoot = null;
|
|
462
|
+
}
|
|
463
|
+
if (!receipt
|
|
464
|
+
|| !canonicalWorkspaceRoot
|
|
465
|
+
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(receipt.operationId)
|
|
466
|
+
|| path.resolve(receipt.workspaceRoot) !== canonicalWorkspaceRoot
|
|
467
|
+
|| !isOwnedRestoreRoot(receipt.stagingRoot, receipt.workspaceRoot, receipt.operationId, "staging")
|
|
468
|
+
|| !isOwnedRestoreRoot(receipt.rollbackRoot, receipt.workspaceRoot, receipt.operationId, "rollback")) {
|
|
469
|
+
blocked.push({ receiptPath, operationId: receipt?.operationId ?? "unknown", error: "invalid_or_unowned_receipt" });
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
const workspace = await pathExists(receipt.workspaceRoot);
|
|
474
|
+
const rollback = await pathExists(receipt.rollbackRoot);
|
|
475
|
+
if (receipt.phase === "prepared") {
|
|
476
|
+
if (workspace && rollback)
|
|
477
|
+
throw new Error("prepared receipt has conflicting live and rollback roots");
|
|
478
|
+
if (!workspace && rollback) {
|
|
479
|
+
// Crash window: workspace was renamed before the phase receipt advanced.
|
|
480
|
+
await fs.rename(receipt.rollbackRoot, receipt.workspaceRoot);
|
|
481
|
+
await syncDirectory(path.dirname(receipt.workspaceRoot));
|
|
482
|
+
}
|
|
483
|
+
await fs.rm(receipt.stagingRoot, { recursive: true, force: true });
|
|
484
|
+
await fs.rm(receipt.rollbackRoot, { recursive: true, force: true });
|
|
485
|
+
}
|
|
486
|
+
else if (receipt.phase === "committed" || receipt.phase === "rolled_back") {
|
|
487
|
+
await fs.rm(receipt.stagingRoot, { recursive: true, force: true });
|
|
488
|
+
await fs.rm(receipt.rollbackRoot, { recursive: true, force: true });
|
|
489
|
+
}
|
|
490
|
+
else if (receipt.phase === "live_moved" || receipt.phase === "recovery_required") {
|
|
491
|
+
if (!workspace && rollback) {
|
|
492
|
+
await fs.rename(receipt.rollbackRoot, receipt.workspaceRoot);
|
|
493
|
+
await syncDirectory(path.dirname(receipt.workspaceRoot));
|
|
494
|
+
}
|
|
495
|
+
else if (workspace && !rollback) {
|
|
496
|
+
const publishedTree = await workspaceTreeSha256(receipt.workspaceRoot);
|
|
497
|
+
if (receipt.expectedTreeSha256 && publishedTree !== receipt.expectedTreeSha256)
|
|
498
|
+
throw new Error("published workspace tree does not match receipt");
|
|
499
|
+
}
|
|
500
|
+
else if (workspace && rollback) {
|
|
501
|
+
const publishedTree = await workspaceTreeSha256(receipt.workspaceRoot);
|
|
502
|
+
if (receipt.expectedTreeSha256 && publishedTree === receipt.expectedTreeSha256) {
|
|
503
|
+
await fs.rm(receipt.rollbackRoot, { recursive: true, force: true });
|
|
504
|
+
await syncDirectory(path.dirname(receipt.workspaceRoot));
|
|
505
|
+
}
|
|
506
|
+
else if (receipt.liveTreeSha256) {
|
|
507
|
+
const rollbackTree = await workspaceTreeSha256(receipt.rollbackRoot);
|
|
508
|
+
if (rollbackTree !== receipt.liveTreeSha256)
|
|
509
|
+
throw new Error("workspace and rollback roots match neither recorded tree");
|
|
510
|
+
await fs.rm(receipt.workspaceRoot, { recursive: true, force: true });
|
|
511
|
+
await fs.rename(receipt.rollbackRoot, receipt.workspaceRoot);
|
|
512
|
+
await syncDirectory(path.dirname(receipt.workspaceRoot));
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
throw new Error("workspace and rollback roots both exist without a recorded live tree");
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
throw new Error("workspace and rollback roots are both missing");
|
|
520
|
+
}
|
|
521
|
+
await fs.rm(receipt.stagingRoot, { recursive: true, force: true });
|
|
522
|
+
await fs.rm(receipt.rollbackRoot, { recursive: true, force: true });
|
|
523
|
+
}
|
|
524
|
+
await fs.rm(receiptPath, { force: true });
|
|
525
|
+
await syncDirectory(receiptRoot);
|
|
526
|
+
recovered.push(receipt.operationId);
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
blocked.push({ receiptPath, operationId: receipt.operationId, error: error instanceof Error ? error.message : String(error) });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return { recovered, blocked };
|
|
533
|
+
}
|
|
534
|
+
async function assertNoUnresolvedRestoreReceipt(orgId) {
|
|
535
|
+
const receiptRoot = path.resolve(resolveDefaultBackupDir(), "workspace-restore-receipts");
|
|
536
|
+
let names;
|
|
537
|
+
try {
|
|
538
|
+
names = await fs.readdir(receiptRoot);
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")
|
|
542
|
+
return;
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
545
|
+
const prefix = `${orgId}-`;
|
|
546
|
+
for (const name of names.filter((entry) => entry.startsWith(prefix) && entry.endsWith(".json"))) {
|
|
547
|
+
const receiptPath = path.join(receiptRoot, name);
|
|
548
|
+
let receipt = null;
|
|
549
|
+
try {
|
|
550
|
+
receipt = await readRestoreReceipt(receiptPath);
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
throw conflict("Workspace restore requires recovery before another restore can run.", {
|
|
554
|
+
code: "restore_recovery_required",
|
|
555
|
+
orgId,
|
|
556
|
+
receiptPath,
|
|
557
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
if (receipt && receipt.orgId === orgId && ["prepared", "live_moved", "recovery_required"].includes(receipt.phase)) {
|
|
561
|
+
throw new WorkspaceRestoreRecoveryRequiredError(receipt, "unresolved_receipt", "An earlier workspace restore has not reached a recoverable terminal state.");
|
|
562
|
+
}
|
|
563
|
+
if (!receipt) {
|
|
564
|
+
throw conflict("Workspace restore requires recovery before another restore can run.", {
|
|
565
|
+
code: "restore_recovery_required",
|
|
566
|
+
orgId,
|
|
567
|
+
receiptPath,
|
|
568
|
+
detail: "invalid_or_unowned_receipt",
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
async function withWorkspaceRestoreLock(orgId, operation) {
|
|
574
|
+
const workspaceRoot = resolveOrganizationWorkspaceRoot(orgId);
|
|
575
|
+
const lockPath = path.resolve(path.dirname(workspaceRoot), `.rudder-workspace-restore-lock-${orgId}`);
|
|
576
|
+
await fs.mkdir(path.dirname(workspaceRoot), { recursive: true });
|
|
577
|
+
const deadline = Date.now() + 30_000;
|
|
578
|
+
while (true) {
|
|
579
|
+
try {
|
|
580
|
+
await fs.mkdir(lockPath, { recursive: false, mode: 0o700 });
|
|
581
|
+
await fs.writeFile(path.join(lockPath, "owner.json"), `${JSON.stringify({ orgId, pid: process.pid, acquiredAt: new Date().toISOString() })}\n`, { mode: 0o600 });
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
catch (error) {
|
|
585
|
+
if (error.code !== "EEXIST" || Date.now() >= deadline) {
|
|
586
|
+
throw conflict("Workspace restore is busy for this organization.", { code: "restore_lock_unavailable", orgId });
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
const owner = JSON.parse(await fs.readFile(path.join(lockPath, "owner.json"), "utf8"));
|
|
590
|
+
if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0) {
|
|
591
|
+
try {
|
|
592
|
+
process.kill(owner.pid, 0);
|
|
593
|
+
}
|
|
594
|
+
catch (probeError) {
|
|
595
|
+
if (probeError.code === "ESRCH") {
|
|
596
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
// An unreadable owner is treated as held until the bounded timeout.
|
|
604
|
+
}
|
|
605
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
return await operation();
|
|
610
|
+
}
|
|
611
|
+
finally {
|
|
612
|
+
await fs.rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
342
615
|
export async function reconcileWorkspaceBackupArtifactStorage(db, liveOrgIds) {
|
|
343
616
|
const migrated = [];
|
|
344
617
|
const skipped = [];
|
|
@@ -671,6 +944,33 @@ export function workspaceBackupService(db) {
|
|
|
671
944
|
if (!(await fileExists(row.artifactRef))) {
|
|
672
945
|
throw notFound("Workspace backup artifact not found");
|
|
673
946
|
}
|
|
947
|
+
if (path.extname(row.artifactRef).toLowerCase() === ".zip") {
|
|
948
|
+
try {
|
|
949
|
+
const index = await inspectWorkspaceBackupV2File(row.artifactRef);
|
|
950
|
+
if (row.archiveSha256 && await sha256File(row.artifactRef) !== row.archiveSha256) {
|
|
951
|
+
throw new Error("Workspace backup artifact checksum does not match the recorded backup metadata");
|
|
952
|
+
}
|
|
953
|
+
const manifest = index.manifest;
|
|
954
|
+
const artifact = {
|
|
955
|
+
version: ARTIFACT_VERSION,
|
|
956
|
+
orgId: manifest.identity.orgId,
|
|
957
|
+
instanceId: manifest.identity.instanceId,
|
|
958
|
+
createdAt: manifest.createdAt,
|
|
959
|
+
rootPath: manifest.identity.rootPath,
|
|
960
|
+
entries: manifest.entries.map((entry) => ({
|
|
961
|
+
...entry,
|
|
962
|
+
dataBase64: undefined,
|
|
963
|
+
})),
|
|
964
|
+
warnings: manifest.warnings,
|
|
965
|
+
};
|
|
966
|
+
if (artifact.orgId !== row.orgId)
|
|
967
|
+
throw new Error("organization identity mismatch");
|
|
968
|
+
return { raw: null, artifact, v2: { filePath: row.artifactRef, index } };
|
|
969
|
+
}
|
|
970
|
+
catch (error) {
|
|
971
|
+
throw unprocessable(error instanceof Error ? `Workspace backup v2 artifact is invalid: ${error.message}` : "Workspace backup v2 artifact is invalid");
|
|
972
|
+
}
|
|
973
|
+
}
|
|
674
974
|
const raw = await fs.readFile(row.artifactRef);
|
|
675
975
|
if (row.archiveSha256 && sha256Buffer(raw) !== row.archiveSha256) {
|
|
676
976
|
throw unprocessable("Workspace backup artifact checksum does not match the recorded backup metadata");
|
|
@@ -686,6 +986,15 @@ export function workspaceBackupService(db) {
|
|
|
686
986
|
}
|
|
687
987
|
async function readArtifact(row) {
|
|
688
988
|
const payload = await readArtifactPayload(row);
|
|
989
|
+
if (payload.v2) {
|
|
990
|
+
const entries = await Promise.all(payload.artifact.entries.map(async (entry) => {
|
|
991
|
+
if (entry.kind !== "file")
|
|
992
|
+
return entry;
|
|
993
|
+
const data = await readWorkspaceBackupV2File(payload.v2.filePath, payload.v2.index, entry.path);
|
|
994
|
+
return { ...entry, dataBase64: data.toString("base64") };
|
|
995
|
+
}));
|
|
996
|
+
return { ...payload.artifact, entries };
|
|
997
|
+
}
|
|
689
998
|
return payload.artifact;
|
|
690
999
|
}
|
|
691
1000
|
async function countActiveRuns(orgId) {
|
|
@@ -789,7 +1098,8 @@ export function workspaceBackupService(db) {
|
|
|
789
1098
|
const triggerSource = input.triggerSource ?? "manual";
|
|
790
1099
|
const organizationStorageKey = resolveOrganizationStorageKey(input.orgId);
|
|
791
1100
|
const backupDir = path.resolve(resolveDefaultBackupDir(), "workspaces", organizationStorageKey);
|
|
792
|
-
const
|
|
1101
|
+
const artifactExtension = isWorkspaceBackupV2Enabled() ? ".zip" : ".json";
|
|
1102
|
+
const artifactRef = path.resolve(backupDir, `workspace-${organizationStorageKey}-${timestamp(startedAt)}-${backupId.slice(0, 8)}${artifactExtension}`);
|
|
793
1103
|
const [runningRow] = await db
|
|
794
1104
|
.insert(workspaceBackups)
|
|
795
1105
|
.values({
|
|
@@ -816,6 +1126,91 @@ export function workspaceBackupService(db) {
|
|
|
816
1126
|
try {
|
|
817
1127
|
await fs.mkdir(path.dirname(artifactRef), { recursive: true });
|
|
818
1128
|
const layout = await ensureOrganizationWorkspaceLayout(runningRow.orgId);
|
|
1129
|
+
if (path.extname(artifactRef).toLowerCase() === ".zip") {
|
|
1130
|
+
let nativeFallbackWarning = null;
|
|
1131
|
+
if (isWorkspaceBackupV2NativeEnabled()) {
|
|
1132
|
+
try {
|
|
1133
|
+
const artifact = await createWorkspaceBackupV2Native({
|
|
1134
|
+
rootPath: layout.root,
|
|
1135
|
+
orgId: runningRow.orgId,
|
|
1136
|
+
instanceId: resolveRudderInstanceId(),
|
|
1137
|
+
artifactPath: artifactRef,
|
|
1138
|
+
createdAt: runningRow.startedAt ?? runningRow.createdAt,
|
|
1139
|
+
});
|
|
1140
|
+
const stat = await fs.stat(artifact.artifactPath);
|
|
1141
|
+
const finishedAt = new Date();
|
|
1142
|
+
const [row] = await db
|
|
1143
|
+
.update(workspaceBackups)
|
|
1144
|
+
.set({
|
|
1145
|
+
status: "succeeded",
|
|
1146
|
+
archiveSha256: artifact.archiveSha256,
|
|
1147
|
+
treeSha256: artifact.treeSha256,
|
|
1148
|
+
fileCount: artifact.fileCount,
|
|
1149
|
+
byteSize: artifact.byteSize,
|
|
1150
|
+
compressedSize: stat.size,
|
|
1151
|
+
manifest: artifact.manifest,
|
|
1152
|
+
warnings: artifact.warnings,
|
|
1153
|
+
finishedAt,
|
|
1154
|
+
updatedAt: finishedAt,
|
|
1155
|
+
})
|
|
1156
|
+
.where(and(eq(workspaceBackups.id, backupId), eq(workspaceBackups.status, "running")))
|
|
1157
|
+
.returning();
|
|
1158
|
+
if (row)
|
|
1159
|
+
return mapBackupRow(row);
|
|
1160
|
+
const [currentRow] = await db.select().from(workspaceBackups).where(eq(workspaceBackups.id, backupId)).limit(1);
|
|
1161
|
+
if (currentRow)
|
|
1162
|
+
return mapBackupRow(currentRow);
|
|
1163
|
+
throw new Error("Workspace backup row was not updated.");
|
|
1164
|
+
}
|
|
1165
|
+
catch (error) {
|
|
1166
|
+
// Native is opt-in and never changes the default Node comparator. A
|
|
1167
|
+
// failed native operation falls through to the same contract-equivalent
|
|
1168
|
+
// Node writer while retaining the DB row and TypeScript decisions.
|
|
1169
|
+
const diagnostic = workspaceBackupV2NativeDiagnostic(error);
|
|
1170
|
+
if (!diagnostic.fallbackAllowed || await fs.stat(artifactRef).then(() => true).catch(() => false)) {
|
|
1171
|
+
throw new Error(`Native workspace backup cannot fall back [${diagnostic.category}/${diagnostic.code}]: ${diagnostic.detail}`);
|
|
1172
|
+
}
|
|
1173
|
+
nativeFallbackWarning = formatWorkspaceBackupV2NativeFallback(error);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
const artifact = await createWorkspaceBackupV2File({
|
|
1177
|
+
rootPath: layout.root,
|
|
1178
|
+
orgId: runningRow.orgId,
|
|
1179
|
+
instanceId: resolveRudderInstanceId(),
|
|
1180
|
+
artifactPath: artifactRef,
|
|
1181
|
+
createdAt: runningRow.startedAt ?? runningRow.createdAt,
|
|
1182
|
+
additionalWarnings: nativeFallbackWarning ? [nativeFallbackWarning] : undefined,
|
|
1183
|
+
});
|
|
1184
|
+
const archiveSha256 = artifact.archiveSha256;
|
|
1185
|
+
const stat = await fs.stat(artifact.artifactPath);
|
|
1186
|
+
const finishedAt = new Date();
|
|
1187
|
+
const [row] = await db
|
|
1188
|
+
.update(workspaceBackups)
|
|
1189
|
+
.set({
|
|
1190
|
+
status: "succeeded",
|
|
1191
|
+
archiveSha256,
|
|
1192
|
+
treeSha256: artifact.treeSha256,
|
|
1193
|
+
fileCount: artifact.fileCount,
|
|
1194
|
+
byteSize: artifact.byteSize,
|
|
1195
|
+
compressedSize: stat.size,
|
|
1196
|
+
manifest: artifact.manifest,
|
|
1197
|
+
warnings: artifact.warnings,
|
|
1198
|
+
finishedAt,
|
|
1199
|
+
updatedAt: finishedAt,
|
|
1200
|
+
})
|
|
1201
|
+
.where(and(eq(workspaceBackups.id, backupId), eq(workspaceBackups.status, "running")))
|
|
1202
|
+
.returning();
|
|
1203
|
+
if (row)
|
|
1204
|
+
return mapBackupRow(row);
|
|
1205
|
+
const [currentRow] = await db
|
|
1206
|
+
.select()
|
|
1207
|
+
.from(workspaceBackups)
|
|
1208
|
+
.where(eq(workspaceBackups.id, backupId))
|
|
1209
|
+
.limit(1);
|
|
1210
|
+
if (currentRow)
|
|
1211
|
+
return mapBackupRow(currentRow);
|
|
1212
|
+
throw new Error("Workspace backup row was not updated.");
|
|
1213
|
+
}
|
|
819
1214
|
const warnings = [];
|
|
820
1215
|
const entries = await walkWorkspace(layout.root, layout.root, warnings);
|
|
821
1216
|
const fileCount = entries.filter((entry) => entry.kind === "file").length;
|
|
@@ -911,7 +1306,8 @@ export function workspaceBackupService(db) {
|
|
|
911
1306
|
},
|
|
912
1307
|
async listFiles(orgId, backupId, directoryPath = "") {
|
|
913
1308
|
const row = await getBackupRow(orgId, backupId);
|
|
914
|
-
const
|
|
1309
|
+
const payload = await readArtifactPayload(row);
|
|
1310
|
+
const artifact = payload.artifact;
|
|
915
1311
|
const normalizedPath = assertSafeRelativePath(directoryPath);
|
|
916
1312
|
const entries = directChildrenFromArtifact(artifact, normalizedPath);
|
|
917
1313
|
return {
|
|
@@ -926,12 +1322,17 @@ export function workspaceBackupService(db) {
|
|
|
926
1322
|
},
|
|
927
1323
|
async readFile(orgId, backupId, filePath) {
|
|
928
1324
|
const row = await getBackupRow(orgId, backupId);
|
|
929
|
-
const
|
|
1325
|
+
const payload = await readArtifactPayload(row);
|
|
1326
|
+
const artifact = payload.artifact;
|
|
930
1327
|
const normalizedPath = assertSafeRelativePath(filePath);
|
|
931
1328
|
const file = findArtifactFile(artifact, normalizedPath);
|
|
932
|
-
if (!file
|
|
1329
|
+
if (!file)
|
|
1330
|
+
throw notFound("File not found inside the workspace backup");
|
|
1331
|
+
const buffer = payload.v2
|
|
1332
|
+
? await readWorkspaceBackupV2File(payload.v2.filePath, payload.v2.index, normalizedPath)
|
|
1333
|
+
: file.dataBase64 ? Buffer.from(file.dataBase64, "base64") : null;
|
|
1334
|
+
if (!buffer)
|
|
933
1335
|
throw notFound("File not found inside the workspace backup");
|
|
934
|
-
const buffer = Buffer.from(file.dataBase64, "base64");
|
|
935
1336
|
if (isBinaryBuffer(buffer)) {
|
|
936
1337
|
return {
|
|
937
1338
|
source: "org_root",
|
|
@@ -971,6 +1372,16 @@ export function workspaceBackupService(db) {
|
|
|
971
1372
|
async getDownload(orgId, backupId) {
|
|
972
1373
|
const row = await getBackupRow(orgId, backupId);
|
|
973
1374
|
const payload = await readArtifactPayload(row);
|
|
1375
|
+
if (payload.v2) {
|
|
1376
|
+
return {
|
|
1377
|
+
artifactRef: row.artifactRef,
|
|
1378
|
+
filename: `${path.basename(row.artifactRef, path.extname(row.artifactRef))}.zip`,
|
|
1379
|
+
contentType: "application/zip",
|
|
1380
|
+
byteSize: payload.v2.index.archiveSize,
|
|
1381
|
+
archiveSha256: row.archiveSha256,
|
|
1382
|
+
contentStream: createReadStream(row.artifactRef),
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
974
1385
|
const zip = buildWorkspaceBackupZip(payload.artifact, backupDownloadRootFolderName(payload.artifact));
|
|
975
1386
|
return {
|
|
976
1387
|
artifactRef: row.artifactRef,
|
|
@@ -1146,64 +1557,172 @@ export function workspaceBackupService(db) {
|
|
|
1146
1557
|
};
|
|
1147
1558
|
},
|
|
1148
1559
|
async restore(orgId, backupId, input) {
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
}
|
|
1164
|
-
const activeRunCountAfterPreRestore = await countActiveRuns(orgId);
|
|
1165
|
-
if (activeRunCountAfterPreRestore > 0) {
|
|
1166
|
-
throw conflict("Workspace restore is blocked while this organization has active runs.", {
|
|
1167
|
-
activeRunCount: activeRunCountAfterPreRestore,
|
|
1168
|
-
preRestoreBackupId: preRestoreBackup.id,
|
|
1560
|
+
return withWorkspaceRestoreLock(orgId, async () => {
|
|
1561
|
+
await assertNoUnresolvedRestoreReceipt(orgId);
|
|
1562
|
+
const row = await getBackupRow(orgId, backupId);
|
|
1563
|
+
const activeRunCount = await countActiveRuns(orgId);
|
|
1564
|
+
if (activeRunCount > 0) {
|
|
1565
|
+
throw conflict("Workspace restore is blocked while this organization has active runs.", { activeRunCount });
|
|
1566
|
+
}
|
|
1567
|
+
const payload = await readArtifactPayload(row);
|
|
1568
|
+
const artifact = payload.artifact;
|
|
1569
|
+
const preRestoreBackup = await service.create({
|
|
1570
|
+
orgId,
|
|
1571
|
+
triggerSource: "pre_restore",
|
|
1572
|
+
createdByUserId: input?.createdByUserId ?? null,
|
|
1573
|
+
restoredFromBackupId: backupId,
|
|
1169
1574
|
});
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
const stagingRoot = path.resolve(resolveDefaultBackupDir(), "workspace-restore-staging", `${orgId}-${backupId}-${Date.now()}`);
|
|
1173
|
-
await fs.rm(stagingRoot, { recursive: true, force: true });
|
|
1174
|
-
await fs.mkdir(stagingRoot, { recursive: true, mode: 0o700 });
|
|
1175
|
-
try {
|
|
1176
|
-
const directories = artifact.entries.filter((entry) => entry.kind === "directory");
|
|
1177
|
-
const files = artifact.entries.filter((entry) => entry.kind === "file");
|
|
1178
|
-
for (const entry of directories) {
|
|
1179
|
-
const { resolvedTarget } = resolveWithinRoot(stagingRoot, entry.path);
|
|
1180
|
-
await fs.mkdir(resolvedTarget, { recursive: true });
|
|
1575
|
+
if (preRestoreBackup.status !== "succeeded") {
|
|
1576
|
+
throw conflict("Pre-restore backup failed; workspace was not changed.", { backupId: preRestoreBackup.id });
|
|
1181
1577
|
}
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1578
|
+
const activeRunCountAfterPreRestore = await countActiveRuns(orgId);
|
|
1579
|
+
if (activeRunCountAfterPreRestore > 0) {
|
|
1580
|
+
throw conflict("Workspace restore is blocked while this organization has active runs.", {
|
|
1581
|
+
activeRunCount: activeRunCountAfterPreRestore,
|
|
1582
|
+
preRestoreBackupId: preRestoreBackup.id,
|
|
1583
|
+
});
|
|
1186
1584
|
}
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1585
|
+
const workspaceRoot = resolveOrganizationWorkspaceRoot(orgId);
|
|
1586
|
+
const operationId = crypto.randomUUID();
|
|
1587
|
+
const stagingRoot = path.resolve(path.dirname(workspaceRoot), `.rudder-workspace-restore-staging-${operationId}`);
|
|
1588
|
+
const rollbackRoot = path.resolve(path.dirname(workspaceRoot), `.rudder-workspace-restore-rollback-${operationId}`);
|
|
1589
|
+
const receiptRoot = path.resolve(resolveDefaultBackupDir(), "workspace-restore-receipts");
|
|
1590
|
+
const receiptPath = path.join(receiptRoot, `${orgId}-${operationId}.json`);
|
|
1193
1591
|
await fs.rm(stagingRoot, { recursive: true, force: true });
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1592
|
+
await fs.mkdir(stagingRoot, { recursive: true, mode: 0o700 });
|
|
1593
|
+
try {
|
|
1594
|
+
const directories = artifact.entries.filter((entry) => entry.kind === "directory");
|
|
1595
|
+
const files = artifact.entries.filter((entry) => entry.kind === "file");
|
|
1596
|
+
for (const entry of directories) {
|
|
1597
|
+
const { resolvedTarget } = resolveWithinRoot(stagingRoot, entry.path);
|
|
1598
|
+
await fs.mkdir(resolvedTarget, { recursive: true });
|
|
1599
|
+
}
|
|
1600
|
+
for (const entry of files) {
|
|
1601
|
+
const { resolvedTarget } = resolveWithinRoot(stagingRoot, entry.path);
|
|
1602
|
+
await fs.mkdir(path.dirname(resolvedTarget), { recursive: true });
|
|
1603
|
+
const data = payload.v2
|
|
1604
|
+
? await readWorkspaceBackupV2File(payload.v2.filePath, payload.v2.index, entry.path)
|
|
1605
|
+
: Buffer.from(entry.dataBase64 ?? "", "base64");
|
|
1606
|
+
await fs.writeFile(resolvedTarget, data, { mode: entry.mode ?? 0o600 });
|
|
1607
|
+
}
|
|
1608
|
+
await fs.mkdir(path.dirname(workspaceRoot), { recursive: true });
|
|
1609
|
+
await fs.mkdir(receiptRoot, { recursive: true, mode: 0o700 });
|
|
1610
|
+
const stagingTreeSha256 = await workspaceTreeSha256(stagingRoot);
|
|
1611
|
+
const liveTreeSha256 = await pathExists(workspaceRoot) ? await workspaceTreeSha256(workspaceRoot) : null;
|
|
1612
|
+
await writeRestoreReceipt(receiptPath, {
|
|
1613
|
+
version: 1,
|
|
1614
|
+
operationId,
|
|
1615
|
+
orgId,
|
|
1616
|
+
backupId,
|
|
1617
|
+
phase: "prepared",
|
|
1618
|
+
workspaceRoot,
|
|
1619
|
+
stagingRoot,
|
|
1620
|
+
rollbackRoot,
|
|
1621
|
+
liveTreeSha256,
|
|
1622
|
+
stagingTreeSha256,
|
|
1623
|
+
expectedTreeSha256: row.treeSha256,
|
|
1624
|
+
preRestoreBackupId: preRestoreBackup.id,
|
|
1625
|
+
});
|
|
1626
|
+
if (await countActiveRuns(orgId) > 0) {
|
|
1627
|
+
throw conflict("Workspace restore is blocked while this organization has active runs.");
|
|
1628
|
+
}
|
|
1629
|
+
const liveExists = await pathExists(workspaceRoot);
|
|
1630
|
+
if (liveExists && !liveExists.isDirectory()) {
|
|
1631
|
+
throw conflict("Workspace restore is blocked because the organization workspace root is not a directory.");
|
|
1632
|
+
}
|
|
1633
|
+
if (liveExists) {
|
|
1634
|
+
await fs.rename(workspaceRoot, rollbackRoot);
|
|
1635
|
+
await syncDirectory(path.dirname(workspaceRoot));
|
|
1636
|
+
await writeRestoreReceipt(receiptPath, {
|
|
1637
|
+
version: 1, operationId, orgId, backupId, phase: "live_moved", workspaceRoot, stagingRoot, rollbackRoot,
|
|
1638
|
+
expectedTreeSha256: row.treeSha256, preRestoreBackupId: preRestoreBackup.id,
|
|
1639
|
+
liveTreeSha256, stagingTreeSha256,
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
try {
|
|
1643
|
+
await fs.rename(stagingRoot, workspaceRoot);
|
|
1644
|
+
await syncDirectory(path.dirname(workspaceRoot));
|
|
1645
|
+
}
|
|
1646
|
+
catch (error) {
|
|
1647
|
+
if (liveExists) {
|
|
1648
|
+
try {
|
|
1649
|
+
await fs.rename(rollbackRoot, workspaceRoot);
|
|
1650
|
+
await syncDirectory(path.dirname(workspaceRoot));
|
|
1651
|
+
await writeRestoreReceipt(receiptPath, {
|
|
1652
|
+
version: 1, operationId, orgId, backupId, phase: "rolled_back", workspaceRoot, stagingRoot, rollbackRoot,
|
|
1653
|
+
expectedTreeSha256: row.treeSha256, preRestoreBackupId: preRestoreBackup.id,
|
|
1654
|
+
liveTreeSha256, stagingTreeSha256,
|
|
1655
|
+
});
|
|
1656
|
+
throw conflict("Workspace restore failed and was rolled back.", { code: "restore_rolled_back", operationId });
|
|
1657
|
+
}
|
|
1658
|
+
catch (rollbackError) {
|
|
1659
|
+
if (rollbackError instanceof Error && "status" in rollbackError && rollbackError.status === 409)
|
|
1660
|
+
throw rollbackError;
|
|
1661
|
+
const receipt = {
|
|
1662
|
+
version: 1, operationId, orgId, backupId, phase: "recovery_required", workspaceRoot, stagingRoot, rollbackRoot,
|
|
1663
|
+
expectedTreeSha256: row.treeSha256, preRestoreBackupId: preRestoreBackup.id,
|
|
1664
|
+
liveTreeSha256, stagingTreeSha256,
|
|
1665
|
+
};
|
|
1666
|
+
await writeRestoreReceipt(receiptPath, receipt);
|
|
1667
|
+
throw restoreRecoveryRequired("Workspace restore requires recovery after a failed rollback.", receipt, rollbackError);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
throw error;
|
|
1671
|
+
}
|
|
1672
|
+
await ensureOrganizationWorkspaceLayout(orgId);
|
|
1673
|
+
const publishedTreeSha256 = await workspaceTreeSha256(workspaceRoot);
|
|
1674
|
+
if (row.treeSha256 && publishedTreeSha256 !== row.treeSha256) {
|
|
1675
|
+
await fs.rm(workspaceRoot, { recursive: true, force: true });
|
|
1676
|
+
if (liveExists) {
|
|
1677
|
+
try {
|
|
1678
|
+
await fs.rename(rollbackRoot, workspaceRoot);
|
|
1679
|
+
await syncDirectory(path.dirname(workspaceRoot));
|
|
1680
|
+
await writeRestoreReceipt(receiptPath, {
|
|
1681
|
+
version: 1, operationId, orgId, backupId, phase: "rolled_back", workspaceRoot, stagingRoot, rollbackRoot,
|
|
1682
|
+
expectedTreeSha256: row.treeSha256, preRestoreBackupId: preRestoreBackup.id,
|
|
1683
|
+
liveTreeSha256, stagingTreeSha256, publishedTreeSha256,
|
|
1684
|
+
});
|
|
1685
|
+
throw conflict("Workspace restore failed published tree verification and was rolled back.", { code: "restore_rolled_back", operationId });
|
|
1686
|
+
}
|
|
1687
|
+
catch (rollbackError) {
|
|
1688
|
+
if (rollbackError instanceof Error && "status" in rollbackError && rollbackError.status === 409)
|
|
1689
|
+
throw rollbackError;
|
|
1690
|
+
const receipt = {
|
|
1691
|
+
version: 1, operationId, orgId, backupId, phase: "recovery_required", workspaceRoot, stagingRoot, rollbackRoot,
|
|
1692
|
+
expectedTreeSha256: row.treeSha256, preRestoreBackupId: preRestoreBackup.id,
|
|
1693
|
+
liveTreeSha256, stagingTreeSha256, publishedTreeSha256,
|
|
1694
|
+
};
|
|
1695
|
+
await writeRestoreReceipt(receiptPath, receipt);
|
|
1696
|
+
throw restoreRecoveryRequired("Workspace restore requires recovery after published-tree verification failed.", receipt, rollbackError);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
throw conflict("Workspace restore failed published tree verification.", { code: "restore_rolled_back", operationId });
|
|
1700
|
+
}
|
|
1701
|
+
await writeRestoreReceipt(receiptPath, {
|
|
1702
|
+
version: 1, operationId, orgId, backupId, phase: "committed", workspaceRoot, stagingRoot, rollbackRoot,
|
|
1703
|
+
expectedTreeSha256: row.treeSha256, liveTreeSha256, stagingTreeSha256, publishedTreeSha256, preRestoreBackupId: preRestoreBackup.id,
|
|
1704
|
+
});
|
|
1705
|
+
await syncDirectory(path.dirname(workspaceRoot));
|
|
1706
|
+
if (liveExists)
|
|
1707
|
+
await fs.rm(rollbackRoot, { recursive: true, force: true });
|
|
1708
|
+
await fs.rm(receiptPath, { force: true });
|
|
1709
|
+
}
|
|
1710
|
+
finally {
|
|
1711
|
+
await fs.rm(stagingRoot, { recursive: true, force: true });
|
|
1712
|
+
}
|
|
1713
|
+
const updatedAt = new Date();
|
|
1714
|
+
const [restoredRow] = await db
|
|
1715
|
+
.update(workspaceBackups)
|
|
1716
|
+
.set({ status: "restored", updatedAt })
|
|
1717
|
+
.where(eq(workspaceBackups.id, backupId))
|
|
1718
|
+
.returning();
|
|
1719
|
+
if (!restoredRow)
|
|
1720
|
+
throw notFound("Workspace backup not found");
|
|
1721
|
+
return {
|
|
1722
|
+
restoredBackup: mapBackupRow(restoredRow),
|
|
1723
|
+
preRestoreBackup,
|
|
1724
|
+
};
|
|
1725
|
+
});
|
|
1207
1726
|
},
|
|
1208
1727
|
};
|
|
1209
1728
|
return service;
|