Skip to content

Commit f17f0f1

Browse files
committed
backup fix
1 parent b6d3b07 commit f17f0f1

3 files changed

Lines changed: 64 additions & 5 deletions

File tree

scripts/ctl/backup.test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, test } from "bun:test";
2-
import { assertNewRestoreTargetIsUnused, rewriteRecoveredBackupMetadata } from "./backup";
3-
import { chooseLatestExportSnapshot, isRetriableS3ExportError } from "../terrarium-s3-export";
2+
import { assertNewRestoreTargetIsUnused, rewriteRecoveredBackupMetadata, rollbackSnapshotsForDatasetTree } from "./backup";
3+
import { chooseLatestExportSnapshot, isRetriableS3ExportError, zfsReplicationSendCommand } from "../terrarium-s3-export";
44

55
describe("backup restore metadata", () => {
66
test("classifies transient S3 export errors for retry", () => {
@@ -27,6 +27,13 @@ describe("backup restore metadata", () => {
2727
).toBe("terrarium/containers/app@manual-keep");
2828
});
2929

30+
test("exports recursive ZFS replication streams so child rootfs datasets are included", () => {
31+
expect(zfsReplicationSendCommand("terrarium/containers/app@manual-keep")).toBe("zfs send -R 'terrarium/containers/app@manual-keep'");
32+
expect(zfsReplicationSendCommand("terrarium/containers/app@manual-keep", "terrarium/containers/app@manual-base")).toBe(
33+
"zfs send -R -I 'terrarium/containers/app@manual-base' 'terrarium/containers/app@manual-keep'"
34+
);
35+
});
36+
3037
test("renames restored LXD metadata and removes generated identity", () => {
3138
const rewritten = rewriteRecoveredBackupMetadata(
3239
{
@@ -112,6 +119,22 @@ describe("backup restore metadata", () => {
112119
});
113120

114121
describe("backup restore target safety", () => {
122+
test("rolls back descendant snapshots before the parent container dataset", () => {
123+
expect(
124+
rollbackSnapshotsForDatasetTree("terrarium/containers/app@manual-keep", [
125+
"terrarium/containers/app@manual-keep",
126+
"terrarium/containers/app/rootfs@manual-keep",
127+
"terrarium/containers/app/rootfs/nested@manual-keep",
128+
"terrarium/containers/app/rootfs@other",
129+
"terrarium/containers/other/rootfs@manual-keep"
130+
])
131+
).toEqual([
132+
"terrarium/containers/app/rootfs/nested@manual-keep",
133+
"terrarium/containers/app/rootfs@manual-keep",
134+
"terrarium/containers/app@manual-keep"
135+
]);
136+
});
137+
115138
test("rejects restore-as-new when the target LXD instance already exists", async () => {
116139
await expect(
117140
assertNewRestoreTargetIsUnused("victim", "terrarium/containers/victim", async (cmd) => {

scripts/ctl/backup.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,35 @@ async function zfsSnapshotExists(snapshot: string): Promise<boolean> {
339339
return (await runAllowFailure(["zfs", "list", "-H", "-t", "snapshot", snapshot])).exitCode === 0;
340340
}
341341

342+
export function rollbackSnapshotsForDatasetTree(snapshot: string, availableSnapshots: string[]): string[] {
343+
const snapshotMarker = snapshot.indexOf("@");
344+
if (snapshotMarker === -1) {
345+
throw new Error(`invalid ZFS snapshot name: ${snapshot}`);
346+
}
347+
348+
const dataset = snapshot.slice(0, snapshotMarker);
349+
const snapshotSuffix = snapshot.slice(snapshotMarker);
350+
const descendants = availableSnapshots
351+
.map((item) => item.trim())
352+
.filter((item) => item.startsWith(`${dataset}/`) && item.endsWith(snapshotSuffix))
353+
.sort((left, right) => right.split("/").length - left.split("/").length || right.localeCompare(left));
354+
355+
return [...descendants, snapshot];
356+
}
357+
358+
async function rollbackDatasetTreeToSnapshot(snapshot: string): Promise<void> {
359+
const snapshotMarker = snapshot.indexOf("@");
360+
if (snapshotMarker === -1) {
361+
throw new Error(`invalid ZFS snapshot name: ${snapshot}`);
362+
}
363+
364+
const dataset = snapshot.slice(0, snapshotMarker);
365+
const snapshots = await runText(["zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-r", dataset], PREFIX);
366+
for (const item of rollbackSnapshotsForDatasetTree(snapshot, snapshots.split("\n"))) {
367+
await runText(["zfs", "rollback", "-r", item], PREFIX);
368+
}
369+
}
370+
342371
function restoreTempDatasetName(targetDataset: string, label: string): string {
343372
const suffix = `${label}-${process.pid}-${Date.now()}`.replace(/[^A-Za-z0-9_.:-]/g, "-");
344373
return `${targetDataset}-${suffix}`;
@@ -452,7 +481,7 @@ async function restoreLocal(
452481
if (mode === "in-place") {
453482
await confirmDestructive(`Rollback ${instance} in place to ${snapshot}?`);
454483
await stopInstanceForRestore(instance);
455-
await runText(["zfs", "rollback", "-r", snapshot], PREFIX);
484+
await rollbackDatasetTreeToSnapshot(snapshot);
456485
console.log(success(`Rolled back ${instance} to ${snapshot}`));
457486
console.log(`${label("Next:")} ${value(`lxc start ${instance}`)}`);
458487
return;

scripts/terrarium-s3-export.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ async function latestSnapshot(dataset: string): Promise<string> {
5858
return chooseLatestExportSnapshot(stdout.split("\n"), dataset);
5959
}
6060

61+
export function zfsReplicationSendCommand(latestSnapshot: string, parentSnapshot = ""): string {
62+
if (parentSnapshot) {
63+
return `zfs send -R -I ${shellEscape(parentSnapshot)} ${shellEscape(latestSnapshot)}`;
64+
}
65+
return `zfs send -R ${shellEscape(latestSnapshot)}`;
66+
}
67+
6168
export function isRetriableS3ExportError(message: string): boolean {
6269
const lowered = message.toLowerCase();
6370
return [
@@ -161,8 +168,8 @@ export async function backupExportCmd(configPath = DEFAULT_CONFIG_PATH): Promise
161168

162169
const streamSource =
163170
last && (await runAllowFailure(["zfs", "list", "-H", "-t", "snapshot", last])).exitCode === 0
164-
? `zfs send -I ${shellEscape(last)} ${shellEscape(latest)}`
165-
: `zfs send ${shellEscape(latest)}`;
171+
? zfsReplicationSendCommand(latest, last)
172+
: zfsReplicationSendCommand(latest);
166173

167174
await runRetriableS3Command(
168175
`${streamSource} | zstd -T0 | ${awsBase.map(shellEscape).join(" ")} s3 cp - ${shellEscape(`s3://${bucket}/${objectKey}`)}`,

0 commit comments

Comments
 (0)