@deployfoundation/foundation-deploy 0.2.53 → 0.2.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/src/deploy/crm.ts CHANGED
@@ -5,6 +5,9 @@
5
5
  * foundation-deploy crm-key --instance <path> --stdin
6
6
  * foundation-deploy crm-check --instance <path> [--allow-skips]
7
7
  * foundation-deploy crm-check --base-url <url> --tenant <id> --key-stdin [--write] [--allow-skips]
8
+ * [--harness-url <url> --harness-key-stdin] [--readonly-key-stdin]
9
+ * [--status-policy <file>] [--suppressed-statuses <file>]
10
+ * [--spend-weekly-allowance]
8
11
  * foundation-deploy crm-replay --instance <path> --from <writes.jsonl> [--dry-run]
9
12
  *
10
13
  * - `crm-key` writes the `crm/1` key into the secret FoundationData created
@@ -14,9 +17,13 @@
14
17
  * uses the deployment's base URL, tenant and stored key and runs only the
15
18
  * read cases, since the write cases create records and spend the week's
16
19
  * outreach allowance. With `--base-url` (no instance at all) it is what a
17
- * CRM's own CI runs against a sandbox tenant, `--write` included. Strict
18
- * unless `--allow-skips`: a case only a person can set up counts as failed
19
- * when it could not run.
20
+ * CRM's own CI runs against a sandbox tenant, `--write` included, with the
21
+ * deployment inputs the remaining cases need: the CRM's harness endpoint
22
+ * (`crm-harness/1`) for what only a person can do, a read-only key, the
23
+ * deployment's status policy and suppressed statuses, and leave to spend
24
+ * the week's caps. Every key comes on stdin, one per line. Strict unless
25
+ * `--allow-skips`: a case only a person can set up counts as failed when it
26
+ * could not run.
20
27
  * - `crm-replay` is the rollback half of a cutover: once `crm.provider` is
21
28
  * back on `dynamodb`, it writes the agent's writes made in the company's CRM
22
29
  * since the cutover (exported as JSONL) into the instance's own table,
@@ -54,12 +61,19 @@ import {
54
61
  parseCrmUpsertRecordsArgs,
55
62
  } from "@deployfoundation/foundation-connectors/crm";
56
63
  import { crmHttpBaseUrl, isCrmHttpKey } from "@deployfoundation/foundation-connectors/crm-http";
64
+ import type { FetchLike } from "@deployfoundation/foundation-connectors/github";
57
65
  import {
58
66
  type CrmCapabilityConfig,
59
67
  type InstanceConfig,
60
68
  crmOutreachConfigured,
61
69
  parseInstanceConfig,
62
70
  } from "@deployfoundation/foundation-core";
71
+ import {
72
+ RawCrmStatusPolicy,
73
+ RawCrmStatusesByType,
74
+ } from "@deployfoundation/foundation-core/capabilities";
75
+ import { parse as parseYaml } from "yaml";
76
+ import type { z } from "zod";
63
77
  import { type AwsContext, aws, putSecretString, readSecretString, secretExists } from "./aws.ts";
64
78
 
65
79
  /** What these commands reach outside the process; tests replace every one. */
@@ -75,6 +89,8 @@ export interface CrmCommandDeps {
75
89
  /** A DocumentClient on the operator's own credentials. */
76
90
  documentClient: (ctx: AwsContext) => Promise<CrmDocumentClient>;
77
91
  readFile: (path: string) => string;
92
+ /** What the HTTP harness calls the CRM's harness endpoint with. */
93
+ fetch: FetchLike;
78
94
  log: (line: string) => void;
79
95
  }
80
96
 
@@ -175,15 +191,35 @@ export interface CrmCheckTargetOptions extends CrmCheckOptions {
175
191
  baseUrl: string;
176
192
  tenantId: string;
177
193
  write: boolean;
194
+ /** The CRM's harness endpoint (`crm-harness/1`); its key is read from stdin. */
195
+ harnessUrl?: string;
196
+ /** Read a key for the same tenant without write scopes from stdin (`read_only_scope`). */
197
+ readOnlyKeyStdin?: boolean;
198
+ /** The deployment's `capabilities.crm.statusPolicy`, as a JSON or YAML file. */
199
+ statusPolicyFile?: string;
200
+ /** The deployment's `capabilities.crm.outreach.suppressedStatuses`, as a JSON or YAML file. */
201
+ suppressedStatusesFile?: string;
202
+ /** Drive this week's first-touch and follow-up caps to their limits: a throwaway database only. */
203
+ spendWeeklyAllowance?: boolean;
178
204
  }
179
205
 
206
+ /** The keys `crm-check --base-url` reads from stdin: one per line, in this order. */
207
+ const STDIN_KEYS = [
208
+ { flag: "--key-stdin", name: "the crm/1 key" },
209
+ { flag: "--readonly-key-stdin", name: "the read-only key" },
210
+ { flag: "--harness-key-stdin", name: "the harness key" },
211
+ ] as const;
212
+
213
+ type StdinKey = (typeof STDIN_KEYS)[number];
214
+
180
215
  /**
181
216
  * The suite against any `crm/1` server, no instance involved: what a CRM's
182
- * own CI runs from the published tool. The key comes from stdin.
217
+ * own CI runs from the published tool. Keys come from stdin; everything else
218
+ * is a flag or a file, all checked before stdin is read.
183
219
  */
184
220
  export async function crmCheckTarget(
185
221
  options: CrmCheckTargetOptions,
186
- deps: Pick<CrmCommandDeps, "readStdin" | "runConformance" | "log">,
222
+ deps: Pick<CrmCommandDeps, "readStdin" | "runConformance" | "readFile" | "fetch" | "log">,
187
223
  ): Promise<number> {
188
224
  let baseUrl: string;
189
225
  try {
@@ -194,24 +230,153 @@ export async function crmCheckTarget(
194
230
  }
195
231
  if (!/^[\x21-\x7e]{1,256}$/.test(options.tenantId))
196
232
  throw new Error("--tenant must be the tenant id the server's /meta names");
197
- const apiKey = (await deps.readStdin()).trim();
198
- if (!isCrmHttpKey(apiKey)) throw new Error("stdin did not hold a usable crm/1 key (--key-stdin)");
233
+ // Each of these feeds write cases only: without --write it would do nothing.
234
+ const writeInputs = (
235
+ [
236
+ ["--harness-url", options.harnessUrl !== undefined],
237
+ ["--readonly-key-stdin", options.readOnlyKeyStdin === true],
238
+ ["--status-policy", options.statusPolicyFile !== undefined],
239
+ ["--suppressed-statuses", options.suppressedStatusesFile !== undefined],
240
+ ["--spend-weekly-allowance", options.spendWeeklyAllowance === true],
241
+ ] as const
242
+ )
243
+ .filter(([, given]) => given)
244
+ .map(([flag]) => flag);
245
+ if (!options.write && writeInputs.length > 0)
246
+ throw new Error(
247
+ `${writeInputs.join(", ")} only feed the write cases: add --write, against a sandbox tenant`,
248
+ );
249
+ // Loaded here, like the suite: no other command needs it.
250
+ const { createCrmHttpHarness, crmHarnessUrl } = await import(
251
+ "@deployfoundation/foundation-capability-crm/contract/http-harness"
252
+ );
253
+ let harnessUrl: string | undefined;
254
+ try {
255
+ harnessUrl = options.harnessUrl === undefined ? undefined : crmHarnessUrl(options.harnessUrl);
256
+ } catch {
257
+ throw new Error("--harness-url must be an HTTPS URL with no userinfo, query or fragment");
258
+ }
259
+ const statusPolicy =
260
+ options.statusPolicyFile === undefined
261
+ ? undefined
262
+ : deploymentInput(deps, "--status-policy", options.statusPolicyFile, RawCrmStatusPolicy);
263
+ const suppressedStatuses =
264
+ options.suppressedStatusesFile === undefined
265
+ ? undefined
266
+ : deploymentInput(
267
+ deps,
268
+ "--suppressed-statuses",
269
+ options.suppressedStatusesFile,
270
+ RawCrmStatusesByType,
271
+ );
272
+
273
+ const wanted = STDIN_KEYS.filter(
274
+ ({ flag }) =>
275
+ flag === "--key-stdin" ||
276
+ (flag === "--readonly-key-stdin" && options.readOnlyKeyStdin === true) ||
277
+ (flag === "--harness-key-stdin" && harnessUrl !== undefined),
278
+ );
279
+ const keys = stdinKeys(await deps.readStdin(), wanted);
280
+ const key = (flag: StdinKey["flag"]) => keys[wanted.findIndex((entry) => entry.flag === flag)];
281
+ const apiKey = key("--key-stdin") as string;
282
+ const readOnlyApiKey = key("--readonly-key-stdin");
283
+ const harnessKey = key("--harness-key-stdin");
284
+ const harness =
285
+ harnessUrl === undefined || harnessKey === undefined
286
+ ? undefined
287
+ : createCrmHttpHarness({
288
+ url: harnessUrl,
289
+ apiKey: harnessKey,
290
+ tenantId: options.tenantId,
291
+ fetchImpl: deps.fetch,
292
+ });
199
293
  const report = await deps.runConformance({
200
294
  baseUrl,
201
295
  apiKey,
202
296
  tenantId: options.tenantId,
203
297
  write: options.write,
204
298
  strict: !options.allowSkips,
299
+ ...(readOnlyApiKey === undefined ? {} : { readOnlyApiKey }),
300
+ ...(statusPolicy === undefined ? {} : { statusPolicy }),
301
+ ...(suppressedStatuses === undefined ? {} : { suppressedStatuses }),
302
+ ...(options.spendWeeklyAllowance === true ? { spendWeeklyAllowance: true } : {}),
303
+ ...(harness === undefined ? {} : { harness }),
205
304
  });
206
- return reportConformance(report, baseUrl, deps.log);
305
+ return reportConformance(report, baseUrl, deps.log, harnessUrl);
306
+ }
307
+
308
+ /**
309
+ * The keys on stdin, one per non-blank line, exactly as many as the flags
310
+ * asked for. Errors name a line and its flag, never what it held.
311
+ */
312
+ function stdinKeys(text: string, wanted: readonly StdinKey[]): string[] {
313
+ const lines = text
314
+ .split(/\r?\n/)
315
+ .map((line) => line.trim())
316
+ .filter((line) => line !== "");
317
+ if (wanted.length === 1) {
318
+ if (lines.length !== 1 || !isCrmHttpKey(lines[0]))
319
+ throw new Error("stdin did not hold a usable crm/1 key (--key-stdin)");
320
+ return lines;
321
+ }
322
+ if (lines.length !== wanted.length)
323
+ throw new Error(
324
+ `stdin must hold ${wanted.length} keys, one per line: ${wanted
325
+ .map(({ name, flag }) => `${name} (${flag})`)
326
+ .join(", then ")}; it held ${lines.length}`,
327
+ );
328
+ lines.forEach((line, index) => {
329
+ const entry = wanted[index] as StdinKey;
330
+ if (!isCrmHttpKey(line))
331
+ throw new Error(
332
+ `stdin line ${index + 1}, ${entry.name} (${entry.flag}), is not a usable key: 16 to 1024 visible ASCII characters, no spaces`,
333
+ );
334
+ });
335
+ return lines;
336
+ }
337
+
338
+ /**
339
+ * A block of the deployment's `capabilities.crm`, from a JSON or YAML file,
340
+ * validated by the same schema as that block in `config.yaml`. Errors name
341
+ * the file and the rule; these files hold policy, never a key.
342
+ */
343
+ function deploymentInput<T>(
344
+ deps: Pick<CrmCommandDeps, "readFile">,
345
+ flag: string,
346
+ path: string,
347
+ schema: z.ZodType<T, z.ZodTypeDef, unknown>,
348
+ ): T {
349
+ let text: string;
350
+ try {
351
+ text = deps.readFile(path);
352
+ } catch {
353
+ throw new Error(`${flag} ${path} could not be read`);
354
+ }
355
+ let value: unknown;
356
+ try {
357
+ value = parseYaml(text);
358
+ } catch {
359
+ throw new Error(`${flag} ${path} is not JSON or YAML`);
360
+ }
361
+ const parsed = schema.safeParse(value);
362
+ if (!parsed.success)
363
+ throw new Error(
364
+ `${flag} ${path}: ${parsed.error.issues
365
+ .map((issue) => `${issue.path.join(".") || "(top level)"}: ${issue.message}`)
366
+ .join("; ")}`,
367
+ );
368
+ return parsed.data;
207
369
  }
208
370
 
209
371
  function reportConformance(
210
372
  report: CrmConformanceReport,
211
373
  baseUrl: string,
212
374
  log: (line: string) => void,
375
+ harnessUrl?: string,
213
376
  ): number {
214
- log(`crm/1 conformance against ${baseUrl}`);
377
+ log(
378
+ `crm/1 conformance against ${baseUrl}${harnessUrl === undefined ? "" : `, harness ${harnessUrl}`}`,
379
+ );
215
380
  for (const entry of report.cases) {
216
381
  const mark = entry.outcome === "fail" ? "FAIL" : entry.outcome;
217
382
  log(
@@ -486,6 +651,7 @@ export function defaultCrmCommandDeps(): CrmCommandDeps {
486
651
  crmTableName: crmTableNameFromStack,
487
652
  documentClient: operatorDocumentClient,
488
653
  readFile: (path) => readFileSync(path, "utf8"),
654
+ fetch: (input, init) => fetch(input, init),
489
655
  log: (line) => console.log(line),
490
656
  };
491
657
  }
@@ -1566,8 +1566,10 @@ export function addConfigLoadAlarm(
1566
1566
  `${args.instance.displayName}'s runtime is not serving the config deployed for it: the`,
1567
1567
  "config failed to load, so it runs on its last good config with company-data",
1568
1568
  "capabilities off, or on the built-in fallback. The config_fallback_active or",
1569
- "config_reload_failed line in the runtime's live log group names the refused field.",
1570
- "Fix config.yaml, run foundation-deploy check, then deploy or config:sync.",
1569
+ "config_reload_failed line in the runtime's live log group says why: its error_message",
1570
+ "names the refused field (record keys shown as *), the YAML error and line, or the",
1571
+ "S3/KMS error and the IAM action it cites (error_type s3_read_failed).",
1572
+ "Fix config.yaml or the grant, run foundation-deploy check, then deploy or config:sync.",
1571
1573
  ].join(" "),
1572
1574
  metric: filter.metric({ statistic: "Sum", period: cdk.Duration.hours(1) }),
1573
1575
  threshold: 1,
@@ -247,6 +247,21 @@ export class FoundationNetwork extends cdk.Stack {
247
247
  resources: [`arn:${this.partition}:s3:::prod-${this.region}-starport-layer-bucket/*`],
248
248
  }),
249
249
  );
250
+ // Amplify serves build and deploy logs the same way: `amplify:GetJob`
251
+ // returns a URL presigned by Amplify into an AWS-owned bucket, so the
252
+ // same-account rule refused it with 403. Olivia then could not read why
253
+ // GrowLab's frontend build failed and asked for the error 19 times while
254
+ // a Node-version break kept every release out of production
255
+ // (2026-10-26). Read-only, and only that bucket's objects.
256
+ s3Endpoint.addToPolicy(
257
+ new iam.PolicyStatement({
258
+ sid: "AmplifyArtifactsBucket",
259
+ effect: iam.Effect.ALLOW,
260
+ principals: [new iam.AnyPrincipal()],
261
+ actions: ["s3:GetObject"],
262
+ resources: [`arn:${this.partition}:s3:::aws-amplify-prod-${this.region}-artifacts/*`],
263
+ }),
264
+ );
250
265
  }
251
266
  if (!skipEndpoints.has("dynamodb")) {
252
267
  const dynamoEndpoint = this.vpc.addGatewayEndpoint("DynamoDbEndpoint", {