@caelo-cms/provisioning 0.10.47 → 0.10.48
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/apply.d.ts +45 -0
- package/dist/agent/apply.d.ts.map +1 -0
- package/dist/agent/apply.js +83 -0
- package/dist/agent/apply.js.map +1 -0
- package/dist/agent/cli-provenance.d.ts +42 -0
- package/dist/agent/cli-provenance.d.ts.map +1 -0
- package/dist/agent/cli-provenance.js +138 -0
- package/dist/agent/cli-provenance.js.map +1 -0
- package/dist/agent/command.d.ts +79 -0
- package/dist/agent/command.d.ts.map +1 -0
- package/dist/agent/command.js +454 -0
- package/dist/agent/command.js.map +1 -0
- package/dist/agent/config.d.ts +30 -0
- package/dist/agent/config.d.ts.map +1 -0
- package/dist/agent/config.js +60 -0
- package/dist/agent/config.js.map +1 -0
- package/dist/agent/lock.d.ts +13 -0
- package/dist/agent/lock.d.ts.map +1 -0
- package/dist/agent/lock.js +100 -0
- package/dist/agent/lock.js.map +1 -0
- package/dist/agent/queue.d.ts +35 -0
- package/dist/agent/queue.d.ts.map +1 -0
- package/dist/agent/queue.js +50 -0
- package/dist/agent/queue.js.map +1 -0
- package/dist/agent/run.d.ts +97 -0
- package/dist/agent/run.d.ts.map +1 -0
- package/dist/agent/run.js +198 -0
- package/dist/agent/run.js.map +1 -0
- package/dist/agent/systemd.d.ts +55 -0
- package/dist/agent/systemd.d.ts.map +1 -0
- package/dist/agent/systemd.js +171 -0
- package/dist/agent/systemd.js.map +1 -0
- package/dist/agent/units.d.ts +44 -0
- package/dist/agent/units.d.ts.map +1 -0
- package/dist/agent/units.js +127 -0
- package/dist/agent/units.js.map +1 -0
- package/dist/agent-db.js +18362 -0
- package/dist/cli.js +119 -16
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-iam-user.d.ts +39 -0
- package/dist/cloudsql-iam-user.d.ts.map +1 -0
- package/dist/cloudsql-iam-user.js +119 -0
- package/dist/cloudsql-iam-user.js.map +1 -0
- package/dist/compose.d.ts +16 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +26 -1
- package/dist/compose.js.map +1 -1
- package/dist/dns/index.d.ts +2 -0
- package/dist/dns/index.d.ts.map +1 -1
- package/dist/dns/index.js +3 -2
- package/dist/dns/index.js.map +1 -1
- package/dist/dns/manual.d.ts +5 -0
- package/dist/dns/manual.d.ts.map +1 -1
- package/dist/dns/manual.js +28 -12
- package/dist/dns/manual.js.map +1 -1
- package/dist/gcloud.d.ts +2 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +1 -1
- package/dist/gcloud.js.map +1 -1
- package/dist/gcp-names.d.ts +15 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +23 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.js +7 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job-runner.d.ts +13 -0
- package/dist/install-job-runner.d.ts.map +1 -0
- package/dist/install-job-runner.js +155 -0
- package/dist/install-job-runner.js.map +1 -0
- package/dist/install-job.d.ts +210 -0
- package/dist/install-job.d.ts.map +1 -0
- package/dist/install-job.js +449 -0
- package/dist/install-job.js.map +1 -0
- package/dist/install-state.d.ts +10 -1
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +10 -2
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +19 -4
- package/dist/lifecycle.js.map +1 -1
- package/dist/migration-runner.d.ts +5 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +1 -1
- package/dist/migration-runner.js.map +1 -1
- package/dist/operator-access.d.ts +14 -8
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +16 -102
- package/dist/operator-access.js.map +1 -1
- package/dist/run-mode.d.ts +27 -0
- package/dist/run-mode.d.ts.map +1 -0
- package/dist/run-mode.js +22 -0
- package/dist/run-mode.js.map +1 -0
- package/dist/runtime-secrets.d.ts +19 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +44 -0
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +22 -1
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +81 -12
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +17 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +16 -1
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +6 -0
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +10 -1
- package/dist/stack-contract.js.map +1 -1
- package/dist/upgrade/converge.d.ts +6 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +59 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +6 -0
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +25 -0
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +12 -0
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +31 -0
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +5 -0
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +28 -1
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +82 -0
- package/dist/upgrader-assess.d.ts.map +1 -0
- package/dist/upgrader-assess.js +116 -0
- package/dist/upgrader-assess.js.map +1 -0
- package/dist/upgrader-gcp.d.ts +17 -0
- package/dist/upgrader-gcp.d.ts.map +1 -0
- package/dist/upgrader-gcp.js +87 -0
- package/dist/upgrader-gcp.js.map +1 -0
- package/dist/upgrader-job.d.ts +131 -0
- package/dist/upgrader-job.d.ts.map +1 -0
- package/dist/upgrader-job.js +259 -0
- package/dist/upgrader-job.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +35 -0
- package/dist/upgrader-reporter.d.ts.map +1 -0
- package/dist/upgrader-reporter.js +79 -0
- package/dist/upgrader-reporter.js.map +1 -0
- package/dist/upgrader-setup.d.ts +59 -0
- package/dist/upgrader-setup.d.ts.map +1 -0
- package/dist/upgrader-setup.js +308 -0
- package/dist/upgrader-setup.js.map +1 -0
- package/dist/wizard.d.ts +6 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +32 -4
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-firebase.d.ts +3 -0
- package/dist/wizards/gcp-firebase.d.ts.map +1 -1
- package/dist/wizards/gcp-firebase.js +1 -0
- package/dist/wizards/gcp-firebase.js.map +1 -1
- package/dist/wizards/gcp-install-job.d.ts +74 -0
- package/dist/wizards/gcp-install-job.d.ts.map +1 -0
- package/dist/wizards/gcp-install-job.js +398 -0
- package/dist/wizards/gcp-install-job.js.map +1 -0
- package/dist/wizards/gcp-prompts.d.ts +3 -0
- package/dist/wizards/gcp-prompts.d.ts.map +1 -0
- package/dist/wizards/gcp-prompts.js +25 -0
- package/dist/wizards/gcp-prompts.js.map +1 -0
- package/dist/wizards/gcp-pulumi.d.ts +4 -2
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +1 -1
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts +40 -0
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +223 -59
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +6 -4
- package/src/adapter.ts +97 -0
- package/src/adc-impersonation.ts +176 -0
- package/src/admin-domain.test.ts +254 -0
- package/src/admin-domain.ts +245 -0
- package/src/admin-memory.test.ts +151 -0
- package/src/agent/apply.test.ts +185 -0
- package/src/agent/apply.ts +124 -0
- package/src/agent/cli-provenance.test.ts +159 -0
- package/src/agent/cli-provenance.ts +191 -0
- package/src/agent/command.test.ts +77 -0
- package/src/agent/command.ts +564 -0
- package/src/agent/config.ts +86 -0
- package/src/agent/lock-config.test.ts +97 -0
- package/src/agent/lock.ts +110 -0
- package/src/agent/queue.test.ts +10 -0
- package/src/agent/queue.ts +91 -0
- package/src/agent/run.test.ts +382 -0
- package/src/agent/run.ts +295 -0
- package/src/agent/systemd.ts +209 -0
- package/src/agent/units.test.ts +223 -0
- package/src/agent/units.ts +163 -0
- package/src/azure-front-door.test.ts +119 -0
- package/src/azure-front-door.ts +56 -0
- package/src/bootstrap-token.ts +20 -0
- package/src/caddy.ts +112 -0
- package/src/cdn-copy.ts +91 -0
- package/src/cert-wait.test.ts +194 -0
- package/src/cert-wait.ts +163 -0
- package/src/checkpoint-secrets.test.ts +380 -0
- package/src/checkpoint-secrets.ts +283 -0
- package/src/cli-routing.test.ts +45 -0
- package/src/cli-routing.ts +43 -0
- package/src/cli.ts +1491 -0
- package/src/cloudsql-iam-user.ts +147 -0
- package/src/compose.ts +334 -0
- package/src/default-firewall.test.ts +411 -0
- package/src/default-firewall.ts +342 -0
- package/src/deployed-release.test.ts +231 -0
- package/src/deployed-release.ts +220 -0
- package/src/dns/cloudflare.ts +182 -0
- package/src/dns/groups.test.ts +76 -0
- package/src/dns/groups.ts +86 -0
- package/src/dns/index.ts +56 -0
- package/src/dns/manual.ts +130 -0
- package/src/dns/types.ts +27 -0
- package/src/gateway-credentials.test.ts +180 -0
- package/src/gateway-credentials.ts +144 -0
- package/src/gcloud-components.test.ts +76 -0
- package/src/gcloud-components.ts +85 -0
- package/src/gcloud-retry.ts +41 -0
- package/src/gcloud-runner.test.ts +61 -0
- package/src/gcloud.test.ts +99 -0
- package/src/gcloud.ts +322 -0
- package/src/gcp-names.ts +135 -0
- package/src/index.test.ts +332 -0
- package/src/index.ts +114 -0
- package/src/infra-contract.ts +32 -0
- package/src/install-job-runner.ts +166 -0
- package/src/install-job.test.ts +566 -0
- package/src/install-job.ts +542 -0
- package/src/install-state.test.ts +239 -0
- package/src/install-state.ts +583 -0
- package/src/internal-token.ts +35 -0
- package/src/lifecycle.ts +900 -0
- package/src/mcp-iap-stacks.test.ts +58 -0
- package/src/mcp-iap.test.ts +149 -0
- package/src/mcp-iap.ts +131 -0
- package/src/media-volume.test.ts +296 -0
- package/src/migration-runner.ts +365 -0
- package/src/operator-access-stacks.test.ts +55 -0
- package/src/operator-access.test.ts +332 -0
- package/src/operator-access.ts +554 -0
- package/src/ops-backend.test.ts +320 -0
- package/src/ops-backend.ts +414 -0
- package/src/provisioner-identity.test.ts +482 -0
- package/src/provisioner-identity.ts +407 -0
- package/src/redirects-emit.ts +175 -0
- package/src/region-data.ts +428 -0
- package/src/regions.test.ts +277 -0
- package/src/regions.ts +370 -0
- package/src/release-contract.ts +57 -0
- package/src/release-manifest.test.ts +293 -0
- package/src/release-manifest.ts +430 -0
- package/src/run-mode.ts +40 -0
- package/src/runtime-secrets.test.ts +627 -0
- package/src/runtime-secrets.ts +754 -0
- package/src/self-command.test.ts +14 -0
- package/src/self-command.ts +21 -0
- package/src/self-hosted-compose.test.ts +177 -0
- package/src/self-hosted-release.ts +240 -0
- package/src/self-hosted-roles.integration.test.ts +161 -0
- package/src/self-hosted-roles.test.ts +124 -0
- package/src/self-hosted-roles.ts +203 -0
- package/src/self-hosted-upgrade.test.ts +394 -0
- package/src/self-hosted-upgrade.ts +548 -0
- package/src/stack-contract-parity.test.ts +199 -0
- package/src/stack-contract.test.ts +391 -0
- package/src/stack-contract.ts +888 -0
- package/src/stack-converge-secrets.test.ts +421 -0
- package/src/stack-converge.test.ts +833 -0
- package/src/stack-converge.ts +909 -0
- package/src/staging-admin.test.ts +246 -0
- package/src/staging-admin.ts +324 -0
- package/src/state-attach.test.ts +211 -0
- package/src/state-attach.ts +132 -0
- package/src/state-backend.test.ts +145 -0
- package/src/state-backend.ts +262 -0
- package/src/state-command.ts +293 -0
- package/src/state-migrate.test.ts +479 -0
- package/src/state-migrate.ts +434 -0
- package/src/state-pulumi.ts +289 -0
- package/src/state-store.test.ts +212 -0
- package/src/state-store.ts +243 -0
- package/src/upgrade/app-roll.ts +486 -0
- package/src/upgrade/backup.ts +109 -0
- package/src/upgrade/converge.ts +437 -0
- package/src/upgrade/gcp-lookup.ts +167 -0
- package/src/upgrade/health.test.ts +279 -0
- package/src/upgrade/health.ts +305 -0
- package/src/upgrade/release-images.test.ts +119 -0
- package/src/upgrade/release-images.ts +391 -0
- package/src/upgrade/reporter.ts +46 -0
- package/src/upgrade/upgrade.test.ts +626 -0
- package/src/upgrade/upgrade.ts +249 -0
- package/src/upgrader-assess.test.ts +265 -0
- package/src/upgrader-assess.ts +233 -0
- package/src/upgrader-gcp.ts +123 -0
- package/src/upgrader-job.test.ts +408 -0
- package/src/upgrader-job.ts +499 -0
- package/src/upgrader-reporter.test.ts +46 -0
- package/src/upgrader-reporter.ts +105 -0
- package/src/upgrader-setup.test.ts +169 -0
- package/src/upgrader-setup.ts +355 -0
- package/src/wizard.ts +425 -0
- package/src/wizards/gcp-cost.ts +188 -0
- package/src/wizards/gcp-firebase.ts +72 -0
- package/src/wizards/gcp-install-job.ts +506 -0
- package/src/wizards/gcp-prompts.ts +26 -0
- package/src/wizards/gcp-pulumi.test.ts +246 -0
- package/src/wizards/gcp-pulumi.ts +516 -0
- package/src/wizards/gcp.ts +1935 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `attach --project` (#696, state-attach.ts) against a fake bucket, and the
|
|
5
|
+
* install-dir archive `state migrate` takes first (state-command.ts).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
mkdtempSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
statSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
21
|
+
import { attachInstall } from "./state-attach.js";
|
|
22
|
+
import { gcsBackendRecord, hasLocalPulumiState } from "./state-backend.js";
|
|
23
|
+
import { backupInstallDir } from "./state-command.js";
|
|
24
|
+
import { acquireBucketStateLock, acquireLocalStateLock, type SyncGcloud } from "./state-store.js";
|
|
25
|
+
|
|
26
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
27
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
28
|
+
|
|
29
|
+
const GCS = gcsBackendRecord("acme-site", "europe-west1");
|
|
30
|
+
const META = {
|
|
31
|
+
installId: "gcp-firebase-acme-site",
|
|
32
|
+
provider: "gcp-firebase",
|
|
33
|
+
projectId: "acme-site",
|
|
34
|
+
domain: "acme.com",
|
|
35
|
+
ownerEmail: "o@acme.com",
|
|
36
|
+
region: "europe-west1",
|
|
37
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
38
|
+
stateBackend: GCS,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function bucket(objects: Record<string, string>): SyncGcloud {
|
|
42
|
+
return (args) => {
|
|
43
|
+
if (args[1] === "cat") {
|
|
44
|
+
const v = objects[args[2] ?? ""];
|
|
45
|
+
return v === undefined ? fail("matched no objects") : ok(v);
|
|
46
|
+
}
|
|
47
|
+
return fail(`unexpected ${args.join(" ")}`);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let home = "";
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
home = mkdtempSync(join(tmpdir(), "caelo-attach-"));
|
|
54
|
+
});
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
rmSync(home, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const deps = (objects: Record<string, string>) => ({
|
|
60
|
+
run: bucket(objects),
|
|
61
|
+
rootFor: (id: string) => join(home, `.caelo-${id}`),
|
|
62
|
+
hasLocalState: hasLocalPulumiState,
|
|
63
|
+
});
|
|
64
|
+
const url = (f: string) => `gs://acme-site-caelo-ops/install/${f}`;
|
|
65
|
+
|
|
66
|
+
describe("attachInstall", () => {
|
|
67
|
+
it("mirrors install.json + progress.json from the bucket into a new install dir", () => {
|
|
68
|
+
const r = attachInstall(
|
|
69
|
+
"acme-site",
|
|
70
|
+
deps({
|
|
71
|
+
[url("install.json")]: JSON.stringify(META),
|
|
72
|
+
[url("progress.json")]: JSON.stringify({ lastCompletedStep: "x", steps: {} }),
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
expect(r).toEqual({
|
|
76
|
+
ok: true,
|
|
77
|
+
installId: META.installId,
|
|
78
|
+
domain: "acme.com",
|
|
79
|
+
status: "attached",
|
|
80
|
+
});
|
|
81
|
+
const root = join(home, `.caelo-${META.installId}`);
|
|
82
|
+
expect(JSON.parse(readFileSync(join(root, "install.json"), "utf8")).stateBackend).toEqual(GCS);
|
|
83
|
+
expect(existsSync(join(root, "progress.json"))).toBe(true);
|
|
84
|
+
expect(statSync(root).mode & 0o777).toBe(0o700);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("re-attaching is a refresh", () => {
|
|
88
|
+
const objects = { [url("install.json")]: JSON.stringify(META) };
|
|
89
|
+
attachInstall("acme-site", deps(objects));
|
|
90
|
+
expect(attachInstall("acme-site", deps(objects))).toMatchObject({
|
|
91
|
+
ok: true,
|
|
92
|
+
status: "refreshed",
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("an empty bucket points at state migrate on the installing machine", () => {
|
|
97
|
+
const r = attachInstall("acme-site", deps({}));
|
|
98
|
+
expect(r.ok).toBe(false);
|
|
99
|
+
if (!r.ok) expect(r.error).toContain("state migrate");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("refuses an install whose state went back to a machine", () => {
|
|
103
|
+
const r = attachInstall(
|
|
104
|
+
"acme-site",
|
|
105
|
+
deps({ [url("install.json")]: JSON.stringify({ ...META, stateBackend: { kind: "local" } }) }),
|
|
106
|
+
);
|
|
107
|
+
expect(r.ok).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("refuses a record for another project", () => {
|
|
111
|
+
const r = attachInstall(
|
|
112
|
+
"acme-site",
|
|
113
|
+
deps({ [url("install.json")]: JSON.stringify({ ...META, projectId: "other" }) }),
|
|
114
|
+
);
|
|
115
|
+
expect(r.ok).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("never hides this machine's un-migrated local state", () => {
|
|
119
|
+
const root = join(home, `.caelo-${META.installId}`);
|
|
120
|
+
mkdirSync(join(root, "state", ".pulumi", "stacks", "caelo-gcp-firebase"), { recursive: true });
|
|
121
|
+
writeFileSync(join(root, "install.json"), JSON.stringify({ ...META, stateBackend: undefined }));
|
|
122
|
+
const r = attachInstall("acme-site", deps({ [url("install.json")]: JSON.stringify(META) }));
|
|
123
|
+
expect(r.ok).toBe(false);
|
|
124
|
+
if (!r.ok) expect(r.error).toContain("state migrate --install");
|
|
125
|
+
expect(
|
|
126
|
+
JSON.parse(readFileSync(join(root, "install.json"), "utf8")).stateBackend,
|
|
127
|
+
).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("attachInstall — local state without install.json", () => {
|
|
132
|
+
it("still refuses to hide it", () => {
|
|
133
|
+
const root = join(home, `.caelo-${META.installId}`);
|
|
134
|
+
mkdirSync(join(root, "state", ".pulumi", "stacks", "caelo-gcp-firebase"), { recursive: true });
|
|
135
|
+
const r = attachInstall("acme-site", deps({ [url("install.json")]: JSON.stringify(META) }));
|
|
136
|
+
expect(r.ok).toBe(false);
|
|
137
|
+
expect(existsSync(join(root, "install.json"))).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("backupInstallDir", () => {
|
|
142
|
+
for (const hasZstd of [true, false]) {
|
|
143
|
+
it(`archives the install dir privately (${hasZstd ? "tar.zst" : "tar.gz fallback"})`, () => {
|
|
144
|
+
if (hasZstd && Bun.spawnSync(["zstd", "--version"]).exitCode !== 0) return;
|
|
145
|
+
const root = join(home, ".caelo-gcp-acme");
|
|
146
|
+
mkdirSync(join(root, "secrets"), { recursive: true });
|
|
147
|
+
writeFileSync(join(root, "secrets", "pulumi-passphrase"), "pp\n");
|
|
148
|
+
const out = backupInstallDir("gcp-acme", {
|
|
149
|
+
home,
|
|
150
|
+
hasZstd,
|
|
151
|
+
now: new Date("2026-10-13T10:00:00Z"),
|
|
152
|
+
});
|
|
153
|
+
expect(out).toBe(
|
|
154
|
+
join(
|
|
155
|
+
home,
|
|
156
|
+
".caelo-backups",
|
|
157
|
+
`gcp-acme-2026-10-13T10-00-00-000Z.tar.${hasZstd ? "zst" : "gz"}`,
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
expect(statSync(out).mode & 0o777).toBe(0o600);
|
|
161
|
+
expect(statSync(join(home, ".caelo-backups")).mode & 0o777).toBe(0o700);
|
|
162
|
+
const list = hasZstd
|
|
163
|
+
? Bun.spawnSync(["sh", "-c", `zstd -dc "${out}" | tar -tf -`])
|
|
164
|
+
: Bun.spawnSync(["tar", "-tzf", out]);
|
|
165
|
+
expect(new TextDecoder().decode(list.stdout)).toContain(
|
|
166
|
+
".caelo-gcp-acme/secrets/pulumi-passphrase",
|
|
167
|
+
);
|
|
168
|
+
expect(existsSync(out.replace(/\.zst$/, ""))).toBe(!hasZstd);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("state locks", () => {
|
|
174
|
+
it("bucket lock: created only if absent, released by deleting it", () => {
|
|
175
|
+
const objects = new Map<string, string>();
|
|
176
|
+
const run: SyncGcloud = (args, input) => {
|
|
177
|
+
const url = args[2] === "-" ? (args[3] ?? "") : (args[2] ?? "");
|
|
178
|
+
if (args[1] === "cp") {
|
|
179
|
+
if (args.includes("--if-generation-match=0") && objects.has(url)) {
|
|
180
|
+
return fail(
|
|
181
|
+
"HTTPError 412: At least one of the pre-conditions you specified did not hold.",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
objects.set(url, input ?? "");
|
|
185
|
+
return ok();
|
|
186
|
+
}
|
|
187
|
+
if (args[1] === "cat")
|
|
188
|
+
return objects.has(url) ? ok(objects.get(url)) : fail("matched no objects");
|
|
189
|
+
if (args[1] === "rm") return objects.delete(url) ? ok() : fail("matched no objects");
|
|
190
|
+
return fail("unexpected");
|
|
191
|
+
};
|
|
192
|
+
const release = acquireBucketStateLock("acme-site-caelo-ops", "state migrate", run);
|
|
193
|
+
expect(() => acquireBucketStateLock("acme-site-caelo-ops", "installer pulumi up", run)).toThrow(
|
|
194
|
+
/gcloud storage rm gs:\/\/acme-site-caelo-ops\/install\/state.lock/,
|
|
195
|
+
);
|
|
196
|
+
release();
|
|
197
|
+
acquireBucketStateLock("acme-site-caelo-ops", "installer pulumi up", run)();
|
|
198
|
+
expect(objects.size).toBe(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("local lock: exclusive file next to the state, mode 600", () => {
|
|
202
|
+
const root = join(home, ".caelo-gcp-acme");
|
|
203
|
+
mkdirSync(root, { recursive: true });
|
|
204
|
+
const release = acquireLocalStateLock(root, "installer pulumi up");
|
|
205
|
+
expect(statSync(join(root, "state.lock")).mode & 0o777).toBe(0o600);
|
|
206
|
+
expect(() => acquireLocalStateLock(root, "state migrate")).toThrow(/installer pulumi up/);
|
|
207
|
+
release();
|
|
208
|
+
acquireLocalStateLock(root, "state migrate")();
|
|
209
|
+
expect(existsSync(join(root, "state.lock"))).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `cms-provision attach --project <id>` (#696): make an install whose state
|
|
5
|
+
* lives in its ops bucket manageable from this machine (a new laptop, Cloud
|
|
6
|
+
* Shell). It reads `gs://<project>-caelo-ops/install/install.json`, checks
|
|
7
|
+
* it, and mirrors install.json + progress.json into `~/.caelo-<id>/`. No
|
|
8
|
+
* secret is copied, because none exists outside the operator's Google
|
|
9
|
+
* login: Pulumi reaches the state and its KMS key with that login.
|
|
10
|
+
*
|
|
11
|
+
* Refuses rather than overwrites: an install directory here that holds this
|
|
12
|
+
* install's local state (it was never migrated) or another backend.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
16
|
+
import { selfCommand } from "./self-command.js";
|
|
17
|
+
import { opsBucketName, parseStateBackend } from "./state-backend.js";
|
|
18
|
+
import { GcsStateStore, LocalStateStore, type SyncGcloud } from "./state-store.js";
|
|
19
|
+
|
|
20
|
+
export type AttachResult =
|
|
21
|
+
| {
|
|
22
|
+
readonly ok: true;
|
|
23
|
+
readonly installId: string;
|
|
24
|
+
readonly domain: string;
|
|
25
|
+
readonly status: "attached" | "refreshed";
|
|
26
|
+
}
|
|
27
|
+
| { readonly ok: false; readonly error: string };
|
|
28
|
+
|
|
29
|
+
/** Install ids are `<provider>-<slug>` (install-state.ts deriveInstallId). */
|
|
30
|
+
const INSTALL_ID = /^[a-z0-9][a-z0-9-]{0,80}$/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Attach the install in `projectId` to this machine.
|
|
34
|
+
*
|
|
35
|
+
* @param deps.run gcloud runner (tests pass a fake bucket)
|
|
36
|
+
* @param deps.rootFor `~/.caelo-<id>` for an install id
|
|
37
|
+
* @param deps.hasLocalState whether a dir holds a local Pulumi stack
|
|
38
|
+
*/
|
|
39
|
+
export function attachInstall(
|
|
40
|
+
projectId: string,
|
|
41
|
+
deps: {
|
|
42
|
+
run: SyncGcloud;
|
|
43
|
+
rootFor: (installId: string) => string;
|
|
44
|
+
hasLocalState: (root: string) => boolean;
|
|
45
|
+
},
|
|
46
|
+
): AttachResult {
|
|
47
|
+
const bucket = opsBucketName(projectId);
|
|
48
|
+
let text: string | null;
|
|
49
|
+
try {
|
|
50
|
+
// Peek: the directory to mirror into isn't known (or checked) yet.
|
|
51
|
+
text = new GcsStateStore(bucket, "/nonexistent", deps.run).peek("install.json");
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
54
|
+
}
|
|
55
|
+
if (text === null) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
error: `gs://${bucket} holds no install.json. If this install's state is still on the machine that installed it, run \`${selfCommand("state", "migrate")}\` there first.`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
let remote: {
|
|
62
|
+
installId?: unknown;
|
|
63
|
+
domain?: unknown;
|
|
64
|
+
projectId?: unknown;
|
|
65
|
+
stateBackend?: unknown;
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
remote = JSON.parse(text) as typeof remote;
|
|
69
|
+
} catch {
|
|
70
|
+
return { ok: false, error: `gs://${bucket}/install/install.json is not valid JSON.` };
|
|
71
|
+
}
|
|
72
|
+
const installId = typeof remote.installId === "string" ? remote.installId : "";
|
|
73
|
+
if (!INSTALL_ID.test(installId) || remote.projectId !== projectId) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
error: `gs://${bucket}/install/install.json does not describe an install in project ${projectId}; refusing to attach.`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
let backend: ReturnType<typeof parseStateBackend>;
|
|
80
|
+
try {
|
|
81
|
+
backend = parseStateBackend(remote.stateBackend);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
84
|
+
}
|
|
85
|
+
if (backend?.kind !== "gcs") {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
error: `${installId}'s state was moved back to a machine (state migrate --to local); there is nothing in gs://${bucket} to attach to. Run \`state migrate --to remote\` on that machine first.`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (backend.bucket !== bucket) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: `install.json in gs://${bucket} names another bucket (${backend.bucket}); refusing to attach.`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const root = deps.rootFor(installId);
|
|
99
|
+
const localText = new LocalStateStore(root).read("install.json");
|
|
100
|
+
// Local Pulumi state blocks the attach whether or not an install.json
|
|
101
|
+
// sits next to it: attaching would hide it.
|
|
102
|
+
if (deps.hasLocalState(root)) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: `${root} holds a local Pulumi state of this install. Attaching would hide it; run \`${selfCommand("state", "migrate", "--install", installId)}\` instead, or move ${root} aside if that state is stale.`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
let status: "attached" | "refreshed" = "attached";
|
|
109
|
+
if (localText !== null) {
|
|
110
|
+
const local = JSON.parse(localText) as { stateBackend?: unknown };
|
|
111
|
+
const localBackend = parseStateBackend(local.stateBackend);
|
|
112
|
+
if (localBackend?.kind === "gcs" && localBackend.bucket === bucket) {
|
|
113
|
+
status = "refreshed";
|
|
114
|
+
} else {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
error: `${root}/install.json exists and names another backend. Move ${root} aside, then re-run attach.`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!existsSync(root)) mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
122
|
+
// Mirror exactly the install.json validated above (a re-read could fetch
|
|
123
|
+
// a different one written meanwhile); progress.json is bookkeeping.
|
|
124
|
+
new LocalStateStore(root).write("install.json", JSON.parse(text));
|
|
125
|
+
new GcsStateStore(bucket, root, deps.run).read("progress.json");
|
|
126
|
+
return {
|
|
127
|
+
ok: true,
|
|
128
|
+
installId,
|
|
129
|
+
domain: typeof remote.domain === "string" ? remote.domain : installId,
|
|
130
|
+
status,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Backend resolution (#696, state-backend.ts): the one place a Pulumi
|
|
5
|
+
* backend URL, secrets provider and environment come from.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "bun:test";
|
|
9
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
backendShellEnv,
|
|
14
|
+
chooseInstallBackend,
|
|
15
|
+
gcsBackendRecord,
|
|
16
|
+
hasLocalPulumiState,
|
|
17
|
+
parseStateBackend,
|
|
18
|
+
resolveBackend,
|
|
19
|
+
} from "./state-backend.js";
|
|
20
|
+
|
|
21
|
+
const ROOT = "/home/op/.caelo-gcp-acme";
|
|
22
|
+
const GCS = gcsBackendRecord("acme", "europe-west1");
|
|
23
|
+
|
|
24
|
+
describe("resolveBackend", () => {
|
|
25
|
+
it("local: file:// under the install dir, passphrase provider, passphrase in the env", () => {
|
|
26
|
+
const b = resolveBackend(
|
|
27
|
+
{ installId: "gcp-acme" },
|
|
28
|
+
{ installRoot: ROOT, readPassphrase: () => "pp" },
|
|
29
|
+
);
|
|
30
|
+
expect(b).toEqual({
|
|
31
|
+
kind: "local",
|
|
32
|
+
url: `file://${ROOT}/state`,
|
|
33
|
+
secretsProvider: "passphrase",
|
|
34
|
+
envVars: {
|
|
35
|
+
PULUMI_BACKEND_URL: `file://${ROOT}/state`,
|
|
36
|
+
PULUMI_CONFIG_PASSPHRASE: "pp",
|
|
37
|
+
PULUMI_FALLBACK_TO_STATE_SECRETS_MANAGER: "true",
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("local without a passphrase fails loudly instead of letting Pulumi prompt", () => {
|
|
43
|
+
expect(() =>
|
|
44
|
+
resolveBackend({ installId: "gcp-acme" }, { installRoot: ROOT, readPassphrase: () => null }),
|
|
45
|
+
).toThrow(/pulumi-passphrase is missing/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("gcs: the ops bucket + KMS key, no passphrase read or passed", () => {
|
|
49
|
+
let read = false;
|
|
50
|
+
const b = resolveBackend(
|
|
51
|
+
{ installId: "gcp-acme", stateBackend: GCS },
|
|
52
|
+
{
|
|
53
|
+
installRoot: ROOT,
|
|
54
|
+
readPassphrase: () => {
|
|
55
|
+
read = true;
|
|
56
|
+
return "pp";
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
expect(read).toBe(false);
|
|
61
|
+
expect(b.kind).toBe("gcs");
|
|
62
|
+
expect(b.url).toBe("gs://acme-caelo-ops/pulumi");
|
|
63
|
+
expect(b.secretsProvider).toBe(
|
|
64
|
+
"gcpkms://projects/acme/locations/europe-west1/keyRings/caelo/cryptoKeys/pulumi-state",
|
|
65
|
+
);
|
|
66
|
+
expect(b.envVars).toEqual({
|
|
67
|
+
PULUMI_BACKEND_URL: "gs://acme-caelo-ops/pulumi",
|
|
68
|
+
PULUMI_FALLBACK_TO_STATE_SECRETS_MANAGER: "true",
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("parseStateBackend", () => {
|
|
74
|
+
it("accepts absent, local and a well-formed gcs record", () => {
|
|
75
|
+
expect(parseStateBackend(undefined)).toBeUndefined();
|
|
76
|
+
expect(parseStateBackend({ kind: "local" })).toEqual({ kind: "local" });
|
|
77
|
+
expect(parseStateBackend({ ...GCS })).toEqual(GCS);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("throws on anything else rather than pointing Pulumi at a guess", () => {
|
|
81
|
+
expect(() => parseStateBackend({ kind: "s3" })).toThrow(/malformed/);
|
|
82
|
+
expect(() =>
|
|
83
|
+
parseStateBackend({ kind: "gcs", bucket: "gs://x", kmsKey: GCS.kmsKey }),
|
|
84
|
+
).toThrow();
|
|
85
|
+
expect(() =>
|
|
86
|
+
parseStateBackend({ kind: "gcs", bucket: "acme-caelo-ops", kmsKey: "k" }),
|
|
87
|
+
).toThrow();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("backendShellEnv (recovery commands the CLI prints)", () => {
|
|
92
|
+
it("never inlines the passphrase", () => {
|
|
93
|
+
const local = backendShellEnv({}, ROOT);
|
|
94
|
+
expect(local).toContain(`PULUMI_BACKEND_URL=file://${ROOT}/state`);
|
|
95
|
+
expect(local).toContain(`"$(cat ${ROOT}/secrets/pulumi-passphrase)"`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("gcs needs no secret at all", () => {
|
|
99
|
+
expect(backendShellEnv({ stateBackend: GCS }, ROOT)).toBe(
|
|
100
|
+
"PULUMI_BACKEND_URL=gs://acme-caelo-ops/pulumi PULUMI_FALLBACK_TO_STATE_SECRETS_MANAGER=true",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("chooseInstallBackend", () => {
|
|
106
|
+
it("new GCP installs go to the ops bucket", () => {
|
|
107
|
+
expect(chooseInstallBackend({ provider: "gcp" }, false)).toBe("gcs");
|
|
108
|
+
expect(chooseInstallBackend({ provider: "gcp-firebase" }, false)).toBe("gcs");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("an older install with state on this machine stays local (never moved by a re-run)", () => {
|
|
112
|
+
expect(chooseInstallBackend({ provider: "gcp-firebase" }, true)).toBe("local");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("a deployed install with no state anywhere stops instead of starting an empty stack", () => {
|
|
116
|
+
expect(() =>
|
|
117
|
+
chooseInstallBackend({ installId: "gcp-acme", provider: "gcp" }, false, true),
|
|
118
|
+
).toThrow(/neither recorded/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("a recorded backend wins; self-hosted stays local", () => {
|
|
122
|
+
expect(chooseInstallBackend({ provider: "gcp", stateBackend: GCS }, true)).toBe("gcs");
|
|
123
|
+
expect(chooseInstallBackend({ provider: "gcp", stateBackend: { kind: "local" } }, false)).toBe(
|
|
124
|
+
"local",
|
|
125
|
+
);
|
|
126
|
+
expect(chooseInstallBackend({ provider: "self-hosted" }, false)).toBe("local");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("hasLocalPulumiState", () => {
|
|
131
|
+
it("is true only when the DIY backend holds a stack", () => {
|
|
132
|
+
const root = mkdtempSync(join(tmpdir(), "caelo-state-"));
|
|
133
|
+
try {
|
|
134
|
+
expect(hasLocalPulumiState(root)).toBe(false);
|
|
135
|
+
mkdirSync(join(root, "state", ".pulumi", "stacks", "caelo-gcp"), { recursive: true });
|
|
136
|
+
expect(hasLocalPulumiState(root)).toBe(true);
|
|
137
|
+
rmSync(join(root, "state", ".pulumi", "stacks", "caelo-gcp"), { recursive: true });
|
|
138
|
+
expect(hasLocalPulumiState(root)).toBe(false);
|
|
139
|
+
writeFileSync(join(root, "state", ".pulumi", "stacks", "production.json"), "{}");
|
|
140
|
+
expect(hasLocalPulumiState(root)).toBe(true);
|
|
141
|
+
} finally {
|
|
142
|
+
rmSync(root, { recursive: true, force: true });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|