Skip to content

Commit d0e05cb

Browse files
committed
fix: Post-upgrade S3 backups can form unrestorable chains
1 parent ef08b77 commit d0e05cb

2 files changed

Lines changed: 61 additions & 9 deletions

File tree

scripts/ctl/backup.test.ts

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

55
describe("backup restore metadata", () => {
66
test("classifies transient S3 export errors for retry", () => {
@@ -34,6 +34,37 @@ describe("backup restore metadata", () => {
3434
);
3535
});
3636

37+
test("forces a full recursive S3 baseline for legacy last-snapshot state", () => {
38+
expect(planS3SnapshotExport("terrarium/containers/app@s2", "terrarium/containers/app@s1", "", true)).toEqual({
39+
skip: false,
40+
parentSnapshot: "",
41+
full: true
42+
});
43+
expect(planS3SnapshotExport("terrarium/containers/app@s2", "terrarium/containers/app@s2", "", true)).toEqual({
44+
skip: false,
45+
parentSnapshot: "",
46+
full: true
47+
});
48+
});
49+
50+
test("uses S3 incremental parents only after recursive state has been recorded", () => {
51+
expect(planS3SnapshotExport("terrarium/containers/app@s2", "terrarium/containers/app@s1", "zfs-recursive-v1", true)).toEqual({
52+
skip: false,
53+
parentSnapshot: "terrarium/containers/app@s1",
54+
full: false
55+
});
56+
expect(planS3SnapshotExport("terrarium/containers/app@s2", "terrarium/containers/app@s2", "zfs-recursive-v1", true)).toEqual({
57+
skip: true,
58+
parentSnapshot: "",
59+
full: false
60+
});
61+
expect(planS3SnapshotExport("terrarium/containers/app@s2", "terrarium/containers/app@s1", "zfs-recursive-v1", false)).toEqual({
62+
skip: false,
63+
parentSnapshot: "",
64+
full: true
65+
});
66+
});
67+
3768
test("renames restored LXD metadata and removes generated identity", () => {
3869
const rewritten = rewriteRecoveredBackupMetadata(
3970
{

scripts/terrarium-s3-export.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import {
1414

1515
const PREFIX = "terrariumctl backup export";
1616
const DEFAULT_CONFIG_PATH = process.env.TERRARIUM_CONFIG_PATH ?? "/etc/terrarium/config.yaml";
17-
const STATE_DIR = "/var/lib/terrarium";
17+
const STATE_DIR = process.env.TERRARIUM_STATE_DIR ?? "/var/lib/terrarium";
18+
const S3_SNAPSHOT_STATE_FORMAT = "zfs-recursive-v1";
1819
const S3_EXPORT_ATTEMPTS = 4;
1920
const S3_EXPORT_RETRY_MS = 5000;
2021

@@ -65,6 +66,21 @@ export function zfsReplicationSendCommand(latestSnapshot: string, parentSnapshot
6566
return `zfs send -R ${shellEscape(latestSnapshot)}`;
6667
}
6768

69+
export function planS3SnapshotExport(
70+
latestSnapshot: string,
71+
lastSnapshot: string,
72+
stateFormat: string,
73+
parentSnapshotExists: boolean
74+
): { skip: boolean; parentSnapshot: string; full: boolean } {
75+
const recursiveState = stateFormat === S3_SNAPSHOT_STATE_FORMAT;
76+
if (recursiveState && lastSnapshot === latestSnapshot) {
77+
return { skip: true, parentSnapshot: "", full: false };
78+
}
79+
80+
const parentSnapshot = recursiveState && lastSnapshot && parentSnapshotExists ? lastSnapshot : "";
81+
return { skip: false, parentSnapshot, full: !parentSnapshot };
82+
}
83+
6884
export function isRetriableS3ExportError(message: string): boolean {
6985
const lowered = message.toLowerCase();
7086
return [
@@ -154,8 +170,15 @@ export async function backupExportCmd(configPath = DEFAULT_CONFIG_PATH): Promise
154170
}
155171

156172
const stateFile = join(STATE_DIR, "lastsnapshots", `${instance.name}.txt`);
173+
const stateFormatFile = join(STATE_DIR, "lastsnapshots", `${instance.name}.format`);
157174
const last = existsSync(stateFile) ? readFileSync(stateFile, "utf8").trim() : "";
158-
if (last === latest) {
175+
const stateFormat = existsSync(stateFormatFile) ? readFileSync(stateFormatFile, "utf8").trim() : "";
176+
const parentSnapshotExists =
177+
stateFormat === S3_SNAPSHOT_STATE_FORMAT && last
178+
? (await runAllowFailure(["zfs", "list", "-H", "-t", "snapshot", last])).exitCode === 0
179+
: false;
180+
const exportPlan = planS3SnapshotExport(latest, last, stateFormat, parentSnapshotExists);
181+
if (exportPlan.skip) {
159182
continue;
160183
}
161184

@@ -166,10 +189,7 @@ export async function backupExportCmd(configPath = DEFAULT_CONFIG_PATH): Promise
166189
const manifestPath = join(manifestDir, `${snapName}.json`);
167190
mkdirSync(manifestDir, { recursive: true });
168191

169-
const streamSource =
170-
last && (await runAllowFailure(["zfs", "list", "-H", "-t", "snapshot", last])).exitCode === 0
171-
? zfsReplicationSendCommand(latest, last)
172-
: zfsReplicationSendCommand(latest);
192+
const streamSource = zfsReplicationSendCommand(latest, exportPlan.parentSnapshot);
173193

174194
await runRetriableS3Command(
175195
`${streamSource} | zstd -T0 | ${awsBase.map(shellEscape).join(" ")} s3 cp - ${shellEscape(`s3://${bucket}/${objectKey}`)}`,
@@ -181,9 +201,9 @@ export async function backupExportCmd(configPath = DEFAULT_CONFIG_PATH): Promise
181201
instance: instance.name,
182202
dataset,
183203
snapshot: latest,
184-
parent_snapshot: last,
204+
parent_snapshot: exportPlan.parentSnapshot,
185205
object_key: objectKey,
186-
full: !last,
206+
full: exportPlan.full,
187207
created_at: new Date().toISOString()
188208
};
189209
writeJsonFile(manifestPath, manifest);
@@ -193,5 +213,6 @@ export async function backupExportCmd(configPath = DEFAULT_CONFIG_PATH): Promise
193213
`S3 manifest upload for ${instance.name}@${snapName}`
194214
);
195215
writeFileSync(stateFile, `${latest}\n`, "utf8");
216+
writeFileSync(stateFormatFile, `${S3_SNAPSHOT_STATE_FORMAT}\n`, "utf8");
196217
}
197218
}

0 commit comments

Comments
 (0)