@caelo-cms/provisioning 0.10.52 → 0.10.55
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/command.js +1 -0
- package/dist/agent/command.js.map +1 -1
- package/dist/agent-db.js +198 -102
- package/dist/audit-job-setup.d.ts +69 -0
- package/dist/audit-job-setup.d.ts.map +1 -0
- package/dist/audit-job-setup.js +317 -0
- package/dist/audit-job-setup.js.map +1 -0
- package/dist/aws-task-roles.d.ts +1 -1
- package/dist/aws-task-roles.d.ts.map +1 -1
- package/dist/aws-task-roles.js +0 -1
- package/dist/aws-task-roles.js.map +1 -1
- package/dist/caddy.d.ts +9 -0
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +38 -0
- package/dist/caddy.js.map +1 -1
- package/dist/cli-routing.d.ts +20 -0
- package/dist/cli-routing.d.ts.map +1 -1
- package/dist/cli-routing.js +106 -0
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +37 -14
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +4 -3
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +3 -2
- package/dist/compose.js.map +1 -1
- package/dist/gateway-credentials.d.ts +3 -5
- package/dist/gateway-credentials.d.ts.map +1 -1
- package/dist/gateway-credentials.js +8 -18
- package/dist/gateway-credentials.js.map +1 -1
- package/dist/gcp-names.d.ts +16 -1
- 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/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +14 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/private-file.d.ts +26 -0
- package/dist/private-file.d.ts.map +1 -0
- package/dist/private-file.js +49 -0
- package/dist/private-file.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/runtime-secrets.d.ts +1 -3
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +2 -6
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +9 -8
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +9 -9
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +7 -1
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +17 -4
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +60 -3
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +49 -37
- package/dist/stack-contract.js.map +1 -1
- package/dist/tar-entries.d.ts.map +1 -1
- package/dist/tar-entries.js +84 -9
- package/dist/tar-entries.js.map +1 -1
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +35 -0
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/audit-job-roll.d.ts +38 -0
- package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
- package/dist/upgrade/audit-job-roll.js +43 -0
- package/dist/upgrade/audit-job-roll.js.map +1 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +38 -15
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrader-setup.d.ts.map +1 -1
- package/dist/upgrader-setup.js +9 -3
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp-cost.js +3 -3
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +6 -0
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +7 -0
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +32 -30
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +4 -4
- package/src/admin-memory.test.ts +1 -1
- package/src/agent/command.ts +1 -0
- package/src/agent/run.test.ts +1 -0
- package/src/audit-job-setup.test.ts +234 -0
- package/src/audit-job-setup.ts +360 -0
- package/src/aws-task-roles.ts +0 -2
- package/src/caddy-redirects.docker.test.ts +19 -6
- package/src/caddy-redirects.test.ts +1 -1
- package/src/caddy.test.ts +30 -0
- package/src/caddy.ts +43 -0
- package/src/checkpoint-secrets.test.ts +0 -2
- package/src/cli-init-validation.test.ts +44 -0
- package/src/cli-routing.test.ts +27 -1
- package/src/cli-routing.ts +131 -0
- package/src/cli.ts +41 -15
- package/src/compose.ts +13 -5
- package/src/gateway-credentials.test.ts +0 -29
- package/src/gateway-credentials.ts +11 -25
- package/src/gateway-env-isolation.test.ts +7 -2
- package/src/gcp-names.ts +28 -1
- package/src/index.test.ts +7 -6
- package/src/infra-contract.test.ts +4 -3
- package/src/infra-contract.ts +14 -1
- package/src/private-file.test.ts +80 -0
- package/src/private-file.ts +50 -0
- package/src/release-contract.ts +3 -0
- package/src/runtime-secrets.test.ts +9 -81
- package/src/runtime-secrets.ts +2 -6
- package/src/self-hosted-compose.test.ts +1 -1
- package/src/self-hosted-roles.integration.test.ts +23 -38
- package/src/self-hosted-roles.test.ts +11 -7
- package/src/self-hosted-roles.ts +17 -16
- package/src/self-hosted-upgrade.test.ts +28 -1
- package/src/self-hosted-upgrade.ts +29 -4
- package/src/stack-contract.test.ts +19 -11
- package/src/stack-contract.ts +54 -5
- package/src/stack-converge-secrets.test.ts +2 -0
- package/src/stack-converge.test.ts +3 -1
- package/src/tar-entries.test.ts +149 -7
- package/src/tar-entries.ts +90 -9
- package/src/upgrade/app-roll.ts +39 -0
- package/src/upgrade/audit-job-roll.ts +68 -0
- package/src/upgrade/converge.ts +47 -20
- package/src/upgrade/upgrade.test.ts +76 -2
- package/src/upgrader-assess.test.ts +1 -0
- package/src/upgrader-job.test.ts +1 -0
- package/src/upgrader-setup.test.ts +1 -0
- package/src/upgrader-setup.ts +9 -2
- package/src/wizards/gcp-cost.ts +3 -3
- package/src/wizards/gcp-pulumi.ts +7 -0
- package/src/wizards/gcp.ts +38 -31
- package/stacks/aws/README.md +1 -1
- package/stacks/aws/index.ts +9 -6
- package/stacks/azure/README.md +1 -1
- package/stacks/azure/index.ts +18 -6
- package/stacks/gcp/README.md +1 -1
- package/stacks/gcp/index.ts +14 -4
- package/stacks/gcp-firebase/index.ts +14 -4
- package/stacks/self-hosted/index.ts +4 -11
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
closeSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
statSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { tightenPrivateTree, writePrivateFile } from "./private-file.js";
|
|
19
|
+
|
|
20
|
+
let dir: string;
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
dir = mkdtempSync(join(tmpdir(), "caelo-private-file-"));
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
25
|
+
|
|
26
|
+
const mode = (path: string) => statSync(path).mode & 0o777;
|
|
27
|
+
|
|
28
|
+
describe("writePrivateFile", () => {
|
|
29
|
+
it("creates the file owner-only and replaces a world-readable file with an owner-only one", () => {
|
|
30
|
+
const fresh = join(dir, "fresh.json");
|
|
31
|
+
writePrivateFile(fresh, "{}");
|
|
32
|
+
expect(mode(fresh)).toBe(0o600);
|
|
33
|
+
|
|
34
|
+
const legacy = join(dir, "legacy.json");
|
|
35
|
+
writeFileSync(legacy, "{}", { mode: 0o644 });
|
|
36
|
+
chmodSync(legacy, 0o644);
|
|
37
|
+
writePrivateFile(legacy, '{"secret":"x"}');
|
|
38
|
+
expect(mode(legacy)).toBe(0o600);
|
|
39
|
+
expect(readFileSync(legacy, "utf8")).toBe('{"secret":"x"}');
|
|
40
|
+
expect(readdirSync(dir).sort()).toEqual(["fresh.json", "legacy.json"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("never writes the new secret into the old world-readable inode", () => {
|
|
44
|
+
// A local user who opened the 0644 file before the rewrite keeps that
|
|
45
|
+
// descriptor; a write-then-chmod would let it read the new secret.
|
|
46
|
+
const legacy = join(dir, "internal-secret.json");
|
|
47
|
+
writeFileSync(legacy, "old");
|
|
48
|
+
chmodSync(legacy, 0o644);
|
|
49
|
+
const fd = openSync(legacy, "r");
|
|
50
|
+
try {
|
|
51
|
+
writePrivateFile(legacy, "new-secret");
|
|
52
|
+
const buf = Buffer.alloc(64);
|
|
53
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
54
|
+
expect(buf.subarray(0, n).toString()).toBe("old");
|
|
55
|
+
} finally {
|
|
56
|
+
closeSync(fd);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("tightenPrivateTree", () => {
|
|
62
|
+
it("takes group/other access off an older install's directory and secrets, skipping missing ones", () => {
|
|
63
|
+
const install = join(dir, ".caelo");
|
|
64
|
+
mkdirSync(join(install, "bootstrap"), { recursive: true });
|
|
65
|
+
chmodSync(install, 0o755);
|
|
66
|
+
chmodSync(join(install, "bootstrap"), 0o755);
|
|
67
|
+
writeFileSync(join(install, "config.json"), "{}");
|
|
68
|
+
chmodSync(join(install, "config.json"), 0o644);
|
|
69
|
+
writeFileSync(join(install, "Caddyfile"), "");
|
|
70
|
+
chmodSync(join(install, "Caddyfile"), 0o644);
|
|
71
|
+
|
|
72
|
+
tightenPrivateTree(install, ["bootstrap", "backups"], ["config.json", "internal-secret.json"]);
|
|
73
|
+
|
|
74
|
+
expect(mode(install)).toBe(0o700);
|
|
75
|
+
expect(mode(join(install, "bootstrap"))).toBe(0o700);
|
|
76
|
+
expect(mode(join(install, "config.json"))).toBe(0o600);
|
|
77
|
+
// Not a secret: the Caddy container reads it through its bind mount.
|
|
78
|
+
expect(mode(join(install, "Caddyfile"))).toBe(0o644);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { chmodSync, existsSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Write a file only its owner may read (0600), replacing any file at `path`.
|
|
7
|
+
*
|
|
8
|
+
* The data goes into a fresh temp file created 0600 (`wx`, so never an
|
|
9
|
+
* existing file or a planted symlink) and is renamed over `path`. Writing in
|
|
10
|
+
* place would not do: `writeFileSync`'s `mode` applies only when the call
|
|
11
|
+
* creates the file, so a secret file an older CLI wrote world-readable would
|
|
12
|
+
* hold the new secret world-readable until a chmod after the write — and a
|
|
13
|
+
* reader that opened it before any chmod keeps its descriptor. The rename
|
|
14
|
+
* gives the new contents a new inode that was never readable by anyone else.
|
|
15
|
+
*/
|
|
16
|
+
export function writePrivateFile(path: string, data: string): void {
|
|
17
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
18
|
+
writeFileSync(tmp, data, { mode: 0o600, flag: "wx" });
|
|
19
|
+
try {
|
|
20
|
+
renameSync(tmp, path);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
rmSync(tmp, { force: true });
|
|
23
|
+
throw e;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Take group/other access off an install's secrets directory and the secret
|
|
29
|
+
* files in it, for installs an older CLI created with the default umask
|
|
30
|
+
* (`.caelo/` 0755, `config.json` / `internal-secret.json` 0644). Those files
|
|
31
|
+
* are written once and rarely rewritten, so tightening only on the next write
|
|
32
|
+
* would leave them readable to every local user. Missing paths are skipped:
|
|
33
|
+
* which secrets exist depends on the provider and how far `init` got.
|
|
34
|
+
*
|
|
35
|
+
* @param dir the install's directory, tightened to 0700
|
|
36
|
+
* @param privateDirs subdirectories (relative to `dir`) tightened to 0700
|
|
37
|
+
* @param privateFiles files (relative to `dir`) tightened to 0600
|
|
38
|
+
*/
|
|
39
|
+
export function tightenPrivateTree(
|
|
40
|
+
dir: string,
|
|
41
|
+
privateDirs: readonly string[],
|
|
42
|
+
privateFiles: readonly string[],
|
|
43
|
+
): void {
|
|
44
|
+
const tighten = (path: string, mode: number) => {
|
|
45
|
+
if (existsSync(path) && (statSync(path).mode & 0o077) !== 0) chmodSync(path, mode);
|
|
46
|
+
};
|
|
47
|
+
tighten(dir, 0o700);
|
|
48
|
+
for (const sub of privateDirs) tighten(`${dir}/${sub}`, 0o700);
|
|
49
|
+
for (const file of privateFiles) tighten(`${dir}/${file}`, 0o600);
|
|
50
|
+
}
|
package/src/release-contract.ts
CHANGED
|
@@ -49,6 +49,9 @@ export const INFRA_CONTRACT_PATHS: readonly string[] = [
|
|
|
49
49
|
// The in-cloud upgrader job's service account, custom roles and bucket
|
|
50
50
|
// grants (#799 added one without the checks noticing).
|
|
51
51
|
"packages/provisioning/src/upgrader-setup.ts",
|
|
52
|
+
// #875 — the quality-audit job, its service account, results bucket and
|
|
53
|
+
// grants.
|
|
54
|
+
"packages/provisioning/src/audit-job-setup.ts",
|
|
52
55
|
];
|
|
53
56
|
|
|
54
57
|
/**
|
|
@@ -115,13 +115,7 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
115
115
|
{ ...secretsInstall, replication: regional },
|
|
116
116
|
{ run },
|
|
117
117
|
);
|
|
118
|
-
expect(outcomes.map((o) => o.status)).toEqual([
|
|
119
|
-
"present",
|
|
120
|
-
"present",
|
|
121
|
-
"present",
|
|
122
|
-
"present",
|
|
123
|
-
"present",
|
|
124
|
-
]);
|
|
118
|
+
expect(outcomes.map((o) => o.status)).toEqual(["present", "present", "present", "present"]);
|
|
125
119
|
expect(lines().some((l) => l.includes(" create ") || l.includes(" add "))).toBe(false);
|
|
126
120
|
});
|
|
127
121
|
|
|
@@ -132,13 +126,7 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
132
126
|
{ ...secretsInstall, replication: regional },
|
|
133
127
|
{ run, generate: () => `generated-value-${++n}` },
|
|
134
128
|
);
|
|
135
|
-
expect(outcomes.map((o) => o.status)).toEqual([
|
|
136
|
-
"applied",
|
|
137
|
-
"applied",
|
|
138
|
-
"applied",
|
|
139
|
-
"applied",
|
|
140
|
-
"applied",
|
|
141
|
-
]);
|
|
129
|
+
expect(outcomes.map((o) => o.status)).toEqual(["applied", "applied", "applied", "applied"]);
|
|
142
130
|
const creates = calls.filter((c) => c.line.startsWith("secrets create"));
|
|
143
131
|
expect(creates).toEqual([
|
|
144
132
|
{
|
|
@@ -149,19 +137,15 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
149
137
|
line: "secrets create caelo-production-tool-approval-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
150
138
|
stdin: "generated-value-2",
|
|
151
139
|
},
|
|
152
|
-
// #613 — the gateway's role
|
|
140
|
+
// #613 — the gateway's role password: a fresh value, never admin_role's.
|
|
153
141
|
{
|
|
154
142
|
line: "secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
155
143
|
stdin: "generated-value-3",
|
|
156
144
|
},
|
|
157
|
-
{
|
|
158
|
-
line: "secrets create caelo-production-gateway-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
159
|
-
stdin: "generated-value-4",
|
|
160
|
-
},
|
|
161
145
|
// #650 — the staging access gate's key.
|
|
162
146
|
{
|
|
163
147
|
line: "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
164
|
-
stdin: "generated-value-
|
|
148
|
+
stdin: "generated-value-4",
|
|
165
149
|
},
|
|
166
150
|
]);
|
|
167
151
|
for (const c of calls) expect(c.line).not.toContain("generated-value");
|
|
@@ -186,7 +170,6 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
186
170
|
"generated",
|
|
187
171
|
"generated",
|
|
188
172
|
"generated",
|
|
189
|
-
"generated",
|
|
190
173
|
]);
|
|
191
174
|
for (const c of calls) expect(c.line).not.toContain("operator-set");
|
|
192
175
|
});
|
|
@@ -197,20 +180,13 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
197
180
|
"secrets versions list caelo-production-internal-secret": [ok("")],
|
|
198
181
|
"secrets versions list caelo-production-tool-approval-secret": [ok("v/1")],
|
|
199
182
|
"secrets versions list caelo-production-public-role-password": [ok("v/1")],
|
|
200
|
-
"secrets versions list caelo-production-gateway-role-password": [ok("v/1")],
|
|
201
183
|
"secrets versions list caelo-production-staging-gate-secret": [ok("v/1")],
|
|
202
184
|
});
|
|
203
185
|
const outcomes = await ensureGeneratedSecrets(
|
|
204
186
|
{ ...secretsInstall, replication: { kind: "automatic" } },
|
|
205
187
|
{ run, generate: () => "v" },
|
|
206
188
|
);
|
|
207
|
-
expect(outcomes.map((o) => o.status)).toEqual([
|
|
208
|
-
"applied",
|
|
209
|
-
"present",
|
|
210
|
-
"present",
|
|
211
|
-
"present",
|
|
212
|
-
"present",
|
|
213
|
-
]);
|
|
189
|
+
expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "present"]);
|
|
214
190
|
expect(calls.filter((c) => c.line.startsWith("secrets versions add"))).toEqual([
|
|
215
191
|
{
|
|
216
192
|
line: "secrets versions add caelo-production-internal-secret --project=acme --data-file=-",
|
|
@@ -244,13 +220,7 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
244
220
|
{ ...secretsInstall, replication: { kind: "automatic" } },
|
|
245
221
|
{ run, generate: () => "v" },
|
|
246
222
|
);
|
|
247
|
-
expect(outcomes.map((o) => o.status)).toEqual([
|
|
248
|
-
"applied",
|
|
249
|
-
"applied",
|
|
250
|
-
"applied",
|
|
251
|
-
"applied",
|
|
252
|
-
"applied",
|
|
253
|
-
]);
|
|
223
|
+
expect(outcomes.map((o) => o.status)).toEqual(["applied", "applied", "applied", "applied"]);
|
|
254
224
|
expect(outcomes.every((o) => o.detail?.includes("gcp.resourceLocations"))).toBe(true);
|
|
255
225
|
const creates = lines().filter((l) => l.startsWith("secrets create"));
|
|
256
226
|
// One refused automatic attempt; then every secret — the refused one
|
|
@@ -260,7 +230,6 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
260
230
|
"secrets create caelo-production-internal-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
261
231
|
"secrets create caelo-production-tool-approval-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
262
232
|
"secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
263
|
-
"secrets create caelo-production-gateway-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
264
233
|
// #652 — the staging gate's key follows the same path.
|
|
265
234
|
"secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
|
|
266
235
|
]);
|
|
@@ -290,7 +259,7 @@ describe("ensureGeneratedSecrets", () => {
|
|
|
290
259
|
{ run, generate: () => "v" },
|
|
291
260
|
);
|
|
292
261
|
expect(outcomes.every((o) => o.status === "failed")).toBe(true);
|
|
293
|
-
expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(
|
|
262
|
+
expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(4);
|
|
294
263
|
});
|
|
295
264
|
});
|
|
296
265
|
|
|
@@ -299,7 +268,6 @@ describe("rotationRefusal", () => {
|
|
|
299
268
|
for (const s of [
|
|
300
269
|
"postgres-password",
|
|
301
270
|
"public-role-password",
|
|
302
|
-
"gateway-role-password",
|
|
303
271
|
"internal-secret",
|
|
304
272
|
"tool-approval-secret",
|
|
305
273
|
"staging-gate-secret",
|
|
@@ -311,6 +279,8 @@ describe("rotationRefusal", () => {
|
|
|
311
279
|
it("refuses the KEK (re-encryption needed) and secrets nothing reads", () => {
|
|
312
280
|
expect(rotationRefusal("secret-kek")).toContain("re-encryption");
|
|
313
281
|
expect(rotationRefusal("csrf-secret")).toContain("Rotatable");
|
|
282
|
+
// #635 — the former gateway_role is gone; there is nothing to rotate.
|
|
283
|
+
expect(rotationRefusal("gateway-role-password")).toContain("Rotatable");
|
|
314
284
|
});
|
|
315
285
|
});
|
|
316
286
|
|
|
@@ -411,28 +381,6 @@ describe("rotateRuntimeSecret", () => {
|
|
|
411
381
|
).toBe(true);
|
|
412
382
|
});
|
|
413
383
|
|
|
414
|
-
it("gateway-role-password: changes gateway_role only and rolls no service (#613, #635)", async () => {
|
|
415
|
-
const { run, lines } = fakeGcloud({
|
|
416
|
-
...converged(),
|
|
417
|
-
"secrets versions access": [ok("old-pw")],
|
|
418
|
-
});
|
|
419
|
-
const sql = fakeSqlApi();
|
|
420
|
-
const report = await rotateRuntimeSecret(target, "gateway-role-password", {
|
|
421
|
-
run,
|
|
422
|
-
http: sql.http,
|
|
423
|
-
sleep: async () => {},
|
|
424
|
-
generate: () => "new-pw",
|
|
425
|
-
});
|
|
426
|
-
expect(report.ok).toBe(true);
|
|
427
|
-
expect(sql.passwords()).toEqual(["gateway_role new-pw"]);
|
|
428
|
-
// No service logs in as gateway_role any more: the gateway reads
|
|
429
|
-
// cms_public only (#635).
|
|
430
|
-
expect(lines().filter((l) => l.startsWith("run services update "))).toEqual([]);
|
|
431
|
-
expect(lines()).toContain(
|
|
432
|
-
"secrets versions access latest --secret=caelo-production-gateway-role-password --project=acme",
|
|
433
|
-
);
|
|
434
|
-
});
|
|
435
|
-
|
|
436
384
|
it("public-role-password: stores nothing and rolls nothing when public_role can't be changed", async () => {
|
|
437
385
|
const { run, lines } = fakeGcloud({
|
|
438
386
|
...converged(),
|
|
@@ -474,26 +422,6 @@ describe("rotateRuntimeSecret", () => {
|
|
|
474
422
|
expect(lines().every((l) => l.startsWith("run services describe"))).toBe(true);
|
|
475
423
|
});
|
|
476
424
|
|
|
477
|
-
it("refuses gateway-role-password while the gateway still logs in with it (before #635)", async () => {
|
|
478
|
-
const answers = converged();
|
|
479
|
-
answers["run services describe gw-svc"] = [
|
|
480
|
-
service([
|
|
481
|
-
ref("GATEWAY_DATABASE_PASSWORD", "gateway-role-password"),
|
|
482
|
-
ref("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
|
|
483
|
-
]),
|
|
484
|
-
];
|
|
485
|
-
const { run } = fakeGcloud(answers);
|
|
486
|
-
const sql = fakeSqlApi();
|
|
487
|
-
const report = await rotateRuntimeSecret(target, "gateway-role-password", {
|
|
488
|
-
run,
|
|
489
|
-
http: sql.http,
|
|
490
|
-
generate: () => "new-pw",
|
|
491
|
-
});
|
|
492
|
-
expect(report.ok).toBe(false);
|
|
493
|
-
expect(report.error).toContain("GATEWAY_DATABASE_PASSWORD");
|
|
494
|
-
expect(sql.requests).toHaveLength(0);
|
|
495
|
-
});
|
|
496
|
-
|
|
497
425
|
it("refuses before changing anything on an install upgrade hasn't moved over", async () => {
|
|
498
426
|
const { run, lines } = fakeGcloud({
|
|
499
427
|
"run services describe adm-svc": [
|
package/src/runtime-secrets.ts
CHANGED
|
@@ -13,9 +13,8 @@
|
|
|
13
13
|
* Secret Manager on stdin — never on argv, never to disk.
|
|
14
14
|
* - {@link ensureDatabaseRolePassword}: a database role's password set
|
|
15
15
|
* from its secret (`DATABASE_ROLE_SECRET`), through the Cloud SQL Admin
|
|
16
|
-
* API — how `
|
|
17
|
-
*
|
|
18
|
-
* get theirs.
|
|
16
|
+
* API — how `public_role` (moved off admin_role's password, #613) gets
|
|
17
|
+
* its own.
|
|
19
18
|
* - {@link rotateRuntimeSecret}: `cms-provision rotate-secret`.
|
|
20
19
|
*
|
|
21
20
|
* Every function reports instead of throwing on a gcloud failure.
|
|
@@ -368,7 +367,6 @@ async function addVersion(
|
|
|
368
367
|
export const ROTATABLE_SECRETS = [
|
|
369
368
|
"postgres-password",
|
|
370
369
|
"public-role-password",
|
|
371
|
-
"gateway-role-password",
|
|
372
370
|
"internal-secret",
|
|
373
371
|
"tool-approval-secret",
|
|
374
372
|
// #650 — rotating it signs every staging session out and replaces the
|
|
@@ -726,8 +724,6 @@ export async function readSecretValue(
|
|
|
726
724
|
* the role already has is a no-op for every client, so upgrade runs this on
|
|
727
725
|
* every roll and an install converges whatever state it was left in.
|
|
728
726
|
*
|
|
729
|
-
* The role must exist: `gateway_role` is created by migration 0248, so this
|
|
730
|
-
* runs after migrations.
|
|
731
727
|
*/
|
|
732
728
|
export async function ensureDatabaseRolePassword(
|
|
733
729
|
target: DatabaseRoleTarget,
|
|
@@ -19,7 +19,7 @@ const GATEWAY = `ghcr.io/caelo-cms/gateway@sha256:${"b".repeat(64)}`;
|
|
|
19
19
|
const base: Omit<ComposeSpec, "images"> = {
|
|
20
20
|
domain: "example.com",
|
|
21
21
|
postgresPassword: "pgpw",
|
|
22
|
-
rolePasswords: { admin: "adminpw", public: "publicpw"
|
|
22
|
+
rolePasswords: { admin: "adminpw", public: "publicpw" },
|
|
23
23
|
caeloSecretKek: "0".repeat(64),
|
|
24
24
|
anthropicApiKey: "sk-ant-x",
|
|
25
25
|
diskSize: "20Gi",
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* #613 review — `cms-provision up` on a self-hosted volume
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* #613 review, #635 — `cms-provision up` on a self-hosted volume from
|
|
5
|
+
* before: `public_role` on another password, and the gateway's former
|
|
6
|
+
* `gateway_role` login still there with its grants. The convergence
|
|
7
|
+
* script, run as the superuser exactly as `up` runs it (psql, script on
|
|
8
|
+
* stdin), must leave the roles on the configured passwords and the former
|
|
9
|
+
* gateway_role gone — and be safe to re-run.
|
|
9
10
|
*
|
|
10
11
|
* Runs against the test database with the passwords the environment already
|
|
11
12
|
* uses, so the other suites' connection URLs keep working afterwards.
|
|
@@ -25,9 +26,8 @@ const required = [
|
|
|
25
26
|
"POSTGRES_PORT",
|
|
26
27
|
"ADMIN_ROLE_PASSWORD",
|
|
27
28
|
"PUBLIC_ROLE_PASSWORD",
|
|
28
|
-
"GATEWAY_ROLE_PASSWORD",
|
|
29
29
|
"ADMIN_DATABASE_URL",
|
|
30
|
-
"
|
|
30
|
+
"PUBLIC_DATABASE_URL",
|
|
31
31
|
] as const;
|
|
32
32
|
const missing = required.filter((name) => !env[name]);
|
|
33
33
|
if (missing.length > 0) throw new Error(`${missing.join(", ")} required (see .env.example)`);
|
|
@@ -63,19 +63,20 @@ const psql: SuperuserPsql = async (script) => {
|
|
|
63
63
|
const passwords = {
|
|
64
64
|
admin: env.ADMIN_ROLE_PASSWORD as string,
|
|
65
65
|
public: env.PUBLIC_ROLE_PASSWORD as string,
|
|
66
|
-
gateway: env.GATEWAY_ROLE_PASSWORD as string,
|
|
67
66
|
};
|
|
68
67
|
|
|
69
|
-
describe("self-hosted role convergence against Postgres (#613 review)", () => {
|
|
70
|
-
it("brings
|
|
71
|
-
// The
|
|
68
|
+
describe("self-hosted role convergence against Postgres (#613 review, #635)", () => {
|
|
69
|
+
it("brings an old volume to the configured roles and passwords, without gateway_role — twice", async () => {
|
|
70
|
+
// The old volume: the former gateway_role with a cms_admin grant, and
|
|
71
|
+
// public_role on another password.
|
|
72
72
|
const reset = await psql(
|
|
73
73
|
[
|
|
74
74
|
"\\set ON_ERROR_STOP on",
|
|
75
|
+
"SELECT 'CREATE ROLE gateway_role NOINHERIT LOGIN' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') \\gexec",
|
|
76
|
+
"GRANT CONNECT ON DATABASE cms_admin TO gateway_role;",
|
|
75
77
|
"\\connect cms_admin",
|
|
76
|
-
"
|
|
78
|
+
"GRANT SELECT (gateway_cookie_secret) ON site_settings TO gateway_role;",
|
|
77
79
|
"\\connect postgres",
|
|
78
|
-
"DROP ROLE gateway_role;",
|
|
79
80
|
"ALTER ROLE public_role WITH PASSWORD 'the-old-shared-password';",
|
|
80
81
|
"",
|
|
81
82
|
].join("\n"),
|
|
@@ -87,36 +88,20 @@ describe("self-hosted role convergence against Postgres (#613 review)", () => {
|
|
|
87
88
|
expect(r).toEqual({ ok: true });
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
//
|
|
91
|
-
const
|
|
91
|
+
// public_role logs in with its configured password again.
|
|
92
|
+
const pub = new SQL(env.PUBLIC_DATABASE_URL as string);
|
|
92
93
|
try {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
return tx`SELECT current_user::text AS u, length(gateway_cookie_secret) > 0 AS s FROM site_settings`;
|
|
96
|
-
})) as { u: string; s: boolean }[];
|
|
97
|
-
expect(rows[0]).toEqual({ u: "gateway_role", s: true });
|
|
94
|
+
const [who] = (await pub`SELECT current_user::text AS u`) as { u: string }[];
|
|
95
|
+
expect(who?.u).toBe("public_role");
|
|
98
96
|
} finally {
|
|
99
|
-
await
|
|
97
|
+
await pub.end();
|
|
100
98
|
}
|
|
99
|
+
// The former gateway_role is gone, with its grants (#635).
|
|
101
100
|
const admin = new SQL(env.ADMIN_DATABASE_URL as string);
|
|
102
101
|
try {
|
|
103
|
-
const
|
|
104
|
-
SELECT
|
|
105
|
-
|
|
106
|
-
rolinherit: boolean;
|
|
107
|
-
}[];
|
|
108
|
-
expect(attrs).toEqual({ rolcanlogin: true, rolinherit: false });
|
|
109
|
-
const policies = (await admin`
|
|
110
|
-
SELECT policyname::text AS p FROM pg_policies WHERE 'gateway_role' = ANY (roles) ORDER BY 1`) as {
|
|
111
|
-
p: string;
|
|
112
|
-
}[];
|
|
113
|
-
expect(policies.map((p) => p.p)).toEqual([
|
|
114
|
-
"audit_events_gateway_plugin_only",
|
|
115
|
-
// #634 (migration 0252): the visitor paths' row limits. The A/B
|
|
116
|
-
// ones went with the experiment tables (#676, migration 0259).
|
|
117
|
-
"comment_archive_gateway_approved_only",
|
|
118
|
-
"rate_limit_buckets_gateway_keys",
|
|
119
|
-
]);
|
|
102
|
+
const roles =
|
|
103
|
+
(await admin`SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role'`) as unknown[];
|
|
104
|
+
expect(roles).toEqual([]);
|
|
120
105
|
} finally {
|
|
121
106
|
await admin.end();
|
|
122
107
|
}
|
|
@@ -8,26 +8,30 @@ import {
|
|
|
8
8
|
selfHostedBootstrapScript,
|
|
9
9
|
} from "./self-hosted-roles.js";
|
|
10
10
|
|
|
11
|
-
const PASSWORDS = { admin: "a'pw", public: "p-pw"
|
|
11
|
+
const PASSWORDS = { admin: "a'pw", public: "p-pw" };
|
|
12
12
|
|
|
13
13
|
describe("roleConvergenceScript (#613 review)", () => {
|
|
14
14
|
const script = roleConvergenceScript(PASSWORDS);
|
|
15
15
|
|
|
16
16
|
it("creates missing roles and (re)sets every role's password, quoted", () => {
|
|
17
|
-
for (const role of ["admin_role", "public_role"
|
|
17
|
+
for (const role of ["admin_role", "public_role"]) {
|
|
18
18
|
expect(script).toContain(
|
|
19
19
|
`WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}')`,
|
|
20
20
|
);
|
|
21
21
|
}
|
|
22
22
|
expect(script).toContain("ALTER ROLE admin_role WITH LOGIN PASSWORD 'a''pw';");
|
|
23
|
-
expect(script).toContain("
|
|
23
|
+
expect(script).not.toContain("CREATE ROLE gateway_role");
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
it("
|
|
26
|
+
it("drops the former gateway_role with what it holds in both databases, and stops on the first error (#635)", () => {
|
|
27
27
|
expect(script.startsWith("\\set ON_ERROR_STOP on")).toBe(true);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
);
|
|
28
|
+
const publicDrop = script.indexOf("\\connect cms_public");
|
|
29
|
+
const adminDrop = script.indexOf("\\connect cms_admin");
|
|
30
|
+
expect(publicDrop).toBeGreaterThan(-1);
|
|
31
|
+
expect(adminDrop).toBeGreaterThan(publicDrop);
|
|
32
|
+
expect(script.match(/DROP OWNED BY gateway_role/g)).toHaveLength(2);
|
|
33
|
+
expect(script.indexOf("THEN DROP ROLE gateway_role;")).toBeGreaterThan(adminDrop);
|
|
34
|
+
expect(script).not.toContain("caelo_grant_gateway_role");
|
|
31
35
|
});
|
|
32
36
|
});
|
|
33
37
|
|
package/src/self-hosted-roles.ts
CHANGED
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
* bootstrap.sh creates the application roles, but only on the Postgres
|
|
8
8
|
* container's FIRST start (docker-entrypoint-initdb.d). An install created
|
|
9
9
|
* before the per-role passwords has a data volume whose roles have other
|
|
10
|
-
* passwords
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* gateway
|
|
10
|
+
* passwords, so emitting Compose URLs with the new passwords would lock the
|
|
11
|
+
* admin and the gateway out on the next restart. `cms-provision up`
|
|
12
|
+
* therefore runs {@link roleConvergenceScript} through the running container
|
|
13
|
+
* as the Postgres superuser BEFORE it writes the new Compose file: it
|
|
14
|
+
* creates missing roles and databases and sets each role's password to the
|
|
15
|
+
* configured one. It also drops the former `gateway_role` (#635: the
|
|
16
|
+
* gateway has no cms_admin login), with whatever it still holds. Every
|
|
17
|
+
* statement is idempotent.
|
|
17
18
|
*
|
|
18
19
|
* Passwords travel on psql's stdin, never on argv.
|
|
19
20
|
*/
|
|
@@ -34,7 +35,7 @@ function literal(s: string): string {
|
|
|
34
35
|
export const AGENT_DB_ROLE = "caelo_agent";
|
|
35
36
|
|
|
36
37
|
/** The application roles bootstrap.sh and the convergence create. */
|
|
37
|
-
const APP_ROLES = ["admin_role", "public_role"
|
|
38
|
+
const APP_ROLES = ["admin_role", "public_role"] as const;
|
|
38
39
|
type AppRole = (typeof APP_ROLES)[number];
|
|
39
40
|
|
|
40
41
|
/** How a role's password appears in the SQL: a literal, or a psql variable. */
|
|
@@ -69,12 +70,14 @@ function convergenceSql(password: PasswordSql, agent: boolean): string {
|
|
|
69
70
|
"REVOKE ALL ON DATABASE cms_public FROM PUBLIC;",
|
|
70
71
|
"GRANT CONNECT ON DATABASE cms_admin TO admin_role;",
|
|
71
72
|
"GRANT CONNECT ON DATABASE cms_public TO public_role, admin_role;",
|
|
73
|
+
// #635 — the gateway's former cms_admin login goes, with whatever it
|
|
74
|
+
// still holds in either database (the superuser may drop what
|
|
75
|
+
// migration 0285 could only revoke).
|
|
76
|
+
"\\connect cms_public",
|
|
77
|
+
"DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
|
|
72
78
|
"\\connect cms_admin",
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
"DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'caelo_grant_gateway_role') THEN PERFORM caelo_grant_gateway_role(); END IF; END $$;",
|
|
76
|
-
// Migration 0251's staging-gate grants (issue #650), same convergence.
|
|
77
|
-
"DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'caelo_grant_gateway_staging_access') THEN PERFORM caelo_grant_gateway_staging_access(); END IF; END $$;",
|
|
79
|
+
"DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP OWNED BY gateway_role; END IF; END $$;",
|
|
80
|
+
"DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gateway_role') THEN DROP ROLE gateway_role; END IF; END $$;",
|
|
78
81
|
// Migrations 0267 + 0270 (#696): the upgrader role's grants, applied
|
|
79
82
|
// now that the role exists.
|
|
80
83
|
"DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'caelo_grant_platform_updater') THEN PERFORM caelo_grant_platform_updater(); END IF; END $$;",
|
|
@@ -84,7 +87,7 @@ function convergenceSql(password: PasswordSql, agent: boolean): string {
|
|
|
84
87
|
|
|
85
88
|
/**
|
|
86
89
|
* The psql script (run as the superuser, connected to any database) that
|
|
87
|
-
* brings roles
|
|
90
|
+
* brings roles and databases to the configured state.
|
|
88
91
|
* Mirrors bootstrap.sh, plus the password reset bootstrap.sh never does.
|
|
89
92
|
* With `agentPassword`, also the host agent's login (#696 Phase 4c).
|
|
90
93
|
*/
|
|
@@ -92,7 +95,6 @@ export function roleConvergenceScript(passwords: RolePasswords, agentPassword?:
|
|
|
92
95
|
const byRole: Record<AppRole, string> = {
|
|
93
96
|
admin_role: passwords.admin,
|
|
94
97
|
public_role: passwords.public,
|
|
95
|
-
gateway_role: passwords.gateway,
|
|
96
98
|
};
|
|
97
99
|
return convergenceSql(
|
|
98
100
|
(role) => literal(role === AGENT_DB_ROLE ? (agentPassword ?? "") : byRole[role]),
|
|
@@ -104,7 +106,6 @@ export function roleConvergenceScript(passwords: RolePasswords, agentPassword?:
|
|
|
104
106
|
const PASSWORD_ENV: Record<AppRole | typeof AGENT_DB_ROLE, string> = {
|
|
105
107
|
admin_role: "ADMIN_ROLE_PASSWORD",
|
|
106
108
|
public_role: "PUBLIC_ROLE_PASSWORD",
|
|
107
|
-
gateway_role: "GATEWAY_ROLE_PASSWORD",
|
|
108
109
|
[AGENT_DB_ROLE]: "AGENT_ROLE_PASSWORD",
|
|
109
110
|
};
|
|
110
111
|
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
readRelease,
|
|
20
20
|
recordRelease,
|
|
21
21
|
} from "./self-hosted-release.js";
|
|
22
|
+
import type { ConvergeOutcome } from "./self-hosted-roles.js";
|
|
22
23
|
import {
|
|
23
24
|
composePath,
|
|
24
25
|
legacyComposePath,
|
|
@@ -121,11 +122,15 @@ beforeEach(() => {
|
|
|
121
122
|
});
|
|
122
123
|
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
123
124
|
|
|
124
|
-
function deps(runner: HostRunner) {
|
|
125
|
+
function deps(runner: HostRunner & { calls: string[] }, roles: ConvergeOutcome = { ok: true }) {
|
|
125
126
|
return {
|
|
126
127
|
caeloDir: dir,
|
|
127
128
|
runner,
|
|
128
129
|
renderCompose: render,
|
|
130
|
+
convergeRoles: async () => {
|
|
131
|
+
runner.calls.push("converge roles");
|
|
132
|
+
return roles;
|
|
133
|
+
},
|
|
129
134
|
log,
|
|
130
135
|
fetchImpl: registry,
|
|
131
136
|
sleep: async () => {},
|
|
@@ -151,6 +156,7 @@ describe("upgradeSelfHosted", () => {
|
|
|
151
156
|
"pull",
|
|
152
157
|
"migrate admin",
|
|
153
158
|
"migrate public",
|
|
159
|
+
"converge roles",
|
|
154
160
|
"up caelo-admin",
|
|
155
161
|
"health admin",
|
|
156
162
|
"up caelo-gateway",
|
|
@@ -269,6 +275,27 @@ describe("upgradeSelfHosted", () => {
|
|
|
269
275
|
expect(readFileSync(composePath(dir), "utf8")).toBe(render(OLD));
|
|
270
276
|
});
|
|
271
277
|
|
|
278
|
+
it("converges the database roles on every upgrade, after the migrations (#635: drops gateway_role)", async () => {
|
|
279
|
+
const runner = fakeRunner();
|
|
280
|
+
expect((await upgradeSelfHosted({ version: "0.11.0" }, deps(runner))).ok).toBe(true);
|
|
281
|
+
const s = steps(runner.calls);
|
|
282
|
+
expect(s.indexOf("converge roles")).toBeGreaterThan(s.indexOf("migrate public"));
|
|
283
|
+
expect(s.indexOf("converge roles")).toBeLessThan(s.indexOf("up caelo-admin"));
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("restores the Compose file and restarts nothing when the role convergence fails", async () => {
|
|
287
|
+
const runner = fakeRunner();
|
|
288
|
+
const r = await upgradeSelfHosted(
|
|
289
|
+
{ version: "0.11.0" },
|
|
290
|
+
deps(runner, { ok: false, reachable: true, error: "role is busy" }),
|
|
291
|
+
);
|
|
292
|
+
expect(r).toMatchObject({ ok: false, stage: "roles", previousRunning: true });
|
|
293
|
+
if (!r.ok) expect(r.error).toContain("role is busy");
|
|
294
|
+
expect(steps(runner.calls).some((x) => x.startsWith("up "))).toBe(false);
|
|
295
|
+
expect(readFileSync(composePath(dir), "utf8")).toBe(render(OLD));
|
|
296
|
+
expect(readRelease(dir)?.version).toBe("0.10.0");
|
|
297
|
+
});
|
|
298
|
+
|
|
272
299
|
it("rolls back to the previous images when the gateway fails its health check", async () => {
|
|
273
300
|
let compose = "";
|
|
274
301
|
const runner = fakeRunner([
|