@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/README.md +1 -1
- package/dist/bin/app.js +4 -4
- package/dist/bin/foundation-deploy.js +65 -22
- package/dist/{chunk-nvsj7bbw.js → chunk-345e0grd.js} +1 -1
- package/dist/{chunk-spq5kr1t.js → chunk-68jxyg2n.js} +13 -4
- package/dist/{chunk-mh1206ne.js → chunk-7k4e3d7n.js} +3 -3
- package/dist/{chunk-p7zkm4pp.js → chunk-caqstrxb.js} +1 -1
- package/dist/chunk-gpq7e1kq.js +101 -0
- package/dist/{chunk-m76jt5rc.js → chunk-hvntk8p5.js} +1 -1
- package/dist/chunk-p6yjbtqz.js +13 -0
- package/dist/{chunk-fjdgxw8h.js → chunk-pmjbzsq3.js} +1 -1
- package/dist/{chunk-96fjaysn.js → chunk-rvx0cncy.js} +33 -6
- package/dist/{chunk-jf5rw6h4.js → chunk-vcz9zbb3.js} +36 -11
- package/dist/{chunk-3614v9rr.js → chunk-xt9gdsa0.js} +1 -1
- package/dist/{chunk-1pww63wd.js → chunk-ztrhakgc.js} +90 -15
- package/dist/src/crm-conformance.d.ts +95 -0
- package/dist/src/crm-conformance.js +11 -0
- package/dist/src/index.js +4 -4
- package/package.json +11 -2
- package/src/crm-conformance.ts +27 -0
- package/src/deploy/cli.ts +51 -0
- package/src/deploy/crm.ts +175 -9
- package/src/stacks/api-stack.ts +4 -2
- package/src/stacks/network-stack.ts +15 -0
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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.
|
|
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
|
-
|
|
198
|
-
|
|
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(
|
|
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
|
}
|
package/src/stacks/api-stack.ts
CHANGED
|
@@ -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
|
|
1570
|
-
"
|
|
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", {
|