@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,185 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The target release's half of a self-hosted update (agent/apply.ts): the
|
|
5
|
+
* upgrade's progress becomes the request's timeline, its result the
|
|
6
|
+
* request's end state, and only a successful install re-pins the agent.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import type { SelfHostedUpgradeResult, UpgradeLog } from "../self-hosted-upgrade.js";
|
|
11
|
+
import type { UpdateEnd, UpdateQueue } from "../upgrader-job.js";
|
|
12
|
+
import { type AgentApplyDeps, applyAgentUpdate, endForResult } from "./apply.js";
|
|
13
|
+
|
|
14
|
+
const REQUEST = {
|
|
15
|
+
id: "22222222-2222-4222-8222-222222222222",
|
|
16
|
+
targetVersion: "0.11.0",
|
|
17
|
+
trigger: "manual" as const,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function setup(
|
|
21
|
+
upgrade: (log: UpgradeLog) => Promise<SelfHostedUpgradeResult>,
|
|
22
|
+
over: Partial<AgentApplyDeps> = {},
|
|
23
|
+
) {
|
|
24
|
+
const events: { phase: string; level: string; message: string }[] = [];
|
|
25
|
+
const ends: UpdateEnd[] = [];
|
|
26
|
+
let pins = 0;
|
|
27
|
+
const queue: UpdateQueue = {
|
|
28
|
+
claim: async () => null,
|
|
29
|
+
event: async (_id, phase, level, message) => {
|
|
30
|
+
events.push({ phase, level, message });
|
|
31
|
+
},
|
|
32
|
+
record: async () => {},
|
|
33
|
+
finish: async (_id, end) => {
|
|
34
|
+
ends.push(end);
|
|
35
|
+
return true;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const deps: AgentApplyDeps = {
|
|
39
|
+
queue,
|
|
40
|
+
request: REQUEST,
|
|
41
|
+
agentVersion: "0.11.0",
|
|
42
|
+
upgrade,
|
|
43
|
+
selfUpdate: async () => {
|
|
44
|
+
pins += 1;
|
|
45
|
+
return { ok: true };
|
|
46
|
+
},
|
|
47
|
+
log: () => {},
|
|
48
|
+
sleep: async () => {},
|
|
49
|
+
...over,
|
|
50
|
+
};
|
|
51
|
+
return { deps, events, ends, pins: () => pins };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** An upgrade that narrates like upgradeSelfHosted does. */
|
|
55
|
+
const narrated =
|
|
56
|
+
(result: SelfHostedUpgradeResult) =>
|
|
57
|
+
async (log: UpgradeLog): Promise<SelfHostedUpgradeResult> => {
|
|
58
|
+
log.phase?.("verify");
|
|
59
|
+
log.info("Verifying the signature of admin");
|
|
60
|
+
log.phase?.("backup");
|
|
61
|
+
log.step("Backing up the databases (pg_dump)");
|
|
62
|
+
log.phase?.("migrations");
|
|
63
|
+
log.step("Running database migrations with the new admin image");
|
|
64
|
+
log.phase?.("roll");
|
|
65
|
+
log.step("Restarting admin on sha256:aaaa");
|
|
66
|
+
if (!result.ok) {
|
|
67
|
+
log.phase?.("rollback");
|
|
68
|
+
log.warn("Rolling back to the previous images");
|
|
69
|
+
} else {
|
|
70
|
+
log.phase?.("record");
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
describe("applyAgentUpdate", () => {
|
|
76
|
+
it("narrates the upgrade into the timeline, records success, re-pins the agent", async () => {
|
|
77
|
+
const { deps, events, ends, pins } = setup(
|
|
78
|
+
narrated({ ok: true, status: "upgraded", version: "0.11.0" }),
|
|
79
|
+
);
|
|
80
|
+
expect(await applyAgentUpdate(deps)).toEqual({ exitCode: 0 });
|
|
81
|
+
expect([...new Set(events.map((e) => e.phase))]).toEqual([
|
|
82
|
+
"start",
|
|
83
|
+
"verify",
|
|
84
|
+
"backup",
|
|
85
|
+
"migrations",
|
|
86
|
+
"roll",
|
|
87
|
+
"self-update",
|
|
88
|
+
"finish",
|
|
89
|
+
]);
|
|
90
|
+
expect(ends).toEqual([
|
|
91
|
+
{ status: "succeeded", reason: null, codes: [], pauseAutoUpdates: false },
|
|
92
|
+
]);
|
|
93
|
+
expect(pins()).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("ends a rolled-back upgrade as rolled_back and leaves the agent's pin", async () => {
|
|
97
|
+
const { deps, events, ends, pins } = setup(
|
|
98
|
+
narrated({
|
|
99
|
+
ok: false,
|
|
100
|
+
stage: "roll-admin",
|
|
101
|
+
error: "health check did not pass",
|
|
102
|
+
previousRunning: true,
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
expect(await applyAgentUpdate(deps)).toEqual({ exitCode: 1 });
|
|
106
|
+
expect(ends[0]).toMatchObject({ status: "rolled_back", codes: ["roll-admin"] });
|
|
107
|
+
expect(ends[0]?.reason).toContain("previous release serves again");
|
|
108
|
+
expect(events.some((e) => e.phase === "rollback")).toBe(true);
|
|
109
|
+
expect(pins()).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("refuses to act as another release than the target", async () => {
|
|
113
|
+
let ran = false;
|
|
114
|
+
const { deps, ends } = setup(
|
|
115
|
+
async () => {
|
|
116
|
+
ran = true;
|
|
117
|
+
return { ok: true, status: "upgraded", version: "0.11.0" };
|
|
118
|
+
},
|
|
119
|
+
{ agentVersion: "0.10.47" },
|
|
120
|
+
);
|
|
121
|
+
expect(await applyAgentUpdate(deps)).toEqual({ exitCode: 1 });
|
|
122
|
+
expect(ran).toBe(false);
|
|
123
|
+
expect(ends[0]).toMatchObject({ status: "failed", codes: ["apply"] });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("turns a thrown upgrade into a failed request", async () => {
|
|
127
|
+
const { deps, ends } = setup(async () => {
|
|
128
|
+
throw new Error("ENOSPC");
|
|
129
|
+
});
|
|
130
|
+
expect(await applyAgentUpdate(deps)).toEqual({ exitCode: 1 });
|
|
131
|
+
expect(ends[0]).toMatchObject({ status: "failed", codes: ["internal"] });
|
|
132
|
+
expect(ends[0]?.reason).toContain("ENOSPC");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("still succeeds when the agent cannot re-pin itself, with a warning", async () => {
|
|
136
|
+
const { deps, events, ends } = setup(
|
|
137
|
+
narrated({ ok: true, status: "upgraded", version: "0.11.0" }),
|
|
138
|
+
{ selfUpdate: async () => ({ ok: false, error: "read-only home" }) },
|
|
139
|
+
);
|
|
140
|
+
expect(await applyAgentUpdate(deps)).toEqual({ exitCode: 0 });
|
|
141
|
+
expect(ends[0]?.status).toBe("succeeded");
|
|
142
|
+
expect(events.find((e) => e.phase === "self-update")).toMatchObject({ level: "warn" });
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("endForResult", () => {
|
|
147
|
+
it.each([
|
|
148
|
+
[
|
|
149
|
+
"verify failure",
|
|
150
|
+
{ ok: false, stage: "verify", error: "cosign", previousRunning: true } as const,
|
|
151
|
+
"failed",
|
|
152
|
+
"verify",
|
|
153
|
+
],
|
|
154
|
+
[
|
|
155
|
+
"migration failure",
|
|
156
|
+
{ ok: false, stage: "migrate", error: "x", previousRunning: true } as const,
|
|
157
|
+
"failed",
|
|
158
|
+
"migrate",
|
|
159
|
+
],
|
|
160
|
+
[
|
|
161
|
+
"gateway rolled back",
|
|
162
|
+
{ ok: false, stage: "roll-gateway", error: "x", previousRunning: true } as const,
|
|
163
|
+
"rolled_back",
|
|
164
|
+
"roll-gateway",
|
|
165
|
+
],
|
|
166
|
+
[
|
|
167
|
+
"rollback failed",
|
|
168
|
+
{ ok: false, stage: "roll-admin", error: "x", previousRunning: false } as const,
|
|
169
|
+
"failed",
|
|
170
|
+
"rollback",
|
|
171
|
+
],
|
|
172
|
+
])("%s", (_name, result, status, code) => {
|
|
173
|
+
const end = endForResult(result, "auto");
|
|
174
|
+
expect(end.status).toBe(status as UpdateEnd["status"]);
|
|
175
|
+
expect(end.codes).toEqual([code]);
|
|
176
|
+
expect(end.pauseAutoUpdates).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("does not pause automatic installs after a manual failure", () => {
|
|
180
|
+
expect(
|
|
181
|
+
endForResult({ ok: false, stage: "pull", error: "x", previousRunning: true }, "manual")
|
|
182
|
+
.pauseAutoUpdates,
|
|
183
|
+
).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `agent apply` — the target release's half of a self-hosted update (#696
|
|
5
|
+
* Phase 4c). The polling agent (agent/run.ts) claimed the request, verified
|
|
6
|
+
* the release's signed manifest and ran the refusal checks; it then starts
|
|
7
|
+
* THIS release's CLI, which performs the upgrade exactly like a hand-run
|
|
8
|
+
* `cms-provision upgrade` (self-hosted-upgrade.ts: verified digests, pg_dump
|
|
9
|
+
* backup, pull, migrations with the new admin image, health-checked roll,
|
|
10
|
+
* rollback) — narrated into the request's timeline — records how it ended,
|
|
11
|
+
* and re-pins the agent's systemd service to this release.
|
|
12
|
+
*
|
|
13
|
+
* It is never started by hand: it acts on a request the parent already
|
|
14
|
+
* claimed and holds the install's upgrade lock for.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { PlatformUpdateTrigger } from "@caelo-cms/shared";
|
|
18
|
+
import type { SelfHostedUpgradeResult, UpgradeLog } from "../self-hosted-upgrade.js";
|
|
19
|
+
import { recordUpdateEnd, type UpdateEnd, type UpdateQueue } from "../upgrader-job.js";
|
|
20
|
+
import { eventReporter, type JobLog } from "../upgrader-reporter.js";
|
|
21
|
+
|
|
22
|
+
export interface AgentApplyDeps {
|
|
23
|
+
readonly queue: UpdateQueue;
|
|
24
|
+
readonly request: {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly targetVersion: string;
|
|
27
|
+
readonly trigger: PlatformUpdateTrigger;
|
|
28
|
+
};
|
|
29
|
+
/** This CLI's own release; must be the target. */
|
|
30
|
+
readonly agentVersion: string;
|
|
31
|
+
/** upgradeSelfHosted for the target, with the manifest's digests. */
|
|
32
|
+
readonly upgrade: (log: UpgradeLog) => Promise<SelfHostedUpgradeResult>;
|
|
33
|
+
/** Re-pin the agent's service to this release (agent/systemd.ts). */
|
|
34
|
+
readonly selfUpdate: () => Promise<{ ok: true } | { ok: false; error: string }>;
|
|
35
|
+
readonly log: JobLog;
|
|
36
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The request's end state for an upgrade result. */
|
|
40
|
+
export function endForResult(
|
|
41
|
+
result: SelfHostedUpgradeResult,
|
|
42
|
+
trigger: PlatformUpdateTrigger,
|
|
43
|
+
): UpdateEnd {
|
|
44
|
+
if (result.ok) return { status: "succeeded", reason: null, codes: [], pauseAutoUpdates: false };
|
|
45
|
+
const rolled = result.stage === "roll-admin" || result.stage === "roll-gateway";
|
|
46
|
+
const status = rolled && result.previousRunning ? "rolled_back" : "failed";
|
|
47
|
+
const lead =
|
|
48
|
+
status === "rolled_back"
|
|
49
|
+
? "The new release did not pass its health check, so the previous release serves again."
|
|
50
|
+
: result.previousRunning
|
|
51
|
+
? "Nothing was changed; the previous release keeps serving."
|
|
52
|
+
: "The update stopped half-way and the previous release did not come back healthy — check `docker compose ps` on the server.";
|
|
53
|
+
return {
|
|
54
|
+
status,
|
|
55
|
+
reason: `${lead}\n${result.error}`.slice(0, 4000),
|
|
56
|
+
codes: [rolled && !result.previousRunning ? "rollback" : result.stage],
|
|
57
|
+
pauseAutoUpdates: trigger === "auto",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Run the upgrade for the claimed request. Never throws. */
|
|
62
|
+
export async function applyAgentUpdate(deps: AgentApplyDeps): Promise<{ exitCode: number }> {
|
|
63
|
+
const { queue, request, log } = deps;
|
|
64
|
+
const r = eventReporter(
|
|
65
|
+
(phase, level, message) => queue.event(request.id, phase, level, message),
|
|
66
|
+
log,
|
|
67
|
+
request.id,
|
|
68
|
+
);
|
|
69
|
+
const finish = async (end: UpdateEnd): Promise<{ exitCode: number }> => {
|
|
70
|
+
r.phase("finish");
|
|
71
|
+
if (end.status === "succeeded") r.success(`Caelo ${request.targetVersion} is installed.`);
|
|
72
|
+
else r.error(end.reason ?? end.status);
|
|
73
|
+
await r.flush();
|
|
74
|
+
const recorded = await recordUpdateEnd(queue, request.id, end, log, deps.sleep);
|
|
75
|
+
return { exitCode: recorded && end.status === "succeeded" ? 0 : 1 };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (deps.agentVersion !== request.targetVersion) {
|
|
79
|
+
return finish({
|
|
80
|
+
status: "failed",
|
|
81
|
+
reason: `Nothing was changed: the installer of Caelo ${deps.agentVersion} was started for ${request.targetVersion}.`,
|
|
82
|
+
codes: ["apply"],
|
|
83
|
+
pauseAutoUpdates: request.trigger === "auto",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
let result: SelfHostedUpgradeResult;
|
|
87
|
+
try {
|
|
88
|
+
r.phase("start");
|
|
89
|
+
r.info(`The Caelo ${request.targetVersion} installer took over.`);
|
|
90
|
+
result = await deps.upgrade({
|
|
91
|
+
step: (m) => {
|
|
92
|
+
r.step(m);
|
|
93
|
+
},
|
|
94
|
+
info: (m) => r.info(m),
|
|
95
|
+
warn: (m) => r.warn(m),
|
|
96
|
+
error: (m) => r.error(m),
|
|
97
|
+
phase: (p) => r.phase(p),
|
|
98
|
+
});
|
|
99
|
+
} catch (e) {
|
|
100
|
+
return finish({
|
|
101
|
+
status: "failed",
|
|
102
|
+
reason: `The installer stopped unexpectedly: ${e instanceof Error ? e.message : String(e)}. Check \`docker compose ps\` on the server.`,
|
|
103
|
+
codes: ["internal"],
|
|
104
|
+
pauseAutoUpdates: request.trigger === "auto",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (result.ok) {
|
|
108
|
+
if (result.status === "already-current") {
|
|
109
|
+
r.info(`Caelo ${result.version} was already installed; the containers are healthy.`);
|
|
110
|
+
}
|
|
111
|
+
r.phase("self-update");
|
|
112
|
+
const pinned = await deps.selfUpdate().catch((e: unknown) => ({
|
|
113
|
+
ok: false as const,
|
|
114
|
+
error: e instanceof Error ? e.message : String(e),
|
|
115
|
+
}));
|
|
116
|
+
if (pinned.ok) r.info(`The update agent itself now runs ${request.targetVersion}.`);
|
|
117
|
+
else {
|
|
118
|
+
r.warn(
|
|
119
|
+
`The update agent could not update itself (${pinned.error}); it keeps working with its current version.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return finish(endForResult(result, request.trigger));
|
|
124
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The target release's CLI is installed and verified before the agent runs
|
|
5
|
+
* it (#721 maintainer decision): npm signatures + Sigstore provenance from
|
|
6
|
+
* caelo-cms/caelo-cms at the release tag. Every reject path is a structured
|
|
7
|
+
* error; nothing falls back to an unverified run.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
11
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { CommandResult, HostRunner } from "../self-hosted-release.js";
|
|
15
|
+
import { installVerifiedCli, provenanceProblem, SOURCE_REPOSITORY } from "./cli-provenance.js";
|
|
16
|
+
|
|
17
|
+
const V = "0.11.0";
|
|
18
|
+
|
|
19
|
+
/** An npm attestations response whose SLSA statement says `workflow` built `name@version`. */
|
|
20
|
+
function attestation(
|
|
21
|
+
name: string,
|
|
22
|
+
version: string,
|
|
23
|
+
workflow: { repository: string; ref: string },
|
|
24
|
+
): Response {
|
|
25
|
+
const statement = {
|
|
26
|
+
subject: [{ name: `pkg:npm/${name.replaceAll("@", "%40")}@${version}` }],
|
|
27
|
+
predicate: { buildDefinition: { externalParameters: { workflow } } },
|
|
28
|
+
};
|
|
29
|
+
return Response.json({
|
|
30
|
+
attestations: [
|
|
31
|
+
{
|
|
32
|
+
predicateType: "https://slsa.dev/provenance/v1",
|
|
33
|
+
bundle: {
|
|
34
|
+
dsseEnvelope: { payload: Buffer.from(JSON.stringify(statement)).toString("base64") },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const genuine = (async (url: string | URL | Request) => {
|
|
42
|
+
const m = /attestations\/(.+)@([^@]+)$/.exec(decodeURIComponent(String(url)));
|
|
43
|
+
return attestation(m?.[1] ?? "", m?.[2] ?? "", {
|
|
44
|
+
repository: SOURCE_REPOSITORY,
|
|
45
|
+
ref: `refs/tags/v${m?.[2]}`,
|
|
46
|
+
});
|
|
47
|
+
}) as typeof fetch;
|
|
48
|
+
|
|
49
|
+
type Rule = (line: string) => Partial<CommandResult> | undefined;
|
|
50
|
+
|
|
51
|
+
/** npm fake: `install` writes the packages at `installed` versions. */
|
|
52
|
+
function npm(dir: string, rules: Rule[] = [], installed = V): HostRunner & { calls: string[] } {
|
|
53
|
+
const calls: string[] = [];
|
|
54
|
+
return {
|
|
55
|
+
calls,
|
|
56
|
+
async run(argv) {
|
|
57
|
+
const line = argv.join(" ");
|
|
58
|
+
calls.push(line);
|
|
59
|
+
const hit = rules.map((r) => r(line)).find((r) => r !== undefined);
|
|
60
|
+
if (hit) return { exitCode: 0, stdout: "", stderr: "", ...hit };
|
|
61
|
+
if (argv[1] === "install") {
|
|
62
|
+
for (const name of ["@caelo-cms/provisioning", "@caelo-cms/shared"]) {
|
|
63
|
+
const pkg = join(dir, "node_modules", name);
|
|
64
|
+
mkdirSync(pkg, { recursive: true });
|
|
65
|
+
writeFileSync(join(pkg, "package.json"), JSON.stringify({ name, version: installed }));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("installVerifiedCli", () => {
|
|
74
|
+
let root: string;
|
|
75
|
+
let dir: string;
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
root = mkdtempSync(join(tmpdir(), "caelo-cli-"));
|
|
78
|
+
dir = join(root, `caelo-${V}`);
|
|
79
|
+
});
|
|
80
|
+
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
81
|
+
|
|
82
|
+
it("installs exactly the release with scripts off, audits it, and returns its CLI", async () => {
|
|
83
|
+
const runner = npm(dir);
|
|
84
|
+
const r = await installVerifiedCli(V, dir, { runner, fetchImpl: genuine });
|
|
85
|
+
expect(r).toEqual({
|
|
86
|
+
ok: true,
|
|
87
|
+
dir,
|
|
88
|
+
cliPath: join(dir, "node_modules", "@caelo-cms", "provisioning", "dist", "cli.js"),
|
|
89
|
+
});
|
|
90
|
+
expect(runner.calls).toEqual([
|
|
91
|
+
"npm --version",
|
|
92
|
+
`npm install --prefix ${dir} --ignore-scripts --no-audit --no-fund --loglevel=error`,
|
|
93
|
+
`npm audit signatures --prefix ${dir}`,
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it.each([
|
|
98
|
+
[
|
|
99
|
+
"no npm",
|
|
100
|
+
[(l: string) => (l === "npm --version" ? { exitCode: 127 } : undefined)],
|
|
101
|
+
genuine,
|
|
102
|
+
V,
|
|
103
|
+
"npm-missing",
|
|
104
|
+
],
|
|
105
|
+
[
|
|
106
|
+
"registry signatures rejected",
|
|
107
|
+
[
|
|
108
|
+
(l: string) =>
|
|
109
|
+
l.startsWith("npm audit") ? { exitCode: 1, stderr: "1 invalid signature" } : undefined,
|
|
110
|
+
],
|
|
111
|
+
genuine,
|
|
112
|
+
V,
|
|
113
|
+
"audit-signatures",
|
|
114
|
+
],
|
|
115
|
+
["another version installed", [], genuine, "0.11.1", "version"],
|
|
116
|
+
[
|
|
117
|
+
"built from a fork",
|
|
118
|
+
[],
|
|
119
|
+
(async () =>
|
|
120
|
+
attestation("@caelo-cms/provisioning", V, {
|
|
121
|
+
repository: "https://github.com/evil/caelo-cms",
|
|
122
|
+
ref: `refs/tags/v${V}`,
|
|
123
|
+
})) as unknown as typeof fetch,
|
|
124
|
+
V,
|
|
125
|
+
"provenance",
|
|
126
|
+
],
|
|
127
|
+
[
|
|
128
|
+
"built from a branch, not the release tag",
|
|
129
|
+
[],
|
|
130
|
+
(async () =>
|
|
131
|
+
attestation("@caelo-cms/provisioning", V, {
|
|
132
|
+
repository: SOURCE_REPOSITORY,
|
|
133
|
+
ref: "refs/heads/main",
|
|
134
|
+
})) as unknown as typeof fetch,
|
|
135
|
+
V,
|
|
136
|
+
"provenance",
|
|
137
|
+
],
|
|
138
|
+
[
|
|
139
|
+
"no attestation at all",
|
|
140
|
+
[],
|
|
141
|
+
(async () => new Response("not found", { status: 404 })) as unknown as typeof fetch,
|
|
142
|
+
V,
|
|
143
|
+
"provenance",
|
|
144
|
+
],
|
|
145
|
+
] as const)("rejects: %s", async (_name, rules, fetchImpl, installed, code) => {
|
|
146
|
+
const r = await installVerifiedCli(V, dir, {
|
|
147
|
+
runner: npm(dir, [...rules], installed),
|
|
148
|
+
fetchImpl,
|
|
149
|
+
});
|
|
150
|
+
expect(r.ok).toBe(false);
|
|
151
|
+
expect(r.ok ? null : r.error.code).toBe(code);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("provenanceProblem", () => {
|
|
156
|
+
it("accepts the release workflow's build of that exact version", async () => {
|
|
157
|
+
expect(await provenanceProblem("@caelo-cms/shared", V, genuine)).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The target release's provisioning CLI, installed and VERIFIED before the
|
|
5
|
+
* update agent runs it (#696 Phase 4c, maintainer decision on #721). The
|
|
6
|
+
* signed release manifest proves a release exists and is installable; this
|
|
7
|
+
* proves the code that installs it is that release's: the same checks the
|
|
8
|
+
* Cloud Shell runner (`run.sh` in caelo-cms/install) makes.
|
|
9
|
+
*
|
|
10
|
+
* 1. `npm install` of exactly `@caelo-cms/provisioning@<v>` (with
|
|
11
|
+
* `@caelo-cms/shared` pinned to the same version, lifecycle scripts
|
|
12
|
+
* off) into a private folder under `.caelo/agent/cli/`;
|
|
13
|
+
* 2. `npm audit signatures` — the registry signatures and Sigstore
|
|
14
|
+
* provenance attestations of every installed package;
|
|
15
|
+
* 3. both Caelo packages are the asked-for version, and their SLSA
|
|
16
|
+
* provenance names caelo-cms/caelo-cms at `refs/tags/v<v>`.
|
|
17
|
+
*
|
|
18
|
+
* Any failure is a structured {@link CliProvenanceError}; there is no
|
|
19
|
+
* fallback to an unverified `bunx` run.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import type { HostRunner } from "../self-hosted-release.js";
|
|
25
|
+
|
|
26
|
+
/** Where Caelo's packages are built (the provenance's workflow repository). */
|
|
27
|
+
export const SOURCE_REPOSITORY = "https://github.com/caelo-cms/caelo-cms";
|
|
28
|
+
|
|
29
|
+
/** The Caelo packages the CLI runs; both must carry the release's provenance. */
|
|
30
|
+
export const CAELO_CLI_PACKAGES = ["@caelo-cms/provisioning", "@caelo-cms/shared"] as const;
|
|
31
|
+
|
|
32
|
+
/** Why the CLI of a release was not trusted. */
|
|
33
|
+
export interface CliProvenanceError {
|
|
34
|
+
readonly code: "npm-missing" | "npm-install" | "audit-signatures" | "version" | "provenance";
|
|
35
|
+
readonly message: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type VerifiedCli =
|
|
39
|
+
| { readonly ok: true; readonly cliPath: string; readonly dir: string }
|
|
40
|
+
| { readonly ok: false; readonly error: CliProvenanceError };
|
|
41
|
+
|
|
42
|
+
/** `.caelo/agent/cli/` — the verified CLI installs, one folder per release. */
|
|
43
|
+
export function verifiedCliRoot(caeloDir: string): string {
|
|
44
|
+
return join(caeloDir, "agent", "cli");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The folder of release `version`'s verified CLI. */
|
|
48
|
+
export function verifiedCliDir(caeloDir: string, version: string): string {
|
|
49
|
+
return join(verifiedCliRoot(caeloDir), `caelo-${version}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The CLI entry point inside {@link verifiedCliDir}. */
|
|
53
|
+
export function verifiedCliPath(dir: string): string {
|
|
54
|
+
return join(dir, "node_modules", "@caelo-cms", "provisioning", "dist", "cli.js");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const fail = (code: CliProvenanceError["code"], message: string): VerifiedCli => ({
|
|
58
|
+
ok: false,
|
|
59
|
+
error: { code, message },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const tail = (s: string) => s.trim().split("\n").slice(-6).join("\n").slice(0, 800);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check one package's npm provenance attestation: SLSA v1, built from
|
|
66
|
+
* {@link SOURCE_REPOSITORY} at `refs/tags/v<version>`, for this exact
|
|
67
|
+
* package version.
|
|
68
|
+
*
|
|
69
|
+
* @returns null when it checks out, else why not
|
|
70
|
+
*/
|
|
71
|
+
export async function provenanceProblem(
|
|
72
|
+
name: string,
|
|
73
|
+
version: string,
|
|
74
|
+
fetchImpl: typeof fetch = fetch,
|
|
75
|
+
): Promise<string | null> {
|
|
76
|
+
const url = `https://registry.npmjs.org/-/npm/v1/attestations/${encodeURIComponent(name)}@${version}`;
|
|
77
|
+
let body: { attestations?: { predicateType?: string; bundle?: unknown }[] };
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetchImpl(url);
|
|
80
|
+
if (!res.ok)
|
|
81
|
+
return `${name}@${version}: no provenance attestation (${url}: HTTP ${res.status})`;
|
|
82
|
+
body = (await res.json()) as typeof body;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return `${name}@${version}: the attestations could not be fetched (${e instanceof Error ? e.message : String(e)})`;
|
|
85
|
+
}
|
|
86
|
+
const slsa = body.attestations?.find((a) => a.predicateType === "https://slsa.dev/provenance/v1");
|
|
87
|
+
const payload = (slsa?.bundle as { dsseEnvelope?: { payload?: string } } | undefined)
|
|
88
|
+
?.dsseEnvelope?.payload;
|
|
89
|
+
if (!payload) return `${name}@${version}: no SLSA provenance`;
|
|
90
|
+
let statement: {
|
|
91
|
+
subject?: { name?: string }[];
|
|
92
|
+
predicate?: {
|
|
93
|
+
buildDefinition?: {
|
|
94
|
+
externalParameters?: { workflow?: { repository?: string; ref?: string } };
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
try {
|
|
99
|
+
statement = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
|
100
|
+
} catch {
|
|
101
|
+
return `${name}@${version}: the provenance statement is not JSON`;
|
|
102
|
+
}
|
|
103
|
+
const workflow = statement.predicate?.buildDefinition?.externalParameters?.workflow ?? {};
|
|
104
|
+
const subjects = (statement.subject ?? []).map((s) => s.name);
|
|
105
|
+
const wantSubject = `pkg:npm/${name.replaceAll("@", "%40")}@${version}`;
|
|
106
|
+
if (
|
|
107
|
+
workflow.repository !== SOURCE_REPOSITORY ||
|
|
108
|
+
workflow.ref !== `refs/tags/v${version}` ||
|
|
109
|
+
!subjects.includes(wantSubject)
|
|
110
|
+
) {
|
|
111
|
+
return `${name}@${version} was not built by ${SOURCE_REPOSITORY} at refs/tags/v${version} (provenance: ${workflow.repository ?? "?"} ${workflow.ref ?? "?"}, ${subjects.join(",")})`;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Install release `version`'s CLI into `dir` (replacing what is there) and
|
|
118
|
+
* verify it. Nothing in it runs before the checks passed: npm's lifecycle
|
|
119
|
+
* scripts are off.
|
|
120
|
+
*/
|
|
121
|
+
export async function installVerifiedCli(
|
|
122
|
+
version: string,
|
|
123
|
+
dir: string,
|
|
124
|
+
deps: { readonly runner: HostRunner; readonly fetchImpl?: typeof fetch },
|
|
125
|
+
): Promise<VerifiedCli> {
|
|
126
|
+
const probe = await deps.runner.run(["npm", "--version"]);
|
|
127
|
+
if (probe.exitCode !== 0) {
|
|
128
|
+
return fail(
|
|
129
|
+
"npm-missing",
|
|
130
|
+
"npm is not installed on the server (or not on the agent's PATH); the update agent needs it to verify the provenance of the Caelo release it installs. Install Node.js + npm, then run `agent install` again.",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
rmSync(dir, { recursive: true, force: true });
|
|
134
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
135
|
+
// Exact versions for both Caelo packages: provisioning depends on
|
|
136
|
+
// ^<version> of shared, which could otherwise resolve to a newer one.
|
|
137
|
+
writeFileSync(
|
|
138
|
+
join(dir, "package.json"),
|
|
139
|
+
JSON.stringify(
|
|
140
|
+
{
|
|
141
|
+
name: "caelo-agent-cli",
|
|
142
|
+
private: true,
|
|
143
|
+
dependencies: { "@caelo-cms/provisioning": version },
|
|
144
|
+
overrides: { "@caelo-cms/shared": version },
|
|
145
|
+
},
|
|
146
|
+
null,
|
|
147
|
+
2,
|
|
148
|
+
),
|
|
149
|
+
);
|
|
150
|
+
const install = await deps.runner.run([
|
|
151
|
+
"npm",
|
|
152
|
+
"install",
|
|
153
|
+
"--prefix",
|
|
154
|
+
dir,
|
|
155
|
+
"--ignore-scripts",
|
|
156
|
+
"--no-audit",
|
|
157
|
+
"--no-fund",
|
|
158
|
+
"--loglevel=error",
|
|
159
|
+
]);
|
|
160
|
+
if (install.exitCode !== 0) {
|
|
161
|
+
return fail(
|
|
162
|
+
"npm-install",
|
|
163
|
+
`npm could not install @caelo-cms/provisioning@${version}: ${tail(install.stderr || install.stdout)}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const audit = await deps.runner.run(["npm", "audit", "signatures", "--prefix", dir]);
|
|
167
|
+
if (audit.exitCode !== 0) {
|
|
168
|
+
return fail(
|
|
169
|
+
"audit-signatures",
|
|
170
|
+
`npm audit signatures rejected the packages of Caelo ${version}: ${tail(audit.stderr || audit.stdout)}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
for (const name of CAELO_CLI_PACKAGES) {
|
|
174
|
+
let installed: string | undefined;
|
|
175
|
+
try {
|
|
176
|
+
installed = (
|
|
177
|
+
JSON.parse(readFileSync(join(dir, "node_modules", name, "package.json"), "utf8")) as {
|
|
178
|
+
version?: string;
|
|
179
|
+
}
|
|
180
|
+
).version;
|
|
181
|
+
} catch {
|
|
182
|
+
installed = undefined;
|
|
183
|
+
}
|
|
184
|
+
if (installed !== version) {
|
|
185
|
+
return fail("version", `${name}: installed ${installed ?? "nothing"}, expected ${version}`);
|
|
186
|
+
}
|
|
187
|
+
const problem = await provenanceProblem(name, version, deps.fetchImpl);
|
|
188
|
+
if (problem) return fail("provenance", problem);
|
|
189
|
+
}
|
|
190
|
+
return { ok: true, cliPath: verifiedCliPath(dir), dir };
|
|
191
|
+
}
|