@caelo-cms/provisioning 0.10.53 → 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-db.js +196 -109
- 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/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.js +32 -8
- package/dist/cli.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 +7 -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/stack-contract.d.ts +8 -0
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +13 -1
- 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 +33 -2
- 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 +31 -0
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +4 -4
- 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/caddy-redirects.docker.test.ts +19 -6
- package/src/caddy.test.ts +30 -0
- package/src/caddy.ts +43 -0
- package/src/cli-init-validation.test.ts +44 -0
- package/src/cli.ts +36 -7
- package/src/gcp-names.ts +28 -1
- package/src/infra-contract.test.ts +3 -3
- package/src/infra-contract.ts +7 -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/stack-contract.test.ts +4 -0
- package/src/stack-contract.ts +14 -0
- package/src/stack-converge.test.ts +1 -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 +42 -1
- 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 +37 -0
- package/stacks/aws/index.ts +8 -0
- package/stacks/azure/index.ts +9 -0
- package/stacks/gcp/index.ts +13 -0
- package/stacks/gcp-firebase/index.ts +13 -0
package/src/tar-entries.test.ts
CHANGED
|
@@ -35,18 +35,160 @@ describe("listTarEntries", () => {
|
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
it("refuses a truncated archive instead of skipping members", () => {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
expect(() => listTarEntries(header("a.txt", "0", { sizeField: 512 }))).toThrow(/truncated/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("rejects PAX global extended headers ('g')", () => {
|
|
42
|
+
const g = member("PaxHeader", "g", paxLine("path=/etc/passwd"));
|
|
43
|
+
expect(() => listTarEntries(concat(g, member("safe.txt", "0"), END))).toThrow(
|
|
44
|
+
/pax global extended header/,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("refuses a member named by two pax headers — GNU tar keeps the last, bsdtar drops the second", () => {
|
|
49
|
+
// Both tars extract `../../evil` here (the second header carries no
|
|
50
|
+
// path); reading the first header's path would have checked `safe.txt`.
|
|
51
|
+
const archive = concat(
|
|
52
|
+
member("PaxHeader", "x", paxLine("path=srv/caelo/safe.txt")),
|
|
53
|
+
member("PaxHeader", "x", paxLine("mtime=1")),
|
|
54
|
+
member("../../evil", "0"),
|
|
55
|
+
END,
|
|
56
|
+
);
|
|
57
|
+
expect(() => listTarEntries(archive)).toThrow(/several extension headers \(xx\)/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("refuses a GNU long name next to a pax path, and two long names", () => {
|
|
61
|
+
// bsdtar extracts the `L` name (`../../evil`), GNU tar the pax path.
|
|
62
|
+
const mixed = concat(
|
|
63
|
+
member("././@LongLink", "L", "../../evil\0"),
|
|
64
|
+
member("PaxHeader", "x", paxLine("path=srv/caelo/safe.txt")),
|
|
65
|
+
member("x", "0"),
|
|
66
|
+
END,
|
|
67
|
+
);
|
|
68
|
+
expect(() => listTarEntries(mixed)).toThrow(/several extension headers \(Lx\)/);
|
|
69
|
+
const twice = concat(
|
|
70
|
+
member("././@LongLink", "L", "../../evil\0"),
|
|
71
|
+
member("././@LongLink", "L", "safe.txt\0"),
|
|
72
|
+
member("x", "0"),
|
|
73
|
+
END,
|
|
74
|
+
);
|
|
75
|
+
expect(() => listTarEntries(twice)).toThrow(/several extension headers \(LL\)/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("still reads a GNU long name together with a long link", () => {
|
|
79
|
+
const archive = concat(
|
|
80
|
+
member("././@LongLink", "K", "target\0"),
|
|
81
|
+
member("././@LongLink", "L", `${"a".repeat(120)}\0`),
|
|
82
|
+
member("short", "2", "", { link: "ignored" }),
|
|
83
|
+
END,
|
|
84
|
+
);
|
|
85
|
+
expect(listTarEntries(archive)).toEqual([
|
|
86
|
+
{ name: "a".repeat(120), type: "symlink", linkTarget: "target" },
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("refuses a header whose checksum does not match — GNU tar would skip it and read its body as headers", () => {
|
|
91
|
+
const archive = concat(
|
|
92
|
+
header("srv/caelo/safe.txt", "0", { sizeField: 512, badChecksum: true }),
|
|
93
|
+
header("../../evil", "0"),
|
|
94
|
+
END,
|
|
95
|
+
);
|
|
96
|
+
expect(() => listTarEntries(archive)).toThrow(/checksum mismatch/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("refuses a directory or symlink with a body — bsdtar reads the next block as a header", () => {
|
|
100
|
+
for (const [name, type] of [
|
|
101
|
+
["srv/caelo/link", "2"],
|
|
102
|
+
["srv/caelo/dir/", "5"],
|
|
103
|
+
] as const) {
|
|
104
|
+
const archive = concat(
|
|
105
|
+
header(name, type, { sizeField: 512, link: "a" }),
|
|
106
|
+
header("../../evil", "0"),
|
|
107
|
+
END,
|
|
108
|
+
);
|
|
109
|
+
expect(() => listTarEntries(archive)).toThrow(/with a body/);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("walks a member's body by its pax size, so a member hidden behind the header size is seen", () => {
|
|
114
|
+
// tar -x reads `safe.txt` as empty (pax size=0) and then extracts the
|
|
115
|
+
// `../../evil` header that the ustar size field claims is file data.
|
|
116
|
+
const archive = concat(
|
|
117
|
+
member("PaxHeader", "x", paxLine("size=0")),
|
|
118
|
+
header("safe.txt", "0", { sizeField: 512 }),
|
|
119
|
+
member("../../evil", "0"),
|
|
120
|
+
END,
|
|
121
|
+
);
|
|
122
|
+
expect(listTarEntries(archive).map((e) => e.name)).toEqual(["safe.txt", "../../evil"]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("rejects GNU sparse pax records and an unreadable pax size", () => {
|
|
126
|
+
const sparse = member("PaxHeader", "x", paxLine("GNU.sparse.name=../../evil"));
|
|
127
|
+
expect(() => listTarEntries(concat(sparse, member("safe.txt", "0")))).toThrow(/GNU sparse/);
|
|
128
|
+
const badSize = member("PaxHeader", "x", paxLine("size=-1"));
|
|
129
|
+
expect(() => listTarEntries(concat(badSize, member("safe.txt", "0")))).toThrow(
|
|
130
|
+
/unreadable pax size/,
|
|
131
|
+
);
|
|
43
132
|
});
|
|
44
133
|
});
|
|
45
134
|
|
|
46
|
-
|
|
47
|
-
|
|
135
|
+
const END = new Uint8Array(1024);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One ustar header block with a valid checksum (unless `badChecksum`).
|
|
139
|
+
* `sizeField` is what the header claims, independent of any body after it.
|
|
140
|
+
*/
|
|
141
|
+
function header(
|
|
142
|
+
name: string,
|
|
143
|
+
typeflag: string,
|
|
144
|
+
opts: { sizeField?: number; link?: string; badChecksum?: boolean } = {},
|
|
145
|
+
): Uint8Array {
|
|
146
|
+
const enc = new TextEncoder();
|
|
147
|
+
const h = new Uint8Array(512);
|
|
148
|
+
h.set(enc.encode(name), 0);
|
|
149
|
+
h.set(enc.encode(`${(opts.sizeField ?? 0).toString(8).padStart(11, "0")}\0`), 124);
|
|
150
|
+
h[156] = typeflag.charCodeAt(0);
|
|
151
|
+
if (opts.link) h.set(enc.encode(opts.link), 157);
|
|
152
|
+
h.set(enc.encode("ustar\0"), 257);
|
|
153
|
+
h.fill(0x20, 148, 156);
|
|
154
|
+
const sum = h.reduce((n, b) => n + b, 0) + (opts.badChecksum ? 1 : 0);
|
|
155
|
+
h.set(enc.encode(`${sum.toString(8).padStart(6, "0")}\0 `), 148);
|
|
156
|
+
return h;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A header plus its padded body. */
|
|
160
|
+
function member(name: string, typeflag: string, bodyText = "", opts: { link?: string } = {}) {
|
|
161
|
+
const body = new TextEncoder().encode(bodyText);
|
|
162
|
+
const out = new Uint8Array(512 + Math.ceil(body.length / 512) * 512);
|
|
163
|
+
out.set(header(name, typeflag, { sizeField: body.length, link: opts.link }), 0);
|
|
164
|
+
out.set(body, 512);
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** One pax record; its leading length counts its own digits. */
|
|
169
|
+
function paxLine(kv: string): string {
|
|
170
|
+
const base = ` ${kv}\n`;
|
|
171
|
+
const len = String(base.length + 2).length + base.length;
|
|
172
|
+
return `${len}${base}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function concat(...parts: Uint8Array[]): Uint8Array {
|
|
176
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
177
|
+
let at = 0;
|
|
178
|
+
for (const p of parts) {
|
|
179
|
+
out.set(p, at);
|
|
180
|
+
at += p.length;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
describe("escapesRoot & normalizeMemberName", () => {
|
|
186
|
+
it("flags absolute, parent-relative, and dot-segment member names", () => {
|
|
48
187
|
expect(escapesRoot("/etc/passwd")).toBe(true);
|
|
49
188
|
expect(escapesRoot("srv/caelo/../../app")).toBe(true);
|
|
189
|
+
expect(escapesRoot(".//../etc/passwd")).toBe(true);
|
|
190
|
+
expect(escapesRoot("a/./b")).toBe(true);
|
|
50
191
|
expect(escapesRoot("./srv/caelo/a..b")).toBe(false);
|
|
192
|
+
expect(normalizeMemberName(".//srv//caelo/")).toBe("srv/caelo");
|
|
51
193
|
});
|
|
52
194
|
});
|
package/src/tar-entries.ts
CHANGED
|
@@ -7,12 +7,34 @@
|
|
|
7
7
|
* otherwise land outside the target directory).
|
|
8
8
|
*
|
|
9
9
|
* Parses ustar headers plus the two extensions GNU tar and bsdtar emit for
|
|
10
|
-
* long names: GNU `L`/`K` records and pax `x
|
|
11
|
-
* `
|
|
10
|
+
* long names: GNU `L`/`K` records and pax `x` records (`path=`, `linkpath=`,
|
|
11
|
+
* `size=`). Anything it cannot read is an error, never a skipped member.
|
|
12
|
+
*
|
|
13
|
+
* The parser must walk the archive exactly as `tar -x` will: a pax `size=`
|
|
14
|
+
* overrides the header's size field for the next member, so ignoring it
|
|
15
|
+
* would let an archive hide a member from this check inside what the parser
|
|
16
|
+
* takes for a body. Records that rename a member in ways it does not model
|
|
17
|
+
* (pax global `g` headers, GNU sparse `GNU.sparse.*` keys) are refused.
|
|
18
|
+
*
|
|
19
|
+
* Wherever GNU tar and bsdtar read the same bytes differently, the archive is
|
|
20
|
+
* refused rather than read one way: no Caelo backup is ever written like that,
|
|
21
|
+
* and whichever reading this module picked, the other tar would extract
|
|
22
|
+
* something it never checked.
|
|
23
|
+
* - A header whose checksum does not match: GNU tar skips it and reads the
|
|
24
|
+
* next block as a header, so a member hidden in the "body" gets extracted.
|
|
25
|
+
* - A directory or symlink with a non-zero size: bsdtar reads no body for
|
|
26
|
+
* them, so the block after the header is the next member's header.
|
|
27
|
+
* - Two name records for one member (two pax `x` headers, two `L`/`K`, or
|
|
28
|
+
* `L`/`K` next to `x`): GNU tar keeps only the last `x` header, bsdtar
|
|
29
|
+
* flags the second as malformed and drops it, and the two tars disagree
|
|
30
|
+
* on whether a GNU long name or a pax path wins.
|
|
12
31
|
*/
|
|
13
32
|
|
|
14
33
|
const BLOCK = 512;
|
|
15
34
|
|
|
35
|
+
/** The extension headers one member may carry: one pax record, or a GNU long name and/or long link. */
|
|
36
|
+
const SINGLE_NAMING = new Set(["x", "L", "K", "LK", "KL"]);
|
|
37
|
+
|
|
16
38
|
export type TarEntryType = "file" | "directory" | "symlink" | "other";
|
|
17
39
|
|
|
18
40
|
export interface TarEntry {
|
|
@@ -30,15 +52,34 @@ export function listTarEntries(tar: Uint8Array): TarEntry[] {
|
|
|
30
52
|
let longLink: string | null = null;
|
|
31
53
|
let paxPath: string | null = null;
|
|
32
54
|
let paxLink: string | null = null;
|
|
55
|
+
let paxSize: number | null = null;
|
|
56
|
+
// The extension headers already seen for the member being read.
|
|
57
|
+
let extensions = "";
|
|
33
58
|
while (offset + BLOCK <= tar.length) {
|
|
34
59
|
const header = tar.subarray(offset, offset + BLOCK);
|
|
35
60
|
if (header.every((b) => b === 0)) break; // end-of-archive marker
|
|
36
|
-
|
|
61
|
+
verifyChecksum(header);
|
|
37
62
|
const typeflag = String.fromCharCode(header[156] ?? 0);
|
|
63
|
+
// A pax `size=` describes the member that follows the pax header, never
|
|
64
|
+
// another extension header (GNU tar applies it the same way).
|
|
65
|
+
const isExtension =
|
|
66
|
+
typeflag === "x" || typeflag === "g" || typeflag === "L" || typeflag === "K";
|
|
67
|
+
const size = !isExtension && paxSize !== null ? paxSize : octal(header.subarray(124, 136));
|
|
38
68
|
const body = tar.subarray(offset + BLOCK, offset + BLOCK + size);
|
|
39
69
|
if (body.length < size) throw new Error("truncated tar archive");
|
|
40
70
|
offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK;
|
|
41
71
|
|
|
72
|
+
if (typeflag === "g") {
|
|
73
|
+
throw new Error("tar archive contains unsupported pax global extended header");
|
|
74
|
+
}
|
|
75
|
+
if (isExtension) {
|
|
76
|
+
extensions += typeflag;
|
|
77
|
+
if (!SINGLE_NAMING.has(extensions)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`tar archive names one member through several extension headers (${extensions}) — tar implementations disagree on which one wins`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
42
83
|
if (typeflag === "L") {
|
|
43
84
|
longName = cString(body);
|
|
44
85
|
continue;
|
|
@@ -47,11 +88,18 @@ export function listTarEntries(tar: Uint8Array): TarEntry[] {
|
|
|
47
88
|
longLink = cString(body);
|
|
48
89
|
continue;
|
|
49
90
|
}
|
|
50
|
-
if (typeflag === "x"
|
|
91
|
+
if (typeflag === "x") {
|
|
51
92
|
const pax = paxRecords(body);
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
93
|
+
if (Object.keys(pax).some((key) => key.startsWith("GNU.sparse."))) {
|
|
94
|
+
throw new Error("tar archive contains an unsupported GNU sparse member");
|
|
95
|
+
}
|
|
96
|
+
paxPath = pax.path ?? null;
|
|
97
|
+
paxLink = pax.linkpath ?? null;
|
|
98
|
+
if (pax.size !== undefined) {
|
|
99
|
+
if (!/^[0-9]+$/.test(pax.size)) {
|
|
100
|
+
throw new Error(`unreadable pax size ${JSON.stringify(pax.size)}`);
|
|
101
|
+
}
|
|
102
|
+
paxSize = Number.parseInt(pax.size, 10);
|
|
55
103
|
}
|
|
56
104
|
continue;
|
|
57
105
|
}
|
|
@@ -60,6 +108,8 @@ export function listTarEntries(tar: Uint8Array): TarEntry[] {
|
|
|
60
108
|
const name = paxPath ?? longName ?? (prefix ? `${prefix}/${shortName}` : shortName);
|
|
61
109
|
const link = paxLink ?? longLink ?? cString(header.subarray(157, 257));
|
|
62
110
|
longName = longLink = paxPath = paxLink = null;
|
|
111
|
+
paxSize = null;
|
|
112
|
+
extensions = "";
|
|
63
113
|
const type: TarEntryType =
|
|
64
114
|
typeflag === "0" || typeflag === "\0" || typeflag === "7"
|
|
65
115
|
? "file"
|
|
@@ -68,6 +118,9 @@ export function listTarEntries(tar: Uint8Array): TarEntry[] {
|
|
|
68
118
|
: typeflag === "2"
|
|
69
119
|
? "symlink"
|
|
70
120
|
: "other";
|
|
121
|
+
if (type !== "file" && size !== 0) {
|
|
122
|
+
throw new Error(`tar member ${JSON.stringify(name)} is a ${type} with a body`);
|
|
123
|
+
}
|
|
71
124
|
entries.push(type === "symlink" ? { name, type, linkTarget: link } : { name, type });
|
|
72
125
|
}
|
|
73
126
|
return entries;
|
|
@@ -75,12 +128,40 @@ export function listTarEntries(tar: Uint8Array): TarEntry[] {
|
|
|
75
128
|
|
|
76
129
|
/** `./a/b/` → `a/b`; the archive root (`./`) → "". */
|
|
77
130
|
export function normalizeMemberName(name: string): string {
|
|
78
|
-
return name
|
|
131
|
+
return name
|
|
132
|
+
.replace(/\/+/g, "/")
|
|
133
|
+
.replace(/^(\.\/)+/, "")
|
|
134
|
+
.replace(/\/+$/, "");
|
|
79
135
|
}
|
|
80
136
|
|
|
81
137
|
/** Whether a member name could write outside the extraction directory. */
|
|
82
138
|
export function escapesRoot(name: string): boolean {
|
|
83
|
-
|
|
139
|
+
const normalized = name.replace(/\/+/g, "/").replace(/^(\.\/)+/, "");
|
|
140
|
+
return (
|
|
141
|
+
name.startsWith("/") || normalized.split("/").some((part) => part === ".." || part === ".")
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Refuse a header whose checksum does not match, as `tar -x` would — except
|
|
147
|
+
* GNU tar then goes on reading at the next block, which this check must not.
|
|
148
|
+
* The sum counts the checksum field as spaces; old tars summed signed bytes,
|
|
149
|
+
* which GNU tar and bsdtar both still accept.
|
|
150
|
+
*/
|
|
151
|
+
function verifyChecksum(header: Uint8Array): void {
|
|
152
|
+
const recorded = cString(header.subarray(148, 156)).trim();
|
|
153
|
+
if (!/^[0-7]+$/.test(recorded)) throw new Error("unreadable tar header checksum");
|
|
154
|
+
let unsigned = 0;
|
|
155
|
+
let signed = 0;
|
|
156
|
+
for (let i = 0; i < BLOCK; i++) {
|
|
157
|
+
const b = i >= 148 && i < 156 ? 0x20 : (header[i] ?? 0);
|
|
158
|
+
unsigned += b;
|
|
159
|
+
signed += b > 127 ? b - 256 : b;
|
|
160
|
+
}
|
|
161
|
+
const expected = Number.parseInt(recorded, 8);
|
|
162
|
+
if (expected !== unsigned && expected !== signed) {
|
|
163
|
+
throw new Error("tar header checksum mismatch — the archive is damaged or was altered");
|
|
164
|
+
}
|
|
84
165
|
}
|
|
85
166
|
|
|
86
167
|
function octal(field: Uint8Array): number {
|
package/src/upgrade/app-roll.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
rollService,
|
|
43
43
|
serviceRollArgs,
|
|
44
44
|
} from "../stack-converge.js";
|
|
45
|
+
import { describeAuditJobImage, setAuditJobImage } from "./audit-job-roll.js";
|
|
45
46
|
import { restoreCommand, type SqlBackup } from "./backup.js";
|
|
46
47
|
import {
|
|
47
48
|
describeServiceJson,
|
|
@@ -366,7 +367,17 @@ export async function executeAppRoll(
|
|
|
366
367
|
}
|
|
367
368
|
|
|
368
369
|
const rolled: RollPlan[] = [];
|
|
370
|
+
/** The audit job's image before this roll moved it (#875); null = not moved. */
|
|
371
|
+
let auditJobPriorImage: string | null = null;
|
|
369
372
|
const rollBack = async (alsoCurrent?: RollPlan): Promise<void> => {
|
|
373
|
+
if (auditJobPriorImage) {
|
|
374
|
+
const back = await setAuditJobImage(run, install, auditJobPriorImage);
|
|
375
|
+
if (back.ok) r.info(` audit job image restored to ${auditJobPriorImage.slice(-19)}`);
|
|
376
|
+
else
|
|
377
|
+
r.error(
|
|
378
|
+
` audit job rollback FAILED (${back.error}) — set its image to ${auditJobPriorImage} by hand`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
370
381
|
const services = alsoCurrent ? [...rolled, alsoCurrent] : rolled;
|
|
371
382
|
if (services.length === 0) return;
|
|
372
383
|
r.warn(`Rolling back ${services.length} service(s) to the revisions that served before...`);
|
|
@@ -447,6 +458,34 @@ export async function executeAppRoll(
|
|
|
447
458
|
rolled.push(p);
|
|
448
459
|
}
|
|
449
460
|
|
|
461
|
+
// #875 — the audit job runs the admin's image; it follows the admin. A
|
|
462
|
+
// failure here does not undo the release: audits then fail loudly with
|
|
463
|
+
// the version mismatch and the fix, until the next upgrade moves it.
|
|
464
|
+
const sAudit = r.step("Moving the quality-audit job onto the admin's image...");
|
|
465
|
+
const described = await describeAuditJobImage(run, install);
|
|
466
|
+
const auditPrior = described.ok ? described.image : null;
|
|
467
|
+
if (!described.ok) {
|
|
468
|
+
sAudit.warned(
|
|
469
|
+
`Could not read the quality-audit job (${described.error}); Stage audits fail until the next upgrade moves it.`,
|
|
470
|
+
);
|
|
471
|
+
} else if (!auditPrior) {
|
|
472
|
+
sAudit.warned(
|
|
473
|
+
"The install has no quality-audit job yet; Stage audits fail until `cms-provision upgrade` sets it up.",
|
|
474
|
+
);
|
|
475
|
+
} else if (auditPrior === admin.imageRef) {
|
|
476
|
+
sAudit.done("Quality-audit job already on the admin's image");
|
|
477
|
+
} else {
|
|
478
|
+
const moved = await setAuditJobImage(run, install, admin.imageRef);
|
|
479
|
+
if (moved.ok) {
|
|
480
|
+
auditJobPriorImage = auditPrior;
|
|
481
|
+
sAudit.done(`Quality-audit job on ${admin.digest.slice(0, 19)}...`);
|
|
482
|
+
} else {
|
|
483
|
+
sAudit.warned(
|
|
484
|
+
`Quality-audit job not moved (${moved.error}); Stage audits fail until the next upgrade moves it.`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
450
489
|
if (deps.health) {
|
|
451
490
|
const s = r.step("Deep health check (database, migrations, version)...");
|
|
452
491
|
// A probe that throws is a failed check: roll back, never leave the
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #875 — the quality-audit job runs the admin image (admin-core
|
|
5
|
+
* quality/audit-task.ts) and refuses a batch from an admin on another
|
|
6
|
+
* version. So every app-roll moves the job onto the admin's new image right
|
|
7
|
+
* after the services rolled, and a rollback moves it back.
|
|
8
|
+
*
|
|
9
|
+
* `run jobs update --image` changes nothing else of the job (identity, env,
|
|
10
|
+
* parallelism), so this stays inside the app-roll's rights: the upgrader's
|
|
11
|
+
* `roles/run.developer` plus actAs on the job's service account
|
|
12
|
+
* (upgrader-setup.ts).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { GcloudRunner } from "../gcloud-retry.js";
|
|
16
|
+
import { auditJobName } from "../gcp-names.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The job's image now; `image: null` only when the install has no audit job
|
|
20
|
+
* yet. Any other gcloud failure (permissions, API, network) is an error, not
|
|
21
|
+
* "no job", so the app-roll reports it instead of a misleading hint.
|
|
22
|
+
*/
|
|
23
|
+
export async function describeAuditJobImage(
|
|
24
|
+
run: GcloudRunner,
|
|
25
|
+
install: { readonly projectId: string; readonly region: string; readonly env: string },
|
|
26
|
+
): Promise<
|
|
27
|
+
| { readonly ok: true; readonly image: string | null }
|
|
28
|
+
| { readonly ok: false; readonly error: string }
|
|
29
|
+
> {
|
|
30
|
+
const r = await run([
|
|
31
|
+
"run",
|
|
32
|
+
"jobs",
|
|
33
|
+
"describe",
|
|
34
|
+
auditJobName(install.env),
|
|
35
|
+
`--region=${install.region}`,
|
|
36
|
+
`--project=${install.projectId}`,
|
|
37
|
+
"--format=value(spec.template.spec.template.spec.containers[0].image)",
|
|
38
|
+
]);
|
|
39
|
+
if (!r.ok) {
|
|
40
|
+
return /NOT_FOUND|not found|could not be found/i.test(r.stderr)
|
|
41
|
+
? { ok: true, image: null }
|
|
42
|
+
: { ok: false, error: r.stderr.trim() };
|
|
43
|
+
}
|
|
44
|
+
const image = r.stdout.trim();
|
|
45
|
+
if (image.length === 0) {
|
|
46
|
+
return { ok: false, error: "the job's description names no container image" };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, image };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Point the audit job at `imageRef`. */
|
|
52
|
+
export async function setAuditJobImage(
|
|
53
|
+
run: GcloudRunner,
|
|
54
|
+
install: { readonly projectId: string; readonly region: string; readonly env: string },
|
|
55
|
+
imageRef: string,
|
|
56
|
+
): Promise<{ readonly ok: boolean; readonly error?: string }> {
|
|
57
|
+
const r = await run([
|
|
58
|
+
"run",
|
|
59
|
+
"jobs",
|
|
60
|
+
"update",
|
|
61
|
+
auditJobName(install.env),
|
|
62
|
+
`--image=${imageRef}`,
|
|
63
|
+
`--region=${install.region}`,
|
|
64
|
+
`--project=${install.projectId}`,
|
|
65
|
+
"--quiet",
|
|
66
|
+
]);
|
|
67
|
+
return r.ok ? { ok: true } : { ok: false, error: r.stderr.trim() };
|
|
68
|
+
}
|
package/src/upgrade/converge.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* CLI-generated runtime secrets, the stack's IAM/CDN/firewall invariants,
|
|
8
8
|
* MCP access through IAP, public_role's Cloud SQL role memberships (#633),
|
|
9
9
|
* the gateway's and public_role's database logins, the operator-access sync
|
|
10
|
-
* job, and dropping bindings nothing reads any
|
|
10
|
+
* job, the quality-audit job (#875), and dropping bindings nothing reads any
|
|
11
|
+
* more.
|
|
11
12
|
*
|
|
12
13
|
* It needs the rights of whoever provisioned the install, so only
|
|
13
14
|
* `cms-provision upgrade` (the operator's own identity) runs it — never the
|
|
@@ -24,6 +25,7 @@
|
|
|
24
25
|
* {@link AppRollHooks} the app-roll calls in between, and a part after it.
|
|
25
26
|
*/
|
|
26
27
|
|
|
28
|
+
import { ensureAuditJob, resolveAdminImage } from "../audit-job-setup.js";
|
|
27
29
|
import { findSqlInstance, planPublicRoleSwitch } from "../gateway-credentials.js";
|
|
28
30
|
import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
|
|
29
31
|
import { upgraderServiceAccountEmail } from "../gcp-names.js";
|
|
@@ -40,6 +42,8 @@ import {
|
|
|
40
42
|
} from "../runtime-secrets.js";
|
|
41
43
|
import { selfCommand } from "../self-command.js";
|
|
42
44
|
import {
|
|
45
|
+
AUDIT_JOB_ENV_VAR,
|
|
46
|
+
AUDIT_RESULTS_BUCKET_ENV_VAR,
|
|
43
47
|
MCP_ENV_VAR,
|
|
44
48
|
OPERATOR_ACCESS_JOB_ENV_VAR,
|
|
45
49
|
UPGRADER_JOB_ENV_VAR,
|
|
@@ -268,6 +272,42 @@ export function startConverge(
|
|
|
268
272
|
incomplete = true;
|
|
269
273
|
}
|
|
270
274
|
|
|
275
|
+
async function auditJob(): Promise<void> {
|
|
276
|
+
// #875 — the job the quality audit fans its Lighthouse runs out to.
|
|
277
|
+
// Deployed with the admin's CURRENT image (the admin still runs it until
|
|
278
|
+
// the app-roll moves both together, and the job refuses a batch from
|
|
279
|
+
// another version). A failure only costs Stage audits, so it warns and
|
|
280
|
+
// the admin keeps its current env.
|
|
281
|
+
const s = r.step("Ensuring the quality-audit job (parallel Lighthouse runs)...");
|
|
282
|
+
const image = await resolveAdminImage(
|
|
283
|
+
{ projectId: install.projectId, region: install.region, env: install.env },
|
|
284
|
+
{ run },
|
|
285
|
+
);
|
|
286
|
+
const aj = image.ok
|
|
287
|
+
? await ensureAuditJob(
|
|
288
|
+
{
|
|
289
|
+
provider: install.provider,
|
|
290
|
+
projectId: install.projectId,
|
|
291
|
+
region: install.region,
|
|
292
|
+
env: install.env,
|
|
293
|
+
adminImageRef: image.imageRef,
|
|
294
|
+
},
|
|
295
|
+
runDeps,
|
|
296
|
+
)
|
|
297
|
+
: { ok: false as const, done: [], error: image.error };
|
|
298
|
+
for (const step of aj.done) r.info(` ${step}`);
|
|
299
|
+
if (aj.ok) {
|
|
300
|
+
s.done("Quality-audit job ready (Stage audits run in parallel)");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
s.warned(`Quality-audit job not set up: ${aj.error}`);
|
|
304
|
+
r.warn(
|
|
305
|
+
"The upgrade continues; Stage audits fail with the missing job until this succeeds. Re-run upgrade once the cause is fixed.",
|
|
306
|
+
);
|
|
307
|
+
incomplete = true;
|
|
308
|
+
leaveUntouched.push(AUDIT_JOB_ENV_VAR, AUDIT_RESULTS_BUCKET_ENV_VAR);
|
|
309
|
+
}
|
|
310
|
+
|
|
271
311
|
/** The admin's IAP resource: the service on gcp-firebase, the LB backend on gcp. */
|
|
272
312
|
async function adminIapResource(): Promise<IapResource | null> {
|
|
273
313
|
if (install.provider === "gcp-firebase") {
|
|
@@ -406,6 +446,7 @@ export function startConverge(
|
|
|
406
446
|
if (!(await publicRoleIsolation())) return false;
|
|
407
447
|
await mcpAccess();
|
|
408
448
|
await urlMapRedirectsAccess();
|
|
449
|
+
await auditJob();
|
|
409
450
|
return true;
|
|
410
451
|
},
|
|
411
452
|
|
|
@@ -60,7 +60,9 @@ const serviceJson = (image: string, env: unknown[]) =>
|
|
|
60
60
|
},
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
/** The admin image the install runs before the upgrade (digest-pinned, as deployed). */
|
|
64
|
+
const OLD_ADMIN_IMAGE = `registry/admin@sha256:${"0".repeat(64)}`;
|
|
65
|
+
const ADMIN_JSON = serviceJson(OLD_ADMIN_IMAGE, [
|
|
64
66
|
{ name: "CAELO_FIREBASE_SITE", value: "caelo-production-site-abc123" },
|
|
65
67
|
{
|
|
66
68
|
name: "ADMIN_DATABASE_URL",
|
|
@@ -98,6 +100,10 @@ function fakeInstall(events: string[], overrides: [RegExp, Answer][] = []) {
|
|
|
98
100
|
[new RegExp(`describe ${ADMIN} .*--format=json`), ok(ADMIN_JSON)],
|
|
99
101
|
[new RegExp(`describe ${GATEWAY} .*--format=json`), ok(GATEWAY_JSON)],
|
|
100
102
|
[/describe (\S+) .*status\.url/, (l) => ok(`https://${l.split(" ")[3]}-xyz.a.run.app\n`)],
|
|
103
|
+
[
|
|
104
|
+
/^run jobs describe caelo-production-audit .*containers\[0\]\.image/,
|
|
105
|
+
ok(`${OLD_ADMIN_IMAGE}\n`),
|
|
106
|
+
],
|
|
101
107
|
[/^sql instances list/, ok(`${SQL}\n`)],
|
|
102
108
|
[/^sql backups list/, ok(`${BACKUP_ID}\n`)],
|
|
103
109
|
[
|
|
@@ -223,7 +229,7 @@ const CONVERGE_WRITE =
|
|
|
223
229
|
/add-iam-policy-binding|remove-iam-policy-binding|set-iam-policy|service-accounts create|secrets create|secrets versions add|services enable|roles create|roles update|roles undelete|firewall-rules delete|users assign-roles/;
|
|
224
230
|
/** Calls that move a service or run migrations — app-roll-only. */
|
|
225
231
|
const APP_ROLL_WRITE =
|
|
226
|
-
/^run services update |^run services update-traffic|jobs execute caelo-migrate/;
|
|
232
|
+
/^run services update |^run services update-traffic|jobs execute caelo-migrate|^run jobs update caelo-production-audit /;
|
|
227
233
|
/** Any gcloud call that changes something. */
|
|
228
234
|
const ANY_WRITE =
|
|
229
235
|
/\b(create|update|update-traffic|delete|add-iam-policy-binding|remove-iam-policy-binding|set-iam-policy|execute|enable|add|restore)\b/;
|
|
@@ -285,6 +291,69 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
|
|
|
285
291
|
);
|
|
286
292
|
});
|
|
287
293
|
|
|
294
|
+
it("sets up the quality-audit job on the CURRENT admin image before the roll, then moves it with the admin (#875)", async () => {
|
|
295
|
+
const events: string[] = [];
|
|
296
|
+
const { deps, lines } = upgradeDeps(events);
|
|
297
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
|
|
298
|
+
const deploy = indexOf(events, /^gcloud run jobs deploy caelo-production-audit /);
|
|
299
|
+
const adminRoll = indexOf(events, new RegExp(`^gcloud run services update ${ADMIN} `));
|
|
300
|
+
const move = indexOf(events, /^gcloud run jobs update caelo-production-audit /);
|
|
301
|
+
const deep = indexOf(events, /^health deep/);
|
|
302
|
+
expect(events[deploy]).toContain(`--image=${OLD_ADMIN_IMAGE}`);
|
|
303
|
+
expect(deploy < adminRoll && adminRoll < move && move < deep).toBe(true);
|
|
304
|
+
expect(events[move]).toContain(`--image=${IMAGES.admin.imageRef}`);
|
|
305
|
+
expect(lines).toContain("done: Quality-audit job ready (Stage audits run in parallel)");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("a failed audit-job setup leaves the converge incomplete and the admin's audit env untouched", async () => {
|
|
309
|
+
const events: string[] = [];
|
|
310
|
+
const { deps, recorded, lines } = upgradeDeps(events, {
|
|
311
|
+
overrides: [[/^run jobs deploy caelo-production-audit/, fail("PERMISSION_DENIED")]],
|
|
312
|
+
});
|
|
313
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
|
|
314
|
+
expect(recorded.contract).toEqual([]);
|
|
315
|
+
expect(lines.some((l) => l.includes("Quality-audit job not set up"))).toBe(true);
|
|
316
|
+
expect(
|
|
317
|
+
events.some((e) => /services update caelo-production-admin.*CAELO_AUDIT_JOB=/.test(e)),
|
|
318
|
+
).toBe(false);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("an unreadable audit job is reported as an error, not as a missing job, and is not moved", async () => {
|
|
322
|
+
const events: string[] = [];
|
|
323
|
+
const { deps, lines } = upgradeDeps(events, {
|
|
324
|
+
overrides: [
|
|
325
|
+
[
|
|
326
|
+
/^run jobs describe caelo-production-audit .*containers\[0\]\.image/,
|
|
327
|
+
fail("PERMISSION_DENIED"),
|
|
328
|
+
],
|
|
329
|
+
],
|
|
330
|
+
});
|
|
331
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
|
|
332
|
+
expect(
|
|
333
|
+
lines.some((l) => l.includes("Could not read the quality-audit job (PERMISSION_DENIED)")),
|
|
334
|
+
).toBe(true);
|
|
335
|
+
expect(lines.some((l) => l.includes("has no quality-audit job yet"))).toBe(false);
|
|
336
|
+
expect(events.some((e) => /^gcloud run jobs update caelo-production-audit /.test(e))).toBe(
|
|
337
|
+
false,
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("a missing audit job is reported as missing", async () => {
|
|
342
|
+
const events: string[] = [];
|
|
343
|
+
const { deps, lines } = upgradeDeps(events, {
|
|
344
|
+
overrides: [
|
|
345
|
+
[
|
|
346
|
+
/^run jobs describe caelo-production-audit .*containers\[0\]\.image/,
|
|
347
|
+
fail(
|
|
348
|
+
"ERROR: (gcloud.run.jobs.describe) Cannot find job [caelo-production-audit]. NOT_FOUND",
|
|
349
|
+
),
|
|
350
|
+
],
|
|
351
|
+
],
|
|
352
|
+
});
|
|
353
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
|
|
354
|
+
expect(lines.some((l) => l.includes("has no quality-audit job yet"))).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
|
|
288
357
|
it("without an ops image the upgrader step fails, the roll goes on, the converge is incomplete", async () => {
|
|
289
358
|
const events: string[] = [];
|
|
290
359
|
const { deps, recorded, lines } = upgradeDeps(events);
|
|
@@ -378,6 +447,11 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
|
|
|
378
447
|
`${ADMIN} --region europe-west1 --project acme --to-revisions ${ADMIN}-00001-old=100`,
|
|
379
448
|
),
|
|
380
449
|
]);
|
|
450
|
+
// The audit job follows the admin back to its prior image (#875).
|
|
451
|
+
const jobBack = events
|
|
452
|
+
.slice(deep)
|
|
453
|
+
.filter((e) => e.startsWith("gcloud run jobs update caelo-production-audit "));
|
|
454
|
+
expect(jobBack).toEqual([expect.stringContaining(`--image=${OLD_ADMIN_IMAGE}`)]);
|
|
381
455
|
expect(recorded.digests).toEqual([]);
|
|
382
456
|
expect(recorded.contract).toEqual([]);
|
|
383
457
|
const restore = `gcloud sql backups restore ${BACKUP_ID} --restore-instance=${SQL} --project=acme`;
|
package/src/upgrader-job.test.ts
CHANGED
|
@@ -83,6 +83,7 @@ describe("ensureUpgraderJob", () => {
|
|
|
83
83
|
"iam service-accounts add-iam-policy-binding caelo-production-gateway-sa@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
|
|
84
84
|
"iam service-accounts add-iam-policy-binding caelo-production-upgrader@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
|
|
85
85
|
"iam service-accounts add-iam-policy-binding caelo-mcp@acme.iam.gserviceaccount.com → roles/iam.serviceAccountTokenCreator",
|
|
86
|
+
"iam service-accounts add-iam-policy-binding caelo-production-audit@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
|
|
86
87
|
"storage buckets add-iam-policy-binding gs://acme-caelo-ops → roles/storage.objectUser",
|
|
87
88
|
"storage buckets add-iam-policy-binding gs://acme-caelo-ops → projects/acme/roles/caeloUpgraderOpsList",
|
|
88
89
|
].sort(),
|