@caelo-cms/provisioning 0.10.48 → 0.10.50
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/caddy/Caddyfile.production +5 -1
- package/caddy/Caddyfile.staging +5 -1
- package/dist/agent-db.js +102 -58
- package/dist/caddy-redirects.d.ts +15 -0
- package/dist/caddy-redirects.d.ts.map +1 -0
- package/dist/caddy-redirects.js +65 -0
- package/dist/caddy-redirects.js.map +1 -0
- package/dist/caddy.d.ts +22 -2
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +66 -17
- package/dist/caddy.js.map +1 -1
- package/dist/cli-routing.d.ts.map +1 -1
- package/dist/cli-routing.js +7 -0
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +248 -254
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +54 -2
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +86 -32
- package/dist/compose.js.map +1 -1
- package/dist/gcp-names.d.ts +12 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +21 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/gcp-url-map.d.ts +27 -0
- package/dist/gcp-url-map.d.ts.map +1 -0
- package/dist/gcp-url-map.js +62 -0
- package/dist/gcp-url-map.js.map +1 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +20 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +39 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/public-role-isolation.d.ts +19 -0
- package/dist/public-role-isolation.d.ts.map +1 -0
- package/dist/public-role-isolation.js +69 -0
- package/dist/public-role-isolation.js.map +1 -0
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +3 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/release-manifest.d.ts +2 -41
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +20 -122
- package/dist/release-manifest.js.map +1 -1
- package/dist/self-hosted-backup.d.ts +86 -0
- package/dist/self-hosted-backup.d.ts.map +1 -0
- package/dist/self-hosted-backup.js +350 -0
- package/dist/self-hosted-backup.js.map +1 -0
- package/dist/self-hosted-release.d.ts +12 -3
- package/dist/self-hosted-release.d.ts.map +1 -1
- package/dist/self-hosted-release.js +6 -4
- package/dist/self-hosted-release.js.map +1 -1
- package/dist/self-hosted-start.d.ts +50 -0
- package/dist/self-hosted-start.d.ts.map +1 -0
- package/dist/self-hosted-start.js +59 -0
- package/dist/self-hosted-start.js.map +1 -0
- package/dist/self-hosted-upgrade.d.ts +30 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +29 -7
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/tar-entries.d.ts +14 -0
- package/dist/tar-entries.d.ts.map +1 -0
- package/dist/tar-entries.js +104 -0
- package/dist/tar-entries.js.map +1 -0
- package/dist/upgrade/converge.d.ts +2 -2
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +44 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +14 -2
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +4 -2
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrader-assess.d.ts +4 -1
- package/dist/upgrader-assess.d.ts.map +1 -1
- package/dist/upgrader-assess.js +8 -1
- package/dist/upgrader-assess.js.map +1 -1
- package/dist/upgrader-job.d.ts +3 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js.map +1 -1
- package/dist/url-map-redirects-access.d.ts +15 -0
- package/dist/url-map-redirects-access.d.ts.map +1 -0
- package/dist/url-map-redirects-access.js +81 -0
- package/dist/url-map-redirects-access.js.map +1 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +3 -1
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +45 -0
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/admin-memory.test.ts +2 -2
- package/src/agent/run.test.ts +55 -0
- package/src/caddy-redirects.docker.test.ts +255 -0
- package/src/caddy-redirects.test.ts +115 -0
- package/src/caddy-redirects.ts +68 -0
- package/src/caddy-validate.test.ts +119 -0
- package/src/caddy.test.ts +108 -0
- package/src/caddy.ts +89 -20
- package/src/cli-routing.test.ts +12 -0
- package/src/cli-routing.ts +7 -0
- package/src/cli.ts +299 -287
- package/src/compose.ts +106 -34
- package/src/gcp-names.ts +23 -0
- package/src/gcp-url-map.test.ts +150 -0
- package/src/gcp-url-map.ts +81 -0
- package/src/index.test.ts +18 -146
- package/src/index.ts +0 -9
- package/src/infra-contract.test.ts +39 -0
- package/src/infra-contract.ts +42 -1
- package/src/public-role-isolation.test.ts +89 -0
- package/src/public-role-isolation.ts +83 -0
- package/src/release-contract.ts +3 -0
- package/src/release-manifest.test.ts +20 -1
- package/src/release-manifest.ts +39 -158
- package/src/self-hosted-backup.test.ts +344 -0
- package/src/self-hosted-backup.ts +480 -0
- package/src/self-hosted-compose.test.ts +88 -4
- package/src/self-hosted-release.ts +22 -7
- package/src/self-hosted-start.test.ts +68 -0
- package/src/self-hosted-start.ts +123 -0
- package/src/self-hosted-upgrade.ts +35 -8
- package/src/stack-contract.test.ts +2 -2
- package/src/tar-entries.test.ts +52 -0
- package/src/tar-entries.ts +116 -0
- package/src/upgrade/converge.ts +51 -4
- package/src/upgrade/release-images.ts +4 -2
- package/src/upgrade/upgrade.test.ts +78 -1
- package/src/upgrader-assess.test.ts +64 -1
- package/src/upgrader-assess.ts +20 -2
- package/src/upgrader-job.test.ts +3 -0
- package/src/upgrader-job.ts +3 -0
- package/src/url-map-redirects-access.test.ts +88 -0
- package/src/url-map-redirects-access.ts +89 -0
- package/src/wizard.ts +3 -1
- package/src/wizards/gcp.ts +48 -0
- package/stacks/aws/index.ts +34 -2
- package/stacks/azure/index.ts +13 -0
- package/stacks/gcp/index.ts +24 -17
- package/stacks/gcp-firebase/index.ts +7 -0
- package/stacks/self-hosted/Pulumi.yaml +2 -2
- package/stacks/self-hosted/README.md +3 -3
- package/stacks/self-hosted/index.ts +45 -22
- package/dist/redirects-emit.d.ts +0 -52
- package/dist/redirects-emit.d.ts.map +0 -1
- package/dist/redirects-emit.js +0 -111
- package/dist/redirects-emit.js.map +0 -1
- package/src/redirects-emit.ts +0 -175
package/src/release-manifest.ts
CHANGED
|
@@ -21,66 +21,40 @@
|
|
|
21
21
|
* says what to check next.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { createHash } from "node:crypto";
|
|
25
24
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
26
25
|
import { tmpdir } from "node:os";
|
|
27
26
|
import { join } from "node:path";
|
|
28
|
-
import { z } from "zod";
|
|
29
27
|
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
fetchReleaseArtifact,
|
|
29
|
+
isSecurityRelease,
|
|
30
|
+
parseReleaseManifest,
|
|
31
|
+
RELEASE_CHANNELS,
|
|
32
|
+
RELEASE_IMAGES,
|
|
33
|
+
type ReleaseChannel,
|
|
34
|
+
type ReleaseImage,
|
|
35
|
+
type ReleaseManifest,
|
|
36
|
+
} from "@caelo-cms/shared";
|
|
37
|
+
import { infraStepsText } from "./infra-contract.js";
|
|
38
|
+
import { RELEASE_OIDC_ISSUER, RELEASE_WORKFLOWS_URL } from "./upgrade/release-images.js";
|
|
39
|
+
|
|
40
|
+
// The manifest's shape and location live in @caelo-cms/shared (the admin's
|
|
41
|
+
// auto-update scheduler reads them too); re-exported so this module stays
|
|
42
|
+
// the CLI's one import for release manifests.
|
|
43
|
+
export {
|
|
44
|
+
RELEASE_CHANNELS,
|
|
45
|
+
RELEASE_IMAGES,
|
|
46
|
+
RELEASE_MANIFEST_ARTIFACT_TYPE,
|
|
47
|
+
RELEASE_MANIFEST_MEDIA_TYPE,
|
|
48
|
+
RELEASE_MANIFEST_REGISTRY,
|
|
49
|
+
RELEASE_MANIFEST_REPOSITORY,
|
|
50
|
+
type ReleaseChannel,
|
|
51
|
+
type ReleaseImage,
|
|
52
|
+
type ReleaseManifest,
|
|
53
|
+
ReleaseManifestSchema,
|
|
54
|
+
SIGSTORE_BUNDLE_MEDIA_TYPE,
|
|
55
|
+
} from "@caelo-cms/shared";
|
|
38
56
|
|
|
39
57
|
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
40
|
-
const GHSA_ID = /^GHSA(-[23456789cfghjmpqrvwx]{4}){3}$/;
|
|
41
|
-
|
|
42
|
-
/** The services a release ships images for. */
|
|
43
|
-
export const RELEASE_IMAGES = ["admin", "gateway", "ops"] as const;
|
|
44
|
-
export type ReleaseImage = (typeof RELEASE_IMAGES)[number];
|
|
45
|
-
|
|
46
|
-
const digest = z.string().regex(IMAGE_DIGEST, "expected sha256:<64 hex>");
|
|
47
|
-
|
|
48
|
-
/** Zod schema of `release-manifest.json` (manifestVersion 1). */
|
|
49
|
-
export const ReleaseManifestSchema = z.object({
|
|
50
|
-
manifestVersion: z.literal(1),
|
|
51
|
-
version: z.string().regex(SEMVER, "expected a SemVer version without a leading v"),
|
|
52
|
-
channel: z.enum(RELEASE_CHANNELS),
|
|
53
|
-
digests: z.object({ admin: digest, gateway: digest, ops: digest }),
|
|
54
|
-
minFromVersion: z.string().regex(SEMVER),
|
|
55
|
-
infraContractVersion: z.number().int().positive(),
|
|
56
|
-
migrations: z.object({
|
|
57
|
-
/** True when the release carries destructive (contract) migrations. */
|
|
58
|
-
contract: z.boolean(),
|
|
59
|
-
/**
|
|
60
|
-
* Newest release up to and including this one that carried contract
|
|
61
|
-
* migrations (null: none known). An install below it that jumps
|
|
62
|
-
* straight here would run those destructive steps while its old image
|
|
63
|
-
* still serves — so it is manual too, even when `contract` is false.
|
|
64
|
-
*/
|
|
65
|
-
lastContractVersion: z.string().regex(SEMVER).nullable(),
|
|
66
|
-
/** Newest cms_admin migration in the release, e.g. `0262_tool_approval_branch`. */
|
|
67
|
-
head: z.string().min(1),
|
|
68
|
-
}),
|
|
69
|
-
/** Set only via the release-cut input, backed by published GitHub Security Advisories. */
|
|
70
|
-
security: z.boolean(),
|
|
71
|
-
advisories: z.array(z.string().regex(GHSA_ID, "expected a GHSA id")),
|
|
72
|
-
});
|
|
73
|
-
export type ReleaseManifest = z.infer<typeof ReleaseManifestSchema>;
|
|
74
|
-
|
|
75
|
-
/** Registry the upgrader reads manifests from — the public AR mirror Cloud Run pulls from. */
|
|
76
|
-
export const RELEASE_MANIFEST_REGISTRY = "europe-west1-docker.pkg.dev";
|
|
77
|
-
/** Repository path of the manifest artifact inside {@link RELEASE_MANIFEST_REGISTRY}. */
|
|
78
|
-
export const RELEASE_MANIFEST_REPOSITORY = "caelo-website/caelo-cms-images/release-manifest";
|
|
79
|
-
|
|
80
|
-
/** OCI artifact type + layer media types of the pushed manifest. */
|
|
81
|
-
export const RELEASE_MANIFEST_ARTIFACT_TYPE = "application/vnd.caelo.release-manifest.v1";
|
|
82
|
-
export const RELEASE_MANIFEST_MEDIA_TYPE = "application/vnd.caelo.release-manifest.v1+json";
|
|
83
|
-
export const SIGSTORE_BUNDLE_MEDIA_TYPE = "application/vnd.dev.sigstore.bundle.v0.3+json";
|
|
84
58
|
|
|
85
59
|
// The signer constants are shared with the image verification (one source).
|
|
86
60
|
export { RELEASE_OIDC_ISSUER };
|
|
@@ -190,40 +164,10 @@ const defaultDeps: ReleaseManifestDeps = {
|
|
|
190
164
|
cosign: runCosign,
|
|
191
165
|
};
|
|
192
166
|
|
|
193
|
-
interface OciDescriptor {
|
|
194
|
-
readonly mediaType: string;
|
|
195
|
-
readonly digest: string;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const OciManifestSchema = z.object({
|
|
199
|
-
artifactType: z.string().optional(),
|
|
200
|
-
layers: z.array(z.object({ mediaType: z.string(), digest: z.string().regex(IMAGE_DIGEST) })),
|
|
201
|
-
});
|
|
202
|
-
|
|
203
167
|
function fail(code: ReleaseManifestErrorCode, message: string): ReleaseManifestResult {
|
|
204
168
|
return { ok: false, error: { code, message } };
|
|
205
169
|
}
|
|
206
170
|
|
|
207
|
-
async function fetchBlob(
|
|
208
|
-
deps: ReleaseManifestDeps,
|
|
209
|
-
base: string,
|
|
210
|
-
layer: OciDescriptor,
|
|
211
|
-
): Promise<Uint8Array | ReleaseManifestError> {
|
|
212
|
-
const res = await deps.fetch(`${base}/blobs/${layer.digest}`);
|
|
213
|
-
if (!res.ok) {
|
|
214
|
-
return { code: "registry", message: `blob ${layer.digest}: HTTP ${res.status}` };
|
|
215
|
-
}
|
|
216
|
-
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
217
|
-
const actual = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
218
|
-
if (actual !== layer.digest) {
|
|
219
|
-
return {
|
|
220
|
-
code: "blob-digest",
|
|
221
|
-
message: `blob content does not match its digest (${layer.digest} expected, ${actual} served) — the registry or a proxy altered it`,
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
return bytes;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
171
|
/**
|
|
228
172
|
* Fetch the manifest of `version` from the fixed registry, verify its
|
|
229
173
|
* Sigstore bundle against the release workflow identity for that exact
|
|
@@ -237,64 +181,16 @@ export async function fetchVerifiedReleaseManifest(
|
|
|
237
181
|
deps: ReleaseManifestDeps = defaultDeps,
|
|
238
182
|
): Promise<ReleaseManifestResult> {
|
|
239
183
|
if (!SEMVER.test(version)) return fail("schema", `"${version}" is not a SemVer version`);
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
let artifact: z.infer<typeof OciManifestSchema>;
|
|
243
|
-
try {
|
|
244
|
-
const res = await deps.fetch(`${base}/manifests/${version}`, {
|
|
245
|
-
headers: { Accept: "application/vnd.oci.image.manifest.v1+json" },
|
|
246
|
-
});
|
|
247
|
-
if (!res.ok) {
|
|
248
|
-
return fail(
|
|
249
|
-
"registry",
|
|
250
|
-
`no release manifest for v${version} at ${base} (HTTP ${res.status}). Releases before the signed manifest (epic #696) have none; otherwise check the version exists.`,
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
const parsed = OciManifestSchema.safeParse(await res.json());
|
|
254
|
-
if (!parsed.success)
|
|
255
|
-
return fail("artifact-shape", `not an OCI manifest: ${parsed.error.message}`);
|
|
256
|
-
artifact = parsed.data;
|
|
257
|
-
} catch (e) {
|
|
258
|
-
return fail("registry", `registry unreachable: ${e instanceof Error ? e.message : String(e)}`);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
if (artifact.artifactType !== RELEASE_MANIFEST_ARTIFACT_TYPE) {
|
|
262
|
-
return fail(
|
|
263
|
-
"artifact-shape",
|
|
264
|
-
`artifactType is ${artifact.artifactType ?? "(none)"}, expected ${RELEASE_MANIFEST_ARTIFACT_TYPE}`,
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
const manifestLayers = artifact.layers.filter((l) => l.mediaType === RELEASE_MANIFEST_MEDIA_TYPE);
|
|
268
|
-
const bundleLayers = artifact.layers.filter((l) => l.mediaType === SIGSTORE_BUNDLE_MEDIA_TYPE);
|
|
269
|
-
const [manifestLayer] = manifestLayers;
|
|
270
|
-
const [bundleLayer] = bundleLayers;
|
|
271
|
-
if (manifestLayers.length !== 1 || bundleLayers.length !== 1 || !manifestLayer || !bundleLayer) {
|
|
272
|
-
return fail(
|
|
273
|
-
"artifact-shape",
|
|
274
|
-
"the artifact must hold exactly one release-manifest layer and one Sigstore bundle layer",
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
let manifestBytes: Uint8Array;
|
|
279
|
-
let bundleBytes: Uint8Array;
|
|
280
|
-
try {
|
|
281
|
-
const m = await fetchBlob(deps, base, manifestLayer);
|
|
282
|
-
if (!(m instanceof Uint8Array)) return { ok: false, error: m };
|
|
283
|
-
const b = await fetchBlob(deps, base, bundleLayer);
|
|
284
|
-
if (!(b instanceof Uint8Array)) return { ok: false, error: b };
|
|
285
|
-
manifestBytes = m;
|
|
286
|
-
bundleBytes = b;
|
|
287
|
-
} catch (e) {
|
|
288
|
-
return fail("registry", `registry unreachable: ${e instanceof Error ? e.message : String(e)}`);
|
|
289
|
-
}
|
|
184
|
+
const artifact = await fetchReleaseArtifact(version, deps);
|
|
185
|
+
if (!artifact.ok) return { ok: false, error: artifact.error };
|
|
290
186
|
|
|
291
187
|
// cosign verifies files, so the exact bytes go to a private temp dir.
|
|
292
188
|
const dir = await mkdtemp(join(tmpdir(), "caelo-release-manifest-"));
|
|
293
189
|
try {
|
|
294
190
|
const manifestPath = join(dir, "release-manifest.json");
|
|
295
191
|
const bundlePath = join(dir, "release-manifest.json.sigstore.json");
|
|
296
|
-
await writeFile(manifestPath, manifestBytes, { mode: 0o600 });
|
|
297
|
-
await writeFile(bundlePath, bundleBytes, { mode: 0o600 });
|
|
192
|
+
await writeFile(manifestPath, artifact.manifestBytes, { mode: 0o600 });
|
|
193
|
+
await writeFile(bundlePath, artifact.bundleBytes, { mode: 0o600 });
|
|
298
194
|
const verify = await deps.cosign([
|
|
299
195
|
"verify-blob",
|
|
300
196
|
"--bundle",
|
|
@@ -315,26 +211,9 @@ export async function fetchVerifiedReleaseManifest(
|
|
|
315
211
|
await rm(dir, { recursive: true, force: true });
|
|
316
212
|
}
|
|
317
213
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
} catch {
|
|
322
|
-
return fail("schema", "the signed manifest is not valid JSON");
|
|
323
|
-
}
|
|
324
|
-
const parsed = ReleaseManifestSchema.safeParse(json);
|
|
325
|
-
if (!parsed.success) {
|
|
326
|
-
return fail(
|
|
327
|
-
"schema",
|
|
328
|
-
`the signed manifest does not match manifestVersion 1 — this CLI may be too old to read it: ${parsed.error.message}`,
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
if (parsed.data.version !== version) {
|
|
332
|
-
return fail(
|
|
333
|
-
"version-mismatch",
|
|
334
|
-
`asked for v${version} but the signed manifest describes v${parsed.data.version}`,
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
|
-
return { ok: true, manifest: parsed.data };
|
|
214
|
+
const parsed = parseReleaseManifest(artifact.manifestBytes, version);
|
|
215
|
+
if (!parsed.ok) return fail(parsed.code, `signed manifest: ${parsed.message}`);
|
|
216
|
+
return { ok: true, manifest: parsed.manifest };
|
|
338
217
|
}
|
|
339
218
|
|
|
340
219
|
/** One image whose resolved digest differs from the manifest's. */
|
|
@@ -414,7 +293,7 @@ export function assessAutoInstall(
|
|
|
414
293
|
const manual: string[] = [];
|
|
415
294
|
if (manifest.infraContractVersion > installed.infraContractVersion) {
|
|
416
295
|
manual.push(
|
|
417
|
-
`the release changes the infrastructure contract (${installed.infraContractVersion} → ${manifest.infraContractVersion}); it must run with the operator's own identity`,
|
|
296
|
+
`the release changes the infrastructure contract (${installed.infraContractVersion} → ${manifest.infraContractVersion}: ${infraStepsText(installed.infraContractVersion, manifest.infraContractVersion)}); it must run with the operator's own identity`,
|
|
418
297
|
);
|
|
419
298
|
}
|
|
420
299
|
const lastContract = manifest.migrations.lastContractVersion;
|
|
@@ -426,5 +305,7 @@ export function assessAutoInstall(
|
|
|
426
305
|
);
|
|
427
306
|
}
|
|
428
307
|
if (manual.length > 0) return { mode: "manual", kind: "normal", reasons: manual };
|
|
429
|
-
|
|
308
|
+
// Security counts only with a published advisory behind it (#696
|
|
309
|
+
// decision 2); the release workflow already refuses the flag without one.
|
|
310
|
+
return { mode: "auto", kind: isSecurityRelease(manifest) ? "security" : "normal", reasons: [] };
|
|
430
311
|
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
statSync,
|
|
11
|
+
symlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { COMPOSE_APP_SERVICES } from "./compose.js";
|
|
17
|
+
import {
|
|
18
|
+
BACKUP_FORMAT,
|
|
19
|
+
backupSelfHosted,
|
|
20
|
+
checkArchive,
|
|
21
|
+
checkArchiveMembers,
|
|
22
|
+
checkFilesTar,
|
|
23
|
+
restoreSelfHosted,
|
|
24
|
+
} from "./self-hosted-backup.js";
|
|
25
|
+
import {
|
|
26
|
+
bunHostRunner,
|
|
27
|
+
type CommandResult,
|
|
28
|
+
type HostRunner,
|
|
29
|
+
type HostRunOptions,
|
|
30
|
+
} from "./self-hosted-release.js";
|
|
31
|
+
|
|
32
|
+
let caeloDir: string;
|
|
33
|
+
let scratch: string;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
scratch = mkdtempSync(join(tmpdir(), "caelo-backup-test-"));
|
|
37
|
+
caeloDir = join(scratch, ".caelo");
|
|
38
|
+
mkdirSync(caeloDir);
|
|
39
|
+
});
|
|
40
|
+
afterEach(() => rmSync(scratch, { recursive: true, force: true }));
|
|
41
|
+
|
|
42
|
+
/** Like the admin container's GNU tar: no macOS AppleDouble members. */
|
|
43
|
+
const NO_MAC = { env: { COPYFILE_DISABLE: "1" } };
|
|
44
|
+
|
|
45
|
+
interface Call {
|
|
46
|
+
readonly cmd: string;
|
|
47
|
+
readonly opts?: HostRunOptions;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A real tar of `files` (path → content, `->target` makes a symlink), built in `scratch`. */
|
|
51
|
+
async function makeTar(files: Record<string, string>, out: string, gzip = false): Promise<void> {
|
|
52
|
+
const root = mkdtempSync(join(scratch, "tar-src-"));
|
|
53
|
+
for (const [path, content] of Object.entries(files)) {
|
|
54
|
+
const full = join(root, path);
|
|
55
|
+
mkdirSync(join(full, ".."), { recursive: true });
|
|
56
|
+
if (content.startsWith("->")) symlinkSync(content.slice(2), full);
|
|
57
|
+
else writeFileSync(full, content);
|
|
58
|
+
}
|
|
59
|
+
const r = await bunHostRunner.run(["tar", gzip ? "-czf" : "-cf", out, "-C", root, "."], NO_MAC);
|
|
60
|
+
if (r.exitCode !== 0) throw new Error(r.stderr);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A files.tar like the admin container writes (members `srv/caelo/…`, `data/media/…`). */
|
|
64
|
+
async function contentTar(out: string): Promise<void> {
|
|
65
|
+
const root = mkdtempSync(join(scratch, "content-"));
|
|
66
|
+
mkdirSync(join(root, "srv/caelo/output/production/current"), { recursive: true });
|
|
67
|
+
mkdirSync(join(root, "data/media"), { recursive: true });
|
|
68
|
+
writeFileSync(join(root, "srv/caelo/output/production/current/index.html"), "<h1>home</h1>");
|
|
69
|
+
writeFileSync(join(root, "data/media/logo.png"), "png");
|
|
70
|
+
const r = await bunHostRunner.run(
|
|
71
|
+
["tar", "-cf", out, "-C", root, "srv/caelo", "data/media"],
|
|
72
|
+
NO_MAC,
|
|
73
|
+
);
|
|
74
|
+
if (r.exitCode !== 0) throw new Error(r.stderr);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Docker is faked (every call recorded, dumps written as stand-in bytes,
|
|
79
|
+
* the file archive as a real tar); host `tar` runs for real, so the archive
|
|
80
|
+
* round-trip and its checks are the real ones.
|
|
81
|
+
*/
|
|
82
|
+
function runner(fail?: (cmd: string) => boolean): HostRunner & { calls: Call[] } {
|
|
83
|
+
const calls: Call[] = [];
|
|
84
|
+
return {
|
|
85
|
+
calls,
|
|
86
|
+
async run(argv, opts): Promise<CommandResult> {
|
|
87
|
+
if (argv[0] === "tar") return bunHostRunner.run(argv, opts);
|
|
88
|
+
const cmd = argv.join(" ").replace(`docker compose -f ${caeloDir}/docker-compose.yml `, "");
|
|
89
|
+
calls.push({ cmd, ...(opts ? { opts } : {}) });
|
|
90
|
+
if (fail?.(cmd)) return { exitCode: 1, stdout: "", stderr: "boom" };
|
|
91
|
+
if (opts?.stdoutPath) {
|
|
92
|
+
if (cmd.includes("--entrypoint tar")) await contentTar(opts.stdoutPath);
|
|
93
|
+
else writeFileSync(opts.stdoutPath, `stand-in for: ${cmd}`);
|
|
94
|
+
}
|
|
95
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const AT = new Date("2026-10-14T12:00:00.000Z");
|
|
101
|
+
const quiet = { log: () => {}, now: () => AT };
|
|
102
|
+
const probe = (cmds: string[]) => cmds.map((c) => c.replace(/ -e .*/, " -e <probe>"));
|
|
103
|
+
|
|
104
|
+
describe("backupSelfHosted", () => {
|
|
105
|
+
it("quiesces the services, dumps, archives site + media, writes one mode-600 .tar.gz, restarts", async () => {
|
|
106
|
+
const r = runner();
|
|
107
|
+
const to = join(scratch, "b.tar.gz");
|
|
108
|
+
const out = await backupSelfHosted(to, "0.10.49", { caeloDir, runner: r, ...quiet });
|
|
109
|
+
expect(out).toEqual({ ok: true, path: to });
|
|
110
|
+
expect(probe(r.calls.map((c) => c.cmd))).toEqual([
|
|
111
|
+
"exec -T postgres pg_isready -U caelo",
|
|
112
|
+
"stop caelo-admin caelo-gateway",
|
|
113
|
+
"exec -T postgres pg_dumpall -U caelo --globals-only",
|
|
114
|
+
"exec -T postgres pg_dump -U caelo -Fc cms_admin",
|
|
115
|
+
"exec -T postgres pg_dump -U caelo -Fc cms_public",
|
|
116
|
+
"run --rm --no-deps -T --entrypoint tar caelo-admin -C / -cf - srv/caelo data/media",
|
|
117
|
+
"up -d caelo-admin caelo-gateway",
|
|
118
|
+
"exec -T caelo-admin bun -e <probe>",
|
|
119
|
+
"exec -T caelo-gateway bun -e <probe>",
|
|
120
|
+
]);
|
|
121
|
+
expect(statSync(to).mode & 0o777).toBe(0o600);
|
|
122
|
+
// The scratch dir is gone; only the archive remains.
|
|
123
|
+
expect(readdirSync(join(caeloDir, "backups"))).toEqual([]);
|
|
124
|
+
expect(checkArchiveMembers(to)).toEqual({ ok: true });
|
|
125
|
+
|
|
126
|
+
const unpacked = join(scratch, "unpacked");
|
|
127
|
+
mkdirSync(unpacked);
|
|
128
|
+
await bunHostRunner.run(["tar", "-xzf", to, "-C", unpacked]);
|
|
129
|
+
const checked = checkArchive(unpacked);
|
|
130
|
+
expect(checked.ok).toBe(true);
|
|
131
|
+
if (checked.ok) {
|
|
132
|
+
expect(checked.manifest).toEqual({
|
|
133
|
+
format: BACKUP_FORMAT,
|
|
134
|
+
caeloVersion: "0.10.49",
|
|
135
|
+
createdAt: AT.toISOString(),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
expect(existsSync(join(unpacked, "db", "globals.sql"))).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("starts the services again when the backup itself fails", async () => {
|
|
142
|
+
const r = runner((c) => c.includes("pg_dump -U caelo -Fc cms_public"));
|
|
143
|
+
const out = await backupSelfHosted(join(scratch, "b.tar.gz"), "0.10.49", {
|
|
144
|
+
caeloDir,
|
|
145
|
+
runner: r,
|
|
146
|
+
...quiet,
|
|
147
|
+
});
|
|
148
|
+
expect(out.ok).toBe(false);
|
|
149
|
+
expect(r.calls.map((c) => c.cmd)).toContain("up -d caelo-admin caelo-gateway");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("refuses when Postgres is not running and changes nothing", async () => {
|
|
153
|
+
const r = runner((c) => c.includes("pg_isready"));
|
|
154
|
+
const to = join(scratch, "b.tar.gz");
|
|
155
|
+
const out = await backupSelfHosted(to, "0.10.49", { caeloDir, runner: r, ...quiet });
|
|
156
|
+
expect(out.ok).toBe(false);
|
|
157
|
+
if (!out.ok) expect(out.error).toContain("Postgres is not running");
|
|
158
|
+
expect(existsSync(to)).toBe(false);
|
|
159
|
+
expect(r.calls).toHaveLength(1);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/** A real backup archive made by backupSelfHosted with the fake docker. */
|
|
164
|
+
async function makeArchive(): Promise<string> {
|
|
165
|
+
const to = join(scratch, "b.tar.gz");
|
|
166
|
+
const made = await backupSelfHosted(to, "0.10.49", { caeloDir, runner: runner(), ...quiet });
|
|
167
|
+
if (!made.ok) throw new Error(made.error);
|
|
168
|
+
return to;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function restoreDeps(r: HostRunner & { calls: Call[] }) {
|
|
172
|
+
return {
|
|
173
|
+
caeloDir,
|
|
174
|
+
runner: r,
|
|
175
|
+
...quiet,
|
|
176
|
+
convergeRoles: async () => {
|
|
177
|
+
r.calls.push({ cmd: "<converge roles>" });
|
|
178
|
+
return { ok: true as const };
|
|
179
|
+
},
|
|
180
|
+
refreshCaddy: async () => {
|
|
181
|
+
r.calls.push({ cmd: "<re-render + reload Caddyfile>" });
|
|
182
|
+
return { ok: true as const };
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const MIGRATE =
|
|
188
|
+
"run --rm --no-deps -T caelo-admin bun --bun /app/packages/migrations/src/migrate.ts";
|
|
189
|
+
|
|
190
|
+
describe("restoreSelfHosted", () => {
|
|
191
|
+
it("restores in order: stop, re-create + restore the DBs, migrate, files, start, health, Caddy", async () => {
|
|
192
|
+
const archive = await makeArchive();
|
|
193
|
+
const r = runner();
|
|
194
|
+
const out = await restoreSelfHosted(archive, restoreDeps(r));
|
|
195
|
+
expect(out.ok).toBe(true);
|
|
196
|
+
expect(probe(r.calls.map((c) => c.cmd))).toEqual([
|
|
197
|
+
"stop caelo-admin caelo-gateway",
|
|
198
|
+
"up -d --wait postgres",
|
|
199
|
+
"exec -T postgres psql -U caelo -d postgres -v ON_ERROR_STOP=1 -c DROP DATABASE IF EXISTS cms_admin WITH (FORCE) -c DROP DATABASE IF EXISTS cms_public WITH (FORCE)",
|
|
200
|
+
"<converge roles>",
|
|
201
|
+
"exec -T postgres pg_restore -U caelo -d cms_admin --exit-on-error --single-transaction",
|
|
202
|
+
"exec -T postgres pg_restore -U caelo -d cms_public --exit-on-error --single-transaction",
|
|
203
|
+
"<converge roles>",
|
|
204
|
+
`${MIGRATE} admin`,
|
|
205
|
+
`${MIGRATE} public`,
|
|
206
|
+
"run --rm --no-deps -T --entrypoint sh caelo-admin -c set -e; find /srv/caelo /data/media -mindepth 1 -delete; tar -C / -xf -",
|
|
207
|
+
"up -d --remove-orphans",
|
|
208
|
+
"exec -T caelo-admin bun -e <probe>",
|
|
209
|
+
"exec -T caelo-gateway bun -e <probe>",
|
|
210
|
+
"<re-render + reload Caddyfile>",
|
|
211
|
+
]);
|
|
212
|
+
// pg_restore and the file restore read the archive's parts on stdin.
|
|
213
|
+
const stdin = r.calls.filter((c) => c.opts?.stdinPath).map((c) => c.opts?.stdinPath ?? "");
|
|
214
|
+
expect(stdin.map((p) => p.split("/").slice(-2).join("/"))).toEqual([
|
|
215
|
+
"db/cms_admin.dump",
|
|
216
|
+
"db/cms_public.dump",
|
|
217
|
+
expect.stringMatching(/files\.tar$/),
|
|
218
|
+
]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("stops only services the Compose file defines — no orchestrator / runner (#707)", async () => {
|
|
222
|
+
const archive = await makeArchive();
|
|
223
|
+
const r = runner();
|
|
224
|
+
await restoreSelfHosted(archive, restoreDeps(r));
|
|
225
|
+
const stop = r.calls.find((c) => c.cmd.startsWith("stop "));
|
|
226
|
+
expect(stop?.cmd).toBe(`stop ${COMPOSE_APP_SERVICES.join(" ")}`);
|
|
227
|
+
expect(r.calls.some((c) => /orchestrator|runner/.test(c.cmd))).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("leaves the install untouched when the archive is incomplete", async () => {
|
|
231
|
+
const archive = join(scratch, "partial.tar.gz");
|
|
232
|
+
await makeTar(
|
|
233
|
+
{
|
|
234
|
+
"manifest.json": JSON.stringify({
|
|
235
|
+
format: BACKUP_FORMAT,
|
|
236
|
+
caeloVersion: "x",
|
|
237
|
+
createdAt: "y",
|
|
238
|
+
}),
|
|
239
|
+
"db/cms_admin.dump": "x",
|
|
240
|
+
},
|
|
241
|
+
archive,
|
|
242
|
+
true,
|
|
243
|
+
);
|
|
244
|
+
const r = runner();
|
|
245
|
+
const out = await restoreSelfHosted(archive, restoreDeps(r));
|
|
246
|
+
expect(out).toEqual({
|
|
247
|
+
ok: false,
|
|
248
|
+
installChanged: false,
|
|
249
|
+
error:
|
|
250
|
+
"The archive is incomplete: db/cms_public.dump is missing or empty. Nothing on the install was changed.",
|
|
251
|
+
});
|
|
252
|
+
expect(r.calls).toEqual([]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("reports a failed pg_restore as a partial restore", async () => {
|
|
256
|
+
const archive = await makeArchive();
|
|
257
|
+
const out = await restoreSelfHosted(
|
|
258
|
+
archive,
|
|
259
|
+
restoreDeps(runner((c) => c.includes("pg_restore -U caelo -d cms_public"))),
|
|
260
|
+
);
|
|
261
|
+
expect(out.ok).toBe(false);
|
|
262
|
+
if (!out.ok) {
|
|
263
|
+
expect(out.installChanged).toBe(true);
|
|
264
|
+
expect(out.error).toContain("pg_restore of cms_public failed");
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("archive checks run before anything is extracted (PR #755 review)", () => {
|
|
270
|
+
it("refuses an archive with a member a backup never writes, and extracts nothing", async () => {
|
|
271
|
+
const archive = join(scratch, "evil.tar.gz");
|
|
272
|
+
await makeTar(
|
|
273
|
+
{
|
|
274
|
+
"manifest.json": JSON.stringify({
|
|
275
|
+
format: BACKUP_FORMAT,
|
|
276
|
+
caeloVersion: "x",
|
|
277
|
+
createdAt: "y",
|
|
278
|
+
}),
|
|
279
|
+
"db/cms_admin.dump": "x",
|
|
280
|
+
"db/cms_public.dump": "x",
|
|
281
|
+
"files.tar": "x",
|
|
282
|
+
"etc/cron.d/backdoor": "* * * * * root sh",
|
|
283
|
+
},
|
|
284
|
+
archive,
|
|
285
|
+
true,
|
|
286
|
+
);
|
|
287
|
+
const members = checkArchiveMembers(archive);
|
|
288
|
+
expect(members.ok).toBe(false);
|
|
289
|
+
if (!members.ok) expect(members.error).toMatch(/unexpected member "\.\/etc/);
|
|
290
|
+
const r = runner();
|
|
291
|
+
const out = await restoreSelfHosted(archive, restoreDeps(r));
|
|
292
|
+
expect(out.ok).toBe(false);
|
|
293
|
+
expect(r.calls).toEqual([]);
|
|
294
|
+
expect(existsSync(join(caeloDir, "backups"))).toBe(true);
|
|
295
|
+
expect(readdirSync(join(caeloDir, "backups"))).toEqual([]);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("refuses a symlink where a backup writes a file", async () => {
|
|
299
|
+
const archive = join(scratch, "link.tar.gz");
|
|
300
|
+
await makeTar({ "manifest.json": "->/etc/passwd" }, archive, true);
|
|
301
|
+
const r = checkArchiveMembers(archive);
|
|
302
|
+
expect(r.ok).toBe(false);
|
|
303
|
+
if (!r.ok) expect(r.error).toContain("is a symlink");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("files.tar: only the site and media trees, no member below a symlink", async () => {
|
|
307
|
+
const ok = join(scratch, "ok.tar");
|
|
308
|
+
await contentTar(ok);
|
|
309
|
+
expect(checkFilesTar(ok)).toEqual({ ok: true });
|
|
310
|
+
|
|
311
|
+
const outside = join(scratch, "outside.tar");
|
|
312
|
+
await makeTar({ "app/server.js": "evil" }, outside);
|
|
313
|
+
const o = checkFilesTar(outside);
|
|
314
|
+
expect(o.ok).toBe(false);
|
|
315
|
+
if (!o.ok) expect(o.error).toContain("outside the site and media directories");
|
|
316
|
+
|
|
317
|
+
const viaLink = join(scratch, "via-link.tar");
|
|
318
|
+
const root = mkdtempSync(join(scratch, "via-"));
|
|
319
|
+
mkdirSync(join(root, "srv/caelo"), { recursive: true });
|
|
320
|
+
symlinkSync("/app", join(root, "srv/caelo/x"));
|
|
321
|
+
await bunHostRunner.run(["tar", "-cf", viaLink, "-C", root, "srv/caelo"], NO_MAC);
|
|
322
|
+
const l = checkFilesTar(viaLink);
|
|
323
|
+
expect(l.ok).toBe(false);
|
|
324
|
+
if (!l.ok) expect(l.error).toContain("links outside its directory");
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe("checkArchive", () => {
|
|
329
|
+
it("refuses a directory that is not a Caelo backup, and another format", () => {
|
|
330
|
+
const dir = join(scratch, "x");
|
|
331
|
+
mkdirSync(dir);
|
|
332
|
+
expect(checkArchive(dir)).toEqual({
|
|
333
|
+
ok: false,
|
|
334
|
+
error: "The archive has no manifest.json — it is not a Caelo backup.",
|
|
335
|
+
});
|
|
336
|
+
writeFileSync(
|
|
337
|
+
join(dir, "manifest.json"),
|
|
338
|
+
JSON.stringify({ format: 99, caeloVersion: "9.9.9", createdAt: "" }),
|
|
339
|
+
);
|
|
340
|
+
const r = checkArchive(dir);
|
|
341
|
+
expect(r.ok).toBe(false);
|
|
342
|
+
if (!r.ok) expect(r.error).toContain("format 99");
|
|
343
|
+
});
|
|
344
|
+
});
|