@caelo-cms/provisioning 0.10.53 → 0.10.57

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.
Files changed (244) hide show
  1. package/dist/agent-db.js +272 -128
  2. package/dist/audit-job-setup.d.ts +69 -0
  3. package/dist/audit-job-setup.d.ts.map +1 -0
  4. package/dist/audit-job-setup.js +317 -0
  5. package/dist/audit-job-setup.js.map +1 -0
  6. package/dist/caddy.d.ts +9 -0
  7. package/dist/caddy.d.ts.map +1 -1
  8. package/dist/caddy.js +38 -0
  9. package/dist/caddy.js.map +1 -1
  10. package/dist/cli-routing.js +1 -1
  11. package/dist/cli-routing.js.map +1 -1
  12. package/dist/cli.js +36 -9
  13. package/dist/cli.js.map +1 -1
  14. package/dist/compose.js +5 -0
  15. package/dist/compose.js.map +1 -1
  16. package/dist/converge-checkpoint.d.ts +66 -0
  17. package/dist/converge-checkpoint.d.ts.map +1 -0
  18. package/dist/converge-checkpoint.js +61 -0
  19. package/dist/converge-checkpoint.js.map +1 -0
  20. package/dist/converge-job-gcp.d.ts +46 -0
  21. package/dist/converge-job-gcp.d.ts.map +1 -0
  22. package/dist/converge-job-gcp.js +368 -0
  23. package/dist/converge-job-gcp.js.map +1 -0
  24. package/dist/converge-job-setup.d.ts +62 -0
  25. package/dist/converge-job-setup.d.ts.map +1 -0
  26. package/dist/converge-job-setup.js +228 -0
  27. package/dist/converge-job-setup.js.map +1 -0
  28. package/dist/converge-job.d.ts +141 -0
  29. package/dist/converge-job.d.ts.map +1 -0
  30. package/dist/converge-job.js +325 -0
  31. package/dist/converge-job.js.map +1 -0
  32. package/dist/gcp-names.d.ts +25 -1
  33. package/dist/gcp-names.d.ts.map +1 -1
  34. package/dist/gcp-names.js +36 -0
  35. package/dist/gcp-names.js.map +1 -1
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/infra-contract.d.ts +1 -1
  41. package/dist/infra-contract.d.ts.map +1 -1
  42. package/dist/infra-contract.js +28 -1
  43. package/dist/infra-contract.js.map +1 -1
  44. package/dist/install-job.d.ts +11 -3
  45. package/dist/install-job.d.ts.map +1 -1
  46. package/dist/install-job.js +22 -12
  47. package/dist/install-job.js.map +1 -1
  48. package/dist/lifecycle.d.ts +13 -1
  49. package/dist/lifecycle.d.ts.map +1 -1
  50. package/dist/lifecycle.js +115 -58
  51. package/dist/lifecycle.js.map +1 -1
  52. package/dist/operator-access.d.ts.map +1 -1
  53. package/dist/operator-access.js +13 -3
  54. package/dist/operator-access.js.map +1 -1
  55. package/dist/private-file.d.ts +26 -0
  56. package/dist/private-file.d.ts.map +1 -0
  57. package/dist/private-file.js +49 -0
  58. package/dist/private-file.js.map +1 -0
  59. package/dist/release-contract.d.ts +3 -2
  60. package/dist/release-contract.d.ts.map +1 -1
  61. package/dist/release-contract.js +8 -2
  62. package/dist/release-contract.js.map +1 -1
  63. package/dist/release-manifest.d.ts +2 -0
  64. package/dist/release-manifest.d.ts.map +1 -1
  65. package/dist/release-manifest.js.map +1 -1
  66. package/dist/remote-mcp-edge.d.ts +114 -0
  67. package/dist/remote-mcp-edge.d.ts.map +1 -0
  68. package/dist/remote-mcp-edge.js +116 -0
  69. package/dist/remote-mcp-edge.js.map +1 -0
  70. package/dist/remote-mcp.d.ts +117 -0
  71. package/dist/remote-mcp.d.ts.map +1 -0
  72. package/dist/remote-mcp.js +379 -0
  73. package/dist/remote-mcp.js.map +1 -0
  74. package/dist/runtime-secrets.d.ts +12 -0
  75. package/dist/runtime-secrets.d.ts.map +1 -1
  76. package/dist/runtime-secrets.js +32 -4
  77. package/dist/runtime-secrets.js.map +1 -1
  78. package/dist/seal-key-rotation.d.ts +38 -0
  79. package/dist/seal-key-rotation.d.ts.map +1 -0
  80. package/dist/seal-key-rotation.js +206 -0
  81. package/dist/seal-key-rotation.js.map +1 -0
  82. package/dist/stack-contract.d.ts +175 -3
  83. package/dist/stack-contract.d.ts.map +1 -1
  84. package/dist/stack-contract.js +183 -3
  85. package/dist/stack-contract.js.map +1 -1
  86. package/dist/stack-converge.d.ts +38 -1
  87. package/dist/stack-converge.d.ts.map +1 -1
  88. package/dist/stack-converge.js +74 -4
  89. package/dist/stack-converge.js.map +1 -1
  90. package/dist/state-lock-lease.d.ts +142 -0
  91. package/dist/state-lock-lease.d.ts.map +1 -0
  92. package/dist/state-lock-lease.js +347 -0
  93. package/dist/state-lock-lease.js.map +1 -0
  94. package/dist/state-store.d.ts +66 -9
  95. package/dist/state-store.d.ts.map +1 -1
  96. package/dist/state-store.js +249 -30
  97. package/dist/state-store.js.map +1 -1
  98. package/dist/tar-entries.d.ts.map +1 -1
  99. package/dist/tar-entries.js +84 -9
  100. package/dist/tar-entries.js.map +1 -1
  101. package/dist/upgrade/app-roll.d.ts +36 -7
  102. package/dist/upgrade/app-roll.d.ts.map +1 -1
  103. package/dist/upgrade/app-roll.js +88 -19
  104. package/dist/upgrade/app-roll.js.map +1 -1
  105. package/dist/upgrade/audit-job-roll.d.ts +38 -0
  106. package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
  107. package/dist/upgrade/audit-job-roll.js +43 -0
  108. package/dist/upgrade/audit-job-roll.js.map +1 -0
  109. package/dist/upgrade/converge.d.ts.map +1 -1
  110. package/dist/upgrade/converge.js +141 -8
  111. package/dist/upgrade/converge.js.map +1 -1
  112. package/dist/upgrade/gcp-lookup.d.ts +27 -10
  113. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  114. package/dist/upgrade/gcp-lookup.js +46 -7
  115. package/dist/upgrade/gcp-lookup.js.map +1 -1
  116. package/dist/upgrade/record-rolled.d.ts +40 -0
  117. package/dist/upgrade/record-rolled.d.ts.map +1 -0
  118. package/dist/upgrade/record-rolled.js +46 -0
  119. package/dist/upgrade/record-rolled.js.map +1 -0
  120. package/dist/upgrade/upgrade.d.ts +45 -1
  121. package/dist/upgrade/upgrade.d.ts.map +1 -1
  122. package/dist/upgrade/upgrade.js +60 -17
  123. package/dist/upgrade/upgrade.js.map +1 -1
  124. package/dist/upgrader-assess.d.ts +15 -1
  125. package/dist/upgrader-assess.d.ts.map +1 -1
  126. package/dist/upgrader-assess.js +36 -6
  127. package/dist/upgrader-assess.js.map +1 -1
  128. package/dist/upgrader-gcp.d.ts +15 -0
  129. package/dist/upgrader-gcp.d.ts.map +1 -1
  130. package/dist/upgrader-gcp.js +42 -7
  131. package/dist/upgrader-gcp.js.map +1 -1
  132. package/dist/upgrader-iam-preflight.d.ts +123 -0
  133. package/dist/upgrader-iam-preflight.d.ts.map +1 -0
  134. package/dist/upgrader-iam-preflight.js +414 -0
  135. package/dist/upgrader-iam-preflight.js.map +1 -0
  136. package/dist/upgrader-job.d.ts +33 -9
  137. package/dist/upgrader-job.d.ts.map +1 -1
  138. package/dist/upgrader-job.js +169 -27
  139. package/dist/upgrader-job.js.map +1 -1
  140. package/dist/upgrader-records.d.ts +58 -0
  141. package/dist/upgrader-records.d.ts.map +1 -0
  142. package/dist/upgrader-records.js +87 -0
  143. package/dist/upgrader-records.js.map +1 -0
  144. package/dist/upgrader-reporter.d.ts +3 -2
  145. package/dist/upgrader-reporter.d.ts.map +1 -1
  146. package/dist/upgrader-reporter.js +10 -2
  147. package/dist/upgrader-reporter.js.map +1 -1
  148. package/dist/upgrader-setup.d.ts +25 -3
  149. package/dist/upgrader-setup.d.ts.map +1 -1
  150. package/dist/upgrader-setup.js +132 -14
  151. package/dist/upgrader-setup.js.map +1 -1
  152. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  153. package/dist/wizards/gcp-cost.js +14 -6
  154. package/dist/wizards/gcp-cost.js.map +1 -1
  155. package/dist/wizards/gcp-pulumi.d.ts +6 -0
  156. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  157. package/dist/wizards/gcp-pulumi.js +7 -0
  158. package/dist/wizards/gcp-pulumi.js.map +1 -1
  159. package/dist/wizards/gcp.d.ts.map +1 -1
  160. package/dist/wizards/gcp.js +130 -4
  161. package/dist/wizards/gcp.js.map +1 -1
  162. package/package.json +4 -4
  163. package/src/agent/run.test.ts +15 -1
  164. package/src/audit-job-setup.test.ts +234 -0
  165. package/src/audit-job-setup.ts +360 -0
  166. package/src/caddy-redirects.docker.test.ts +19 -6
  167. package/src/caddy.test.ts +30 -0
  168. package/src/caddy.ts +43 -0
  169. package/src/cli-init-validation.test.ts +44 -0
  170. package/src/cli-routing.ts +1 -1
  171. package/src/cli.ts +40 -8
  172. package/src/compose.ts +5 -0
  173. package/src/converge-checkpoint.ts +75 -0
  174. package/src/converge-entrypoint.test.ts +213 -0
  175. package/src/converge-job-gcp.test.ts +222 -0
  176. package/src/converge-job-gcp.ts +443 -0
  177. package/src/converge-job-setup.test.ts +152 -0
  178. package/src/converge-job-setup.ts +268 -0
  179. package/src/converge-job.test.ts +486 -0
  180. package/src/converge-job.ts +542 -0
  181. package/src/gateway-env-isolation.test.ts +8 -1
  182. package/src/gcp-names.ts +43 -1
  183. package/src/gcp-url-map.test.ts +9 -1
  184. package/src/index.ts +8 -0
  185. package/src/infra-contract.test.ts +7 -4
  186. package/src/infra-contract.ts +28 -1
  187. package/src/install-job.test.ts +3 -1
  188. package/src/install-job.ts +31 -14
  189. package/src/lifecycle.ts +137 -71
  190. package/src/operator-access.test.ts +14 -0
  191. package/src/operator-access.ts +13 -3
  192. package/src/private-file.test.ts +80 -0
  193. package/src/private-file.ts +50 -0
  194. package/src/release-contract.ts +8 -2
  195. package/src/release-manifest.test.ts +42 -0
  196. package/src/release-manifest.ts +2 -0
  197. package/src/remote-mcp-edge.ts +137 -0
  198. package/src/remote-mcp.test.ts +517 -0
  199. package/src/remote-mcp.ts +460 -0
  200. package/src/runtime-secrets.test.ts +31 -6
  201. package/src/runtime-secrets.ts +41 -3
  202. package/src/seal-key-rotation.test.ts +131 -0
  203. package/src/seal-key-rotation.ts +258 -0
  204. package/src/stack-contract-parity.test.ts +20 -3
  205. package/src/stack-contract.test.ts +34 -2
  206. package/src/stack-contract.ts +305 -3
  207. package/src/stack-converge-secrets.test.ts +3 -0
  208. package/src/stack-converge.test.ts +21 -5
  209. package/src/stack-converge.ts +101 -7
  210. package/src/state-attach.test.ts +7 -1
  211. package/src/state-lock-lease.test.ts +302 -0
  212. package/src/state-lock-lease.ts +423 -0
  213. package/src/state-lock.test.ts +568 -0
  214. package/src/state-store.ts +347 -27
  215. package/src/tar-entries.test.ts +149 -7
  216. package/src/tar-entries.ts +90 -9
  217. package/src/upgrade/app-roll.ts +135 -21
  218. package/src/upgrade/audit-job-roll.ts +68 -0
  219. package/src/upgrade/converge.ts +170 -7
  220. package/src/upgrade/gcp-lookup.test.ts +80 -0
  221. package/src/upgrade/gcp-lookup.ts +65 -8
  222. package/src/upgrade/record-rolled.test.ts +105 -0
  223. package/src/upgrade/record-rolled.ts +91 -0
  224. package/src/upgrade/upgrade.test.ts +279 -5
  225. package/src/upgrade/upgrade.ts +120 -24
  226. package/src/upgrader-assess.test.ts +93 -3
  227. package/src/upgrader-assess.ts +58 -9
  228. package/src/upgrader-gcp.ts +47 -8
  229. package/src/upgrader-iam-preflight.test.ts +393 -0
  230. package/src/upgrader-iam-preflight.ts +510 -0
  231. package/src/upgrader-job.test.ts +587 -15
  232. package/src/upgrader-job.ts +237 -53
  233. package/src/upgrader-records.ts +151 -0
  234. package/src/upgrader-reporter.test.ts +6 -0
  235. package/src/upgrader-reporter.ts +7 -2
  236. package/src/upgrader-setup.test.ts +4 -3
  237. package/src/upgrader-setup.ts +137 -13
  238. package/src/wizards/gcp-cost.ts +15 -6
  239. package/src/wizards/gcp-pulumi.ts +7 -0
  240. package/src/wizards/gcp.ts +162 -4
  241. package/stacks/aws/index.ts +18 -0
  242. package/stacks/azure/index.ts +18 -0
  243. package/stacks/gcp/index.ts +59 -7
  244. package/stacks/gcp-firebase/index.ts +50 -6
@@ -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`/`g` records (`path=`,
11
- * `linkpath=`). Anything it cannot read is an error, never a skipped member.
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
- const size = octal(header.subarray(124, 136));
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" || typeflag === "g") {
91
+ if (typeflag === "x") {
51
92
  const pax = paxRecords(body);
52
- if (typeflag === "x") {
53
- paxPath = pax.path ?? null;
54
- paxLink = pax.linkpath ?? null;
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.replace(/^(\.\/)+/, "").replace(/\/+$/, "");
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
- return name.startsWith("/") || name.split("/").some((part) => part === "..");
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 {
@@ -27,7 +27,12 @@
27
27
  import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
28
28
  import { gatewayServiceAccountEmail, runServiceAccountEmail } from "../gcp-names.js";
29
29
  import type { ImageDigests } from "../install-state.js";
30
- import { adminMediaVolume } from "../stack-contract.js";
30
+ import {
31
+ adminMediaVolume,
32
+ type CloudRunEnvVar,
33
+ GATEWAY_TIMEOUT_SECONDS,
34
+ SERVICE_SECRET_FILES,
35
+ } from "../stack-contract.js";
31
36
  import {
32
37
  type DeployedService,
33
38
  type EnvChange,
@@ -35,13 +40,17 @@ import {
35
40
  type LiveVolumes,
36
41
  liveContainerEnv,
37
42
  liveContainerMemory,
43
+ liveTimeoutSeconds,
38
44
  liveVolumes,
39
45
  planAdminMemory,
40
46
  planContractEnv,
41
47
  planMediaVolume,
48
+ planRequestTimeout,
49
+ planSecretFiles,
42
50
  rollService,
43
51
  serviceRollArgs,
44
52
  } from "../stack-converge.js";
53
+ import { describeAuditJobImage, setAuditJobImage } from "./audit-job-roll.js";
45
54
  import { restoreCommand, type SqlBackup } from "./backup.js";
46
55
  import {
47
56
  describeServiceJson,
@@ -88,15 +97,25 @@ export interface ServicePlan {
88
97
  /** Container memory limit now (null = Cloud Run's default). */
89
98
  readonly liveMemory: string | null;
90
99
  readonly liveVolumes: LiveVolumes;
100
+ /** Request timeout now, in seconds (null = Cloud Run's default). */
101
+ readonly liveTimeout: number | null;
91
102
  }
92
103
 
93
104
  /** A service plan plus the changes its roll applies. */
94
105
  export interface RollPlan extends ServicePlan {
95
106
  readonly envFlags: readonly string[];
96
107
  readonly envChanges: readonly EnvChange[];
97
- /** #553 — e.g. `--memory=2Gi` when the admin runs below the stack default. */
108
+ /**
109
+ * #553 — e.g. `--memory=2Gi` when the admin runs below the stack default;
110
+ * #806 — `--timeout=3600` when the gateway runs below its contract timeout.
111
+ */
98
112
  readonly resourceFlags: readonly string[];
99
- /** The admin's media bucket volume when it is (partly) missing; empty otherwise. */
113
+ readonly resourceChanges: readonly string[];
114
+ /**
115
+ * The admin's media bucket volume when it is (partly) missing, the
116
+ * service's secret files (#806, the gateway's OAuth client) when not
117
+ * mounted; empty otherwise.
118
+ */
100
119
  readonly volumeFlags: readonly string[];
101
120
  readonly volumeChanges: readonly string[];
102
121
  }
@@ -163,9 +182,10 @@ export async function planAppRoll(
163
182
  liveEnv: liveContainerEnv(json),
164
183
  liveMemory: liveContainerMemory(json),
165
184
  liveVolumes: liveVolumes(json),
185
+ liveTimeout: liveTimeoutSeconds(json),
166
186
  });
167
187
  }
168
- const env = planEnv(install, services, []);
188
+ const env = planEnv(install, services, {});
169
189
  if (!env.ok)
170
190
  return { ok: false, error: `the install's env contract can't be applied: ${env.error}` };
171
191
  // Without the media volume every upload is lost on the next revision or
@@ -177,28 +197,60 @@ export async function planAppRoll(
177
197
  );
178
198
  if (!media.ok)
179
199
  return { ok: false, error: `the admin's media volume can't be set up: ${media.error}` };
200
+ // #806 — each service's secret files (the gateway's remote-MCP OAuth
201
+ // client). A file the roll cannot mount fails the plan: the new gateway
202
+ // reads its path from the env contract.
203
+ const secretFiles = {} as Record<RollSlug, { flags: string[]; changes: string[] }>;
204
+ for (const s of services) {
205
+ const files = planSecretFiles(s.liveVolumes, SERVICE_SECRET_FILES[s.slug], install.env);
206
+ if (!files.ok) {
207
+ return { ok: false, error: `the ${s.slug}'s secret files can't be mounted: ${files.error}` };
208
+ }
209
+ secretFiles[s.slug] = files;
210
+ }
180
211
  const warnings: string[] = [];
181
212
  const rolls = services.map((s): RollPlan => {
182
213
  // #553 — the admin runs the Lighthouse quality audit; raise it to the
183
214
  // stack's memory default (never lowered; an unparsable value is kept).
184
215
  const memory = s.slug === "admin" ? planAdminMemory(s.liveMemory) : null;
185
216
  if (memory && !memory.ok) warnings.push(`admin memory left as is: ${memory.error}`);
217
+ // #806 — the gateway streams remote-MCP chat turns (stack-contract.ts
218
+ // GATEWAY_TIMEOUT_SECONDS). The admin's timeout is the stacks' alone.
219
+ const timeout =
220
+ s.slug === "gateway" ? planRequestTimeout(s.liveTimeout, GATEWAY_TIMEOUT_SECONDS) : null;
221
+ const memoryFlags = memory?.ok ? memory.flags : [];
222
+ const timeoutFlags = timeout?.ok ? timeout.flags : [];
186
223
  return {
187
224
  ...s,
188
225
  envFlags: env.services[s.slug].flags,
189
226
  envChanges: env.services[s.slug].changes,
190
- resourceFlags: memory?.ok ? memory.flags : [],
191
- volumeFlags: s.slug === "admin" ? media.flags : [],
192
- volumeChanges: s.slug === "admin" ? media.changes : [],
227
+ resourceFlags: [...memoryFlags, ...timeoutFlags],
228
+ resourceChanges: [
229
+ ...memoryFlags.map((f) => `${s.liveMemory ?? "(default)"} → ${f}`),
230
+ ...(timeout?.ok ? timeout.changes : []),
231
+ ],
232
+ volumeFlags: [...(s.slug === "admin" ? media.flags : []), ...secretFiles[s.slug].flags],
233
+ volumeChanges: [...(s.slug === "admin" ? media.changes : []), ...secretFiles[s.slug].changes],
193
234
  };
194
235
  });
195
236
  return { ok: true, plan: { install, services: rolls, warnings } };
196
237
  }
197
238
 
239
+ /** Env the converge half adds to, or keeps out of, the rolls ({@link AppRollHooks.afterMigrations}). */
240
+ export interface EnvAdjustments {
241
+ /** Contract vars to leave exactly as the services have them. */
242
+ readonly leaveUntouched?: readonly string[];
243
+ /**
244
+ * Vars outside the env contract the rolls set too — #806's remote-MCP
245
+ * identity env, which only the converge (operator identity) can resolve.
246
+ */
247
+ readonly extraEnv?: Readonly<Partial<Record<RollSlug, readonly CloudRunEnvVar[]>>>;
248
+ }
249
+
198
250
  function planEnv(
199
251
  install: AppRollInstall,
200
252
  services: readonly ServicePlan[],
201
- leaveUntouched: readonly string[],
253
+ adjust: EnvAdjustments,
202
254
  ) {
203
255
  const deployed = Object.fromEntries(
204
256
  services.map((s) => [s.slug, { serviceName: s.serviceName, liveEnv: s.liveEnv }]),
@@ -213,17 +265,22 @@ function planEnv(
213
265
  stagingHost: install.stagingHost,
214
266
  },
215
267
  deployed,
216
- { leaveUntouched },
268
+ {
269
+ leaveUntouched: adjust.leaveUntouched ?? [],
270
+ ...(adjust.extraEnv ? { extraEnv: adjust.extraEnv } : {}),
271
+ },
217
272
  );
218
273
  }
219
274
 
220
275
  /**
221
- * The plan with `names` left exactly as the services have them — for env
222
- * vars that point at something a converge step could not set up.
276
+ * The plan with the converge's adjustments: vars left exactly as the
277
+ * services have them (they point at something a converge step could not set
278
+ * up) and vars outside the contract the rolls set too.
223
279
  */
224
- export function leavingEnvUntouched(plan: AppRollPlan, names: readonly string[]): AppRollPlan {
225
- if (names.length === 0) return plan;
226
- const env = planEnv(plan.install, plan.services, names);
280
+ export function adjustingEnv(plan: AppRollPlan, adjust: EnvAdjustments): AppRollPlan {
281
+ const extra = Object.values(adjust.extraEnv ?? {}).some((v) => (v?.length ?? 0) > 0);
282
+ if ((adjust.leaveUntouched?.length ?? 0) === 0 && !extra) return plan;
283
+ const env = planEnv(plan.install, plan.services, adjust);
227
284
  if (!env.ok) return plan;
228
285
  return {
229
286
  ...plan,
@@ -239,10 +296,8 @@ export function leavingEnvUntouched(plan: AppRollPlan, names: readonly string[])
239
296
  export function reportPlan(plan: AppRollPlan, reporter: UpgradeReporter): void {
240
297
  for (const w of plan.warnings) reporter.warn(w);
241
298
  for (const s of plan.services) {
242
- for (const c of s.volumeChanges) reporter.info(`${s.slug} media: ${c}`);
243
- for (const f of s.resourceFlags) {
244
- reporter.info(`${s.slug} resources: ${s.liveMemory ?? "(default)"} → ${f}`);
245
- }
299
+ for (const c of s.volumeChanges) reporter.info(`${s.slug} volumes: ${c}`);
300
+ for (const c of s.resourceChanges) reporter.info(`${s.slug} resources: ${c}`);
246
301
  for (const c of s.envChanges) {
247
302
  reporter.info(
248
303
  `${s.slug} env ${c.name}: ${c.from === undefined ? "(unset)" : c.from} → ${c.to === undefined ? "(removed)" : c.to}`,
@@ -259,7 +314,11 @@ export interface AppRollHooks {
259
314
  * not be set up; `ok: false` aborts the upgrade (nothing rolled yet).
260
315
  */
261
316
  afterMigrations?(): Promise<
262
- { readonly ok: true; readonly leaveUntouched: readonly string[] } | { readonly ok: false }
317
+ | ({ readonly ok: true; readonly leaveUntouched: readonly string[] } & Pick<
318
+ EnvAdjustments,
319
+ "extraEnv"
320
+ >)
321
+ | { readonly ok: false }
263
322
  >;
264
323
  /** Right before `slug` rolls; `ok: false` aborts and rolls back what rolled. */
265
324
  beforeRoll?(
@@ -308,6 +367,13 @@ export type AppRollResult =
308
367
  readonly phase: AppRollPhase;
309
368
  /** Whether migrations ran before the failure (the backup matters then). */
310
369
  readonly migrated: boolean;
370
+ /**
371
+ * Whether services had rolled and EVERY one of them was pinned back to
372
+ * the revision that served before — i.e. the previous release serves
373
+ * again. False when nothing had rolled, or when a pin failed (then a
374
+ * person must pin it; the reporter named which).
375
+ */
376
+ readonly rolledBack: boolean;
311
377
  };
312
378
 
313
379
  /** Run the app-roll. See the file comment for the sequence. */
@@ -319,10 +385,13 @@ export async function executeAppRoll(
319
385
  const { reporter: r, run } = deps;
320
386
  const { install } = input.plan;
321
387
  let plan = input.plan;
388
+ /** Set by {@link rollBack}: null until one ran with services to restore. */
389
+ let rollbackOk: boolean | null = null;
322
390
  const fail = (phase: AppRollPhase, migrated: boolean): AppRollResult => ({
323
391
  ok: false,
324
392
  phase,
325
393
  migrated,
394
+ rolledBack: rollbackOk === true,
326
395
  });
327
396
 
328
397
  // A probe that cannot reach the install now would only fail after the
@@ -362,14 +431,28 @@ export async function executeAppRoll(
362
431
  if (hooks.afterMigrations) {
363
432
  const converged = await hooks.afterMigrations();
364
433
  if (!converged.ok) return fail("converge", true);
365
- plan = leavingEnvUntouched(plan, converged.leaveUntouched);
434
+ plan = adjustingEnv(plan, {
435
+ leaveUntouched: converged.leaveUntouched,
436
+ ...(converged.extraEnv ? { extraEnv: converged.extraEnv } : {}),
437
+ });
366
438
  }
367
439
 
368
440
  const rolled: RollPlan[] = [];
441
+ /** The audit job's image before this roll moved it (#875); null = not moved. */
442
+ let auditJobPriorImage: string | null = null;
369
443
  const rollBack = async (alsoCurrent?: RollPlan): Promise<void> => {
444
+ if (auditJobPriorImage) {
445
+ const back = await setAuditJobImage(run, install, auditJobPriorImage);
446
+ if (back.ok) r.info(` audit job image restored to ${auditJobPriorImage.slice(-19)}`);
447
+ else
448
+ r.error(
449
+ ` audit job rollback FAILED (${back.error}) — set its image to ${auditJobPriorImage} by hand`,
450
+ );
451
+ }
370
452
  const services = alsoCurrent ? [...rolled, alsoCurrent] : rolled;
371
453
  if (services.length === 0) return;
372
454
  r.warn(`Rolling back ${services.length} service(s) to the revisions that served before...`);
455
+ rollbackOk = true;
373
456
  for (const p of [...services].reverse()) {
374
457
  const ok = await pinTraffic(
375
458
  run,
@@ -379,7 +462,10 @@ export async function executeAppRoll(
379
462
  p.priorRevision,
380
463
  );
381
464
  if (ok) r.info(` ${p.slug} traffic restored to ${p.priorRevision}`);
382
- else r.error(` ${p.slug} rollback FAILED — pin ${p.priorRevision} by hand`);
465
+ else {
466
+ rollbackOk = false;
467
+ r.error(` ${p.slug} rollback FAILED — pin ${p.priorRevision} by hand`);
468
+ }
383
469
  await hooks.afterRollback?.(p.slug);
384
470
  }
385
471
  };
@@ -447,6 +533,34 @@ export async function executeAppRoll(
447
533
  rolled.push(p);
448
534
  }
449
535
 
536
+ // #875 — the audit job runs the admin's image; it follows the admin. A
537
+ // failure here does not undo the release: audits then fail loudly with
538
+ // the version mismatch and the fix, until the next upgrade moves it.
539
+ const sAudit = r.step("Moving the quality-audit job onto the admin's image...");
540
+ const described = await describeAuditJobImage(run, install);
541
+ const auditPrior = described.ok ? described.image : null;
542
+ if (!described.ok) {
543
+ sAudit.warned(
544
+ `Could not read the quality-audit job (${described.error}); Stage audits fail until the next upgrade moves it.`,
545
+ );
546
+ } else if (!auditPrior) {
547
+ sAudit.warned(
548
+ "The install has no quality-audit job yet; Stage audits fail until `cms-provision upgrade` sets it up.",
549
+ );
550
+ } else if (auditPrior === admin.imageRef) {
551
+ sAudit.done("Quality-audit job already on the admin's image");
552
+ } else {
553
+ const moved = await setAuditJobImage(run, install, admin.imageRef);
554
+ if (moved.ok) {
555
+ auditJobPriorImage = auditPrior;
556
+ sAudit.done(`Quality-audit job on ${admin.digest.slice(0, 19)}...`);
557
+ } else {
558
+ sAudit.warned(
559
+ `Quality-audit job not moved (${moved.error}); Stage audits fail until the next upgrade moves it.`,
560
+ );
561
+ }
562
+ }
563
+
450
564
  if (deps.health) {
451
565
  const s = r.step("Deep health check (database, migrations, version)...");
452
566
  // 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
+ }