@deployfoundation/foundation-deploy 0.2.54 → 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-tfn9tvaa.js → chunk-68jxyg2n.js} +6 -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/README.md
CHANGED
|
@@ -55,7 +55,7 @@ bun run packages/infra/bin/foundation-deploy.ts <command> --instance <path>
|
|
|
55
55
|
| `slack-manifest` | Print the instance's Slack app manifest as JSON (the Slack CLI's `get-manifest` hook contract). |
|
|
56
56
|
| `stage-customization` | Validate and stage a tenant-owned runtime config. |
|
|
57
57
|
| `crm-key` | Write the company CRM's `crm/1` key, piped in with `--stdin`, into `<secretPrefix>/crm/http` (`crm.provider` kind `http`). |
|
|
58
|
-
| `crm-check` | Run the `crm/1` conformance suite: the read cases against the instance's CRM with its stored key, or, with `--base-url --tenant --key-stdin` and no instance, any server, write cases included. |
|
|
58
|
+
| `crm-check` | Run the `crm/1` conformance suite: the read cases against the instance's CRM with its stored key, or, with `--base-url --tenant --key-stdin` and no instance, any server, write cases included, every case strict given the server's harness endpoint (`--harness-url`) and the deployment's inputs (docs/crm-provider-contract.md#crm-check). A CRM can also import the suite: `@deployfoundation/foundation-deploy/crm-conformance`. |
|
|
59
59
|
| `crm-replay` | Replay the agent's CRM writes (JSONL) into the instance's own table after a rollback to the DynamoDB provider. |
|
|
60
60
|
|
|
61
61
|
`--instance <path>` is required for every command (or `FOUNDATION_INSTANCE_FILE`
|
package/dist/bin/app.js
CHANGED
|
@@ -8,16 +8,16 @@ import {
|
|
|
8
8
|
FoundationPages,
|
|
9
9
|
FoundationPipeline,
|
|
10
10
|
NewsletterStack
|
|
11
|
-
} from "../chunk-
|
|
11
|
+
} from "../chunk-68jxyg2n.js";
|
|
12
12
|
import"../chunk-pxhftba1.js";
|
|
13
|
-
import"../chunk-dz91avrb.js";
|
|
14
13
|
import {
|
|
15
14
|
adminsFor,
|
|
16
15
|
instanceNames,
|
|
17
16
|
loadInstanceFile,
|
|
18
17
|
provisionsIntegration
|
|
19
|
-
} from "../chunk-
|
|
20
|
-
import"../chunk-
|
|
18
|
+
} from "../chunk-pmjbzsq3.js";
|
|
19
|
+
import"../chunk-dz91avrb.js";
|
|
20
|
+
import"../chunk-rvx0cncy.js";
|
|
21
21
|
import"../chunk-5wgwscd0.js";
|
|
22
22
|
|
|
23
23
|
// bin/app.ts
|
|
@@ -1,17 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @bun
|
|
3
|
-
import {
|
|
4
|
-
RELEASE_BUCKET_ENV,
|
|
5
|
-
RELEASE_VERSION_RE,
|
|
6
|
-
manifestKey,
|
|
7
|
-
releaseCacheDir,
|
|
8
|
-
skillsKey,
|
|
9
|
-
verifyManifest
|
|
10
|
-
} from "../chunk-pxhftba1.js";
|
|
11
|
-
import {
|
|
12
|
-
DEFAULT_RELEASE_BUCKET,
|
|
13
|
-
expectedKeyArn
|
|
14
|
-
} from "../chunk-dz91avrb.js";
|
|
15
3
|
import {
|
|
16
4
|
argv,
|
|
17
5
|
aws,
|
|
@@ -32,16 +20,28 @@ import {
|
|
|
32
20
|
secretExists,
|
|
33
21
|
stackExists,
|
|
34
22
|
stackOutput
|
|
35
|
-
} from "../chunk-
|
|
23
|
+
} from "../chunk-345e0grd.js";
|
|
24
|
+
import {
|
|
25
|
+
RELEASE_BUCKET_ENV,
|
|
26
|
+
RELEASE_VERSION_RE,
|
|
27
|
+
manifestKey,
|
|
28
|
+
releaseCacheDir,
|
|
29
|
+
skillsKey,
|
|
30
|
+
verifyManifest
|
|
31
|
+
} from "../chunk-pxhftba1.js";
|
|
36
32
|
import {
|
|
37
33
|
CUSTOMIZATION_ARTIFACT_NAME,
|
|
38
34
|
instanceNames,
|
|
39
35
|
mongoCollectionPolicyEnv,
|
|
40
36
|
slackCommandPrefix,
|
|
41
37
|
versionedConfigKey
|
|
42
|
-
} from "../chunk-
|
|
43
|
-
import
|
|
44
|
-
|
|
38
|
+
} from "../chunk-pmjbzsq3.js";
|
|
39
|
+
import {
|
|
40
|
+
DEFAULT_RELEASE_BUCKET,
|
|
41
|
+
expectedKeyArn
|
|
42
|
+
} from "../chunk-dz91avrb.js";
|
|
43
|
+
import"../chunk-hvntk8p5.js";
|
|
44
|
+
import"../chunk-xt9gdsa0.js";
|
|
45
45
|
import {
|
|
46
46
|
BASE_SLACK_EVENTS,
|
|
47
47
|
CAPABILITY_IDS,
|
|
@@ -53,7 +53,7 @@ import {
|
|
|
53
53
|
requiredGithubPermissions,
|
|
54
54
|
requiredSlackCommands,
|
|
55
55
|
requiredSlackScopes
|
|
56
|
-
} from "../chunk-
|
|
56
|
+
} from "../chunk-rvx0cncy.js";
|
|
57
57
|
import {
|
|
58
58
|
__require
|
|
59
59
|
} from "../chunk-5wgwscd0.js";
|
|
@@ -2449,6 +2449,20 @@ crm-check options
|
|
|
2449
2449
|
--write with --base-url only: also run the write cases, which
|
|
2450
2450
|
create records and spend the week's outreach allowance;
|
|
2451
2451
|
point it at a sandbox tenant
|
|
2452
|
+
with --base-url --write, what the remaining write cases need:
|
|
2453
|
+
--harness-url <url> the CRM's harness endpoint (crm-harness/1), which does
|
|
2454
|
+
--harness-key-stdin what only a person can; its key comes on stdin
|
|
2455
|
+
--readonly-key-stdin a key for the same tenant without write scopes, on stdin
|
|
2456
|
+
--status-policy <file>
|
|
2457
|
+
the deployment's capabilities.crm.statusPolicy (JSON
|
|
2458
|
+
or YAML)
|
|
2459
|
+
--suppressed-statuses <file>
|
|
2460
|
+
its capabilities.crm.outreach.suppressedStatuses
|
|
2461
|
+
--spend-weekly-allowance
|
|
2462
|
+
drive this week's outreach caps to their limits
|
|
2463
|
+
(a throwaway database only)
|
|
2464
|
+
Keys on stdin go one per line, in this order: the crm/1 key, the read-only
|
|
2465
|
+
key, the harness key (each only when its flag is given).
|
|
2452
2466
|
|
|
2453
2467
|
crm-replay options
|
|
2454
2468
|
--from <file> REQUIRED: the writes to replay, one JSON object per line
|
|
@@ -2462,6 +2476,22 @@ function flag2(args, name) {
|
|
|
2462
2476
|
const index = args.indexOf(name);
|
|
2463
2477
|
return index === -1 ? undefined : args[index + 1];
|
|
2464
2478
|
}
|
|
2479
|
+
function valueFlag(args, name, what) {
|
|
2480
|
+
if (!args.includes(name))
|
|
2481
|
+
return;
|
|
2482
|
+
const value = flag2(args, name);
|
|
2483
|
+
if (value === undefined || value.startsWith("-"))
|
|
2484
|
+
throw new Error(`${name} needs ${what}`);
|
|
2485
|
+
return value;
|
|
2486
|
+
}
|
|
2487
|
+
var CRM_CHECK_TARGET_FLAGS = [
|
|
2488
|
+
"--harness-url",
|
|
2489
|
+
"--harness-key-stdin",
|
|
2490
|
+
"--readonly-key-stdin",
|
|
2491
|
+
"--status-policy",
|
|
2492
|
+
"--suppressed-statuses",
|
|
2493
|
+
"--spend-weekly-allowance"
|
|
2494
|
+
];
|
|
2465
2495
|
function contextFor(args) {
|
|
2466
2496
|
const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
|
|
2467
2497
|
return awsContext({
|
|
@@ -2540,12 +2570,22 @@ ${USAGE}`);
|
|
|
2540
2570
|
const tenantId = flag2(args, "--tenant");
|
|
2541
2571
|
if (baseUrl === undefined || tenantId === undefined || !args.includes("--key-stdin"))
|
|
2542
2572
|
throw new Error("crm-check --base-url needs --tenant <id> and --key-stdin");
|
|
2543
|
-
const
|
|
2573
|
+
const harnessUrl = valueFlag(args, "--harness-url", "the CRM's harness endpoint URL");
|
|
2574
|
+
if (harnessUrl !== undefined !== args.includes("--harness-key-stdin"))
|
|
2575
|
+
throw new Error("crm-check --harness-url <url> and --harness-key-stdin go together: the harness key comes on stdin");
|
|
2576
|
+
const statusPolicyFile = valueFlag(args, "--status-policy", "a file");
|
|
2577
|
+
const suppressedStatusesFile = valueFlag(args, "--suppressed-statuses", "a file");
|
|
2578
|
+
const { crmCheckTarget, defaultCrmCommandDeps } = await import("../chunk-ztrhakgc.js");
|
|
2544
2579
|
return crmCheckTarget({
|
|
2545
2580
|
baseUrl,
|
|
2546
2581
|
tenantId,
|
|
2547
2582
|
write: args.includes("--write"),
|
|
2548
|
-
allowSkips: args.includes("--allow-skips")
|
|
2583
|
+
allowSkips: args.includes("--allow-skips"),
|
|
2584
|
+
readOnlyKeyStdin: args.includes("--readonly-key-stdin"),
|
|
2585
|
+
spendWeeklyAllowance: args.includes("--spend-weekly-allowance"),
|
|
2586
|
+
...harnessUrl === undefined ? {} : { harnessUrl },
|
|
2587
|
+
...statusPolicyFile === undefined ? {} : { statusPolicyFile },
|
|
2588
|
+
...suppressedStatusesFile === undefined ? {} : { suppressedStatusesFile }
|
|
2549
2589
|
}, defaultCrmCommandDeps());
|
|
2550
2590
|
}
|
|
2551
2591
|
if (command === "github-app-create") {
|
|
@@ -2613,21 +2653,24 @@ ${USAGE}`);
|
|
|
2613
2653
|
return 0;
|
|
2614
2654
|
}
|
|
2615
2655
|
case "crm-key": {
|
|
2616
|
-
const { crmKey, defaultCrmCommandDeps } = await import("../chunk-
|
|
2656
|
+
const { crmKey, defaultCrmCommandDeps } = await import("../chunk-ztrhakgc.js");
|
|
2617
2657
|
await crmKey(ctx, { stdin: args.includes("--stdin") }, defaultCrmCommandDeps());
|
|
2618
2658
|
return 0;
|
|
2619
2659
|
}
|
|
2620
2660
|
case "crm-check": {
|
|
2621
2661
|
if (args.includes("--write"))
|
|
2622
2662
|
throw new Error("crm-check --write creates records and spends the week's outreach allowance, so it never runs against the instance's own tenant: use --base-url with a sandbox tenant");
|
|
2623
|
-
const
|
|
2663
|
+
const targetOnly = CRM_CHECK_TARGET_FLAGS.filter((name) => args.includes(name));
|
|
2664
|
+
if (targetOnly.length > 0)
|
|
2665
|
+
throw new Error(`crm-check --instance runs the read cases with the deployment's own key and policy; ${targetOnly.join(", ")} feed the write cases: use --base-url --write with a sandbox tenant`);
|
|
2666
|
+
const { crmCheck, defaultCrmCommandDeps } = await import("../chunk-ztrhakgc.js");
|
|
2624
2667
|
return crmCheck(ctx, { allowSkips: args.includes("--allow-skips") }, defaultCrmCommandDeps());
|
|
2625
2668
|
}
|
|
2626
2669
|
case "crm-replay": {
|
|
2627
2670
|
const from = flag2(args, "--from");
|
|
2628
2671
|
if (from === undefined || from.startsWith("-"))
|
|
2629
2672
|
throw new Error("crm-replay needs --from <file>: the writes to replay, as JSONL");
|
|
2630
|
-
const { crmReplay, defaultCrmCommandDeps } = await import("../chunk-
|
|
2673
|
+
const { crmReplay, defaultCrmCommandDeps } = await import("../chunk-ztrhakgc.js");
|
|
2631
2674
|
const result = await crmReplay(ctx, { from }, defaultCrmCommandDeps());
|
|
2632
2675
|
return result.refused.length > 0 ? 1 : 0;
|
|
2633
2676
|
}
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
provisionsIntegration,
|
|
18
18
|
requiresAuthenticatedQueue,
|
|
19
19
|
slackCommandPrefixes
|
|
20
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-pmjbzsq3.js";
|
|
21
21
|
import {
|
|
22
22
|
capabilityScheduledJobs,
|
|
23
23
|
crmOutreachConfigured,
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
parseSchedule,
|
|
29
29
|
scheduledJobBindings,
|
|
30
30
|
webhookJobBindings
|
|
31
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-rvx0cncy.js";
|
|
32
32
|
|
|
33
33
|
// src/stacks/agent-stack.ts
|
|
34
34
|
import * as cdk from "aws-cdk-lib";
|
|
@@ -1942,8 +1942,10 @@ function addConfigLoadAlarm(stack, args) {
|
|
|
1942
1942
|
`${args.instance.displayName}'s runtime is not serving the config deployed for it: the`,
|
|
1943
1943
|
"config failed to load, so it runs on its last good config with company-data",
|
|
1944
1944
|
"capabilities off, or on the built-in fallback. The config_fallback_active or",
|
|
1945
|
-
"config_reload_failed line in the runtime's live log group
|
|
1946
|
-
"
|
|
1945
|
+
"config_reload_failed line in the runtime's live log group says why: its error_message",
|
|
1946
|
+
"names the refused field (record keys shown as *), the YAML error and line, or the",
|
|
1947
|
+
"S3/KMS error and the IAM action it cites (error_type s3_read_failed).",
|
|
1948
|
+
"Fix config.yaml or the grant, run foundation-deploy check, then deploy or config:sync."
|
|
1947
1949
|
].join(" "),
|
|
1948
1950
|
metric: filter.metric({ statistic: "Sum", period: cdk2.Duration.hours(1) }),
|
|
1949
1951
|
threshold: 1,
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
classifyOutreach,
|
|
16
16
|
movesToDraftedStatus,
|
|
17
17
|
outreachWeekStart
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-caqstrxb.js";
|
|
19
19
|
import {
|
|
20
20
|
CRM_MAX_ACTIVITY_PAGE_SIZE,
|
|
21
21
|
CRM_MAX_CURSOR_CHARS,
|
|
@@ -29,8 +29,8 @@ import {
|
|
|
29
29
|
parseCrmOutreachQueueResponse,
|
|
30
30
|
parseCrmSearchResponse,
|
|
31
31
|
parseCrmTimestamp
|
|
32
|
-
} from "./chunk-
|
|
33
|
-
import"./chunk-
|
|
32
|
+
} from "./chunk-xt9gdsa0.js";
|
|
33
|
+
import"./chunk-rvx0cncy.js";
|
|
34
34
|
import"./chunk-5wgwscd0.js";
|
|
35
35
|
|
|
36
36
|
// ../capability-crm/src/proxy/repository.ts
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CrmConformanceHarnessError
|
|
3
|
+
} from "./chunk-vcz9zbb3.js";
|
|
4
|
+
import {
|
|
5
|
+
CRM_HTTP_HEADERS,
|
|
6
|
+
crmHttpBaseUrl,
|
|
7
|
+
isCrmHttpKey,
|
|
8
|
+
readCrmHttpBody
|
|
9
|
+
} from "./chunk-hvntk8p5.js";
|
|
10
|
+
import"./chunk-caqstrxb.js";
|
|
11
|
+
import"./chunk-xt9gdsa0.js";
|
|
12
|
+
import"./chunk-rvx0cncy.js";
|
|
13
|
+
import"./chunk-5wgwscd0.js";
|
|
14
|
+
|
|
15
|
+
// ../capability-crm/src/contract/http-harness.ts
|
|
16
|
+
var CRM_HARNESS_CONTRACT = "crm-harness/1";
|
|
17
|
+
var USER_AGENT = "foundation-crm-conformance/1";
|
|
18
|
+
var TIMEOUT_MS = 1e4;
|
|
19
|
+
var MAX_REPLY_BYTES = 64 * 1024;
|
|
20
|
+
function crmHarnessUrl(value) {
|
|
21
|
+
try {
|
|
22
|
+
return crmHttpBaseUrl(value);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error("the harness URL must be an HTTPS URL with no userinfo, query or fragment");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function createCrmHttpHarness(options) {
|
|
28
|
+
const url = crmHarnessUrl(options.url);
|
|
29
|
+
if (!isCrmHttpKey(options.apiKey)) {
|
|
30
|
+
throw new Error("the harness key must be 16 to 1024 visible ASCII characters");
|
|
31
|
+
}
|
|
32
|
+
const { apiKey, tenantId } = options;
|
|
33
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
34
|
+
async function call(request) {
|
|
35
|
+
const signal = AbortSignal.timeout(TIMEOUT_MS);
|
|
36
|
+
let response;
|
|
37
|
+
try {
|
|
38
|
+
response = await fetchImpl(url, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
redirect: "error",
|
|
41
|
+
headers: {
|
|
42
|
+
authorization: `Bearer ${apiKey}`,
|
|
43
|
+
accept: "application/json",
|
|
44
|
+
"content-type": "application/json",
|
|
45
|
+
"user-agent": USER_AGENT,
|
|
46
|
+
[CRM_HTTP_HEADERS.contract]: CRM_HARNESS_CONTRACT
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify(request),
|
|
49
|
+
signal
|
|
50
|
+
});
|
|
51
|
+
} catch {
|
|
52
|
+
throw new CrmConformanceHarnessError(`${request.action} ${signal.aborted ? "did not answer in time" : "could not reach the harness"}`);
|
|
53
|
+
}
|
|
54
|
+
let text;
|
|
55
|
+
try {
|
|
56
|
+
text = await readCrmHttpBody(response, MAX_REPLY_BYTES, signal);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new CrmConformanceHarnessError(`${request.action} reply is too large or unreadable`);
|
|
59
|
+
}
|
|
60
|
+
if (response.status < 200 || response.status > 299) {
|
|
61
|
+
throw new CrmConformanceHarnessError(`${request.action} answered HTTP ${response.status}`);
|
|
62
|
+
}
|
|
63
|
+
return text;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
async setStatusAsPerson(recordType, recordId, status) {
|
|
67
|
+
await call({ action: "set_status_as_person", tenantId, recordType, recordId, status });
|
|
68
|
+
},
|
|
69
|
+
async setDoNotContact(recordType, recordId) {
|
|
70
|
+
await call({ action: "set_do_not_contact", tenantId, recordType, recordId });
|
|
71
|
+
},
|
|
72
|
+
async suppressEmail(email) {
|
|
73
|
+
await call({ action: "suppress_email", tenantId, email });
|
|
74
|
+
},
|
|
75
|
+
async addPersonNote(recordType, recordId, summary) {
|
|
76
|
+
const text = await call({
|
|
77
|
+
action: "add_person_note",
|
|
78
|
+
tenantId,
|
|
79
|
+
recordType,
|
|
80
|
+
recordId,
|
|
81
|
+
summary
|
|
82
|
+
});
|
|
83
|
+
let activityId;
|
|
84
|
+
try {
|
|
85
|
+
const reply = JSON.parse(text);
|
|
86
|
+
if (typeof reply === "object" && reply !== null && "activityId" in reply) {
|
|
87
|
+
activityId = reply.activityId;
|
|
88
|
+
}
|
|
89
|
+
} catch {}
|
|
90
|
+
if (typeof activityId !== "string" || !/^[\x21-\x7e]{1,256}$/.test(activityId)) {
|
|
91
|
+
throw new CrmConformanceHarnessError("add_person_note answered no activityId");
|
|
92
|
+
}
|
|
93
|
+
return activityId;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export {
|
|
98
|
+
CRM_HARNESS_CONTRACT,
|
|
99
|
+
createCrmHttpHarness,
|
|
100
|
+
crmHarnessUrl
|
|
101
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CrmConformanceHarnessError,
|
|
3
|
+
runCrmConformance
|
|
4
|
+
} from "./chunk-vcz9zbb3.js";
|
|
5
|
+
import"./chunk-hvntk8p5.js";
|
|
6
|
+
import"./chunk-caqstrxb.js";
|
|
7
|
+
import"./chunk-xt9gdsa0.js";
|
|
8
|
+
import"./chunk-rvx0cncy.js";
|
|
9
|
+
import"./chunk-5wgwscd0.js";
|
|
10
|
+
export {
|
|
11
|
+
CrmConformanceHarnessError,
|
|
12
|
+
runCrmConformance
|
|
13
|
+
};
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// ../core/src/config.ts
|
|
2
|
-
import { createHash as createHash2 } from "node:crypto";
|
|
3
|
-
import { parse as parseYaml } from "yaml";
|
|
4
|
-
import { z as z4 } from "zod";
|
|
5
|
-
|
|
6
1
|
// ../core/src/capabilities.ts
|
|
7
2
|
import { z } from "zod";
|
|
8
3
|
|
|
@@ -936,6 +931,11 @@ function requiredSlackCommandSuffixes(enabled) {
|
|
|
936
931
|
return [...suffixes].sort();
|
|
937
932
|
}
|
|
938
933
|
|
|
934
|
+
// ../core/src/config.ts
|
|
935
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
936
|
+
import { parse as parseYaml } from "yaml";
|
|
937
|
+
import { z as z4 } from "zod";
|
|
938
|
+
|
|
939
939
|
// ../core/src/jobs.ts
|
|
940
940
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
941
941
|
import { z as z3 } from "zod";
|
|
@@ -1234,6 +1234,33 @@ function parseInstanceConfig(yamlText) {
|
|
|
1234
1234
|
throw new Error(`job ${job.id} owner ${job.owner} must be an instance admin`);
|
|
1235
1235
|
return cfg;
|
|
1236
1236
|
}
|
|
1237
|
+
var ZOD_ISSUE_CODES = new Set(Object.values(z4.ZodIssueCode));
|
|
1238
|
+
var ZOD_PARSED_TYPES = new Set(Object.values(z4.ZodParsedType));
|
|
1239
|
+
var YAML_ERROR_CODES = new Set([
|
|
1240
|
+
"ALIAS_PROPS",
|
|
1241
|
+
"BAD_ALIAS",
|
|
1242
|
+
"BAD_COLLECTION_TYPE",
|
|
1243
|
+
"BAD_DIRECTIVE",
|
|
1244
|
+
"BAD_DQ_ESCAPE",
|
|
1245
|
+
"BAD_INDENT",
|
|
1246
|
+
"BAD_PROP_ORDER",
|
|
1247
|
+
"BAD_SCALAR_START",
|
|
1248
|
+
"BLOCK_AS_IMPLICIT_KEY",
|
|
1249
|
+
"BLOCK_IN_FLOW",
|
|
1250
|
+
"DUPLICATE_KEY",
|
|
1251
|
+
"IMPOSSIBLE",
|
|
1252
|
+
"KEY_OVER_1024_CHARS",
|
|
1253
|
+
"MISSING_CHAR",
|
|
1254
|
+
"MULTILINE_IMPLICIT_KEY",
|
|
1255
|
+
"MULTIPLE_ANCHORS",
|
|
1256
|
+
"MULTIPLE_DOCS",
|
|
1257
|
+
"MULTIPLE_TAGS",
|
|
1258
|
+
"NON_STRING_KEY",
|
|
1259
|
+
"RESOURCE_EXHAUSTION",
|
|
1260
|
+
"TAB_AS_INDENT",
|
|
1261
|
+
"TAG_RESOLVE_FAILED",
|
|
1262
|
+
"UNEXPECTED_TOKEN"
|
|
1263
|
+
]);
|
|
1237
1264
|
|
|
1238
1265
|
// ../core/src/logger.ts
|
|
1239
1266
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
@@ -1295,4 +1322,4 @@ function wrapUntrusted(source, content) {
|
|
|
1295
1322
|
${safe}
|
|
1296
1323
|
</${UNTRUSTED_TAG}>`;
|
|
1297
1324
|
}
|
|
1298
|
-
export { parseSchedule, BASE_SLACK_EVENTS, SLACK_COMMAND_PREFIX_RE, CAPABILITY_IDS, INTEGRATION_CAPABILITY_IDS, requiredGithubPermissions, requiredSlackScopes, requiredSlackCommands, isScheduledJob, isWebhookJob, webhookJobBindings, scheduledJobBindings, jobWebhookKey, capabilityScheduledJobs, crmOutreachConfigured, crmPolicyFingerprint, parseInstanceConfig, PROACTIVE_CHANNEL_MODES, encodeProactiveChannels, wrapUntrusted };
|
|
1325
|
+
export { parseSchedule, RawCrmStatusPolicy, RawCrmStatusesByType, BASE_SLACK_EVENTS, SLACK_COMMAND_PREFIX_RE, CAPABILITY_IDS, INTEGRATION_CAPABILITY_IDS, requiredGithubPermissions, requiredSlackScopes, requiredSlackCommands, isScheduledJob, isWebhookJob, webhookJobBindings, scheduledJobBindings, jobWebhookKey, capabilityScheduledJobs, crmOutreachConfigured, crmPolicyFingerprint, parseInstanceConfig, PROACTIVE_CHANNEL_MODES, encodeProactiveChannels, wrapUntrusted };
|
|
@@ -6,21 +6,26 @@ import {
|
|
|
6
6
|
createCrmHttpClient,
|
|
7
7
|
encodeCrmActor,
|
|
8
8
|
readCrmHttpBody
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-hvntk8p5.js";
|
|
10
10
|
import {
|
|
11
11
|
outreachWeekStart,
|
|
12
12
|
personOwnedStatuses
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-caqstrxb.js";
|
|
14
14
|
import {
|
|
15
15
|
CRM_NOTE_ACTIVITY,
|
|
16
16
|
CRM_OUTREACH_DRAFTED_ACTIVITY,
|
|
17
17
|
CRM_REQUIRED_PROVIDER_OPERATIONS
|
|
18
|
-
} from "./chunk-
|
|
19
|
-
import"./chunk-96fjaysn.js";
|
|
20
|
-
import"./chunk-5wgwscd0.js";
|
|
18
|
+
} from "./chunk-xt9gdsa0.js";
|
|
21
19
|
|
|
22
20
|
// ../capability-crm/src/contract/conformance.ts
|
|
23
21
|
import { randomUUID } from "node:crypto";
|
|
22
|
+
class CrmConformanceHarnessError extends Error {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "CrmConformanceHarnessError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
class Skip extends Error {
|
|
25
30
|
reason;
|
|
26
31
|
needs;
|
|
@@ -386,9 +391,11 @@ class Suite {
|
|
|
386
391
|
return;
|
|
387
392
|
}
|
|
388
393
|
async transitionCase() {
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
394
|
+
const given = this.target.disallowedTransition;
|
|
395
|
+
const transition = given ?? this.policyDisallowedTransition();
|
|
396
|
+
if (transition === undefined) {
|
|
397
|
+
throw new Skip(this.target.statusPolicy === undefined ? "no disallowedTransition or statusPolicy given" : "the statusPolicy allows every move from a new record's status");
|
|
398
|
+
}
|
|
392
399
|
const fixture = await this.fixture("transition");
|
|
393
400
|
const recordId = transition.recordType === "company" ? fixture.companyId : fixture.contactId;
|
|
394
401
|
const current = await this.profile(transition.recordType, recordId);
|
|
@@ -398,6 +405,23 @@ class Suite {
|
|
|
398
405
|
status: transition.to,
|
|
399
406
|
idempotencyKey: this.key()
|
|
400
407
|
}, { status: current.status, statusVersion: current.statusVersion ?? 0 }), "refused", "transition_not_allowed");
|
|
408
|
+
return given === undefined ? `${transition.recordType} → ${transition.to}, from the statusPolicy` : undefined;
|
|
409
|
+
}
|
|
410
|
+
policyDisallowedTransition() {
|
|
411
|
+
const policy = this.target.statusPolicy;
|
|
412
|
+
if (policy === undefined)
|
|
413
|
+
return;
|
|
414
|
+
const meta = this.requireMeta();
|
|
415
|
+
for (const recordType of ["company", "contact"]) {
|
|
416
|
+
const rule = policy[recordType];
|
|
417
|
+
const [first, ...others] = meta.statuses[recordType];
|
|
418
|
+
if (rule === undefined || first === undefined)
|
|
419
|
+
continue;
|
|
420
|
+
const exits = Object.hasOwn(rule.agentTransitions, first) ? rule.agentTransitions[first] ?? [] : [];
|
|
421
|
+
const to = others.find((status) => !exits.includes(status));
|
|
422
|
+
if (to !== undefined)
|
|
423
|
+
return { recordType, to };
|
|
424
|
+
}
|
|
401
425
|
return;
|
|
402
426
|
}
|
|
403
427
|
async statusLockedCase() {
|
|
@@ -1102,6 +1126,8 @@ async function expectError(call, code, reason) {
|
|
|
1102
1126
|
function describe(error) {
|
|
1103
1127
|
if (error instanceof Failure)
|
|
1104
1128
|
return error.message;
|
|
1129
|
+
if (error instanceof CrmConformanceHarnessError)
|
|
1130
|
+
return `harness: ${error.message}`;
|
|
1105
1131
|
if (error instanceof CrmHttpError) {
|
|
1106
1132
|
return [
|
|
1107
1133
|
error.code,
|
|
@@ -1112,6 +1138,5 @@ function describe(error) {
|
|
|
1112
1138
|
}
|
|
1113
1139
|
return `unexpected ${error instanceof Error ? error.name : typeof error}`;
|
|
1114
1140
|
}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
};
|
|
1141
|
+
|
|
1142
|
+
export { CrmConformanceHarnessError, runCrmConformance };
|
|
@@ -3,8 +3,8 @@ import {
|
|
|
3
3
|
putSecretString,
|
|
4
4
|
readSecretString,
|
|
5
5
|
secretExists
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
import"./chunk-
|
|
6
|
+
} from "./chunk-345e0grd.js";
|
|
7
|
+
import"./chunk-pmjbzsq3.js";
|
|
8
8
|
import {
|
|
9
9
|
CrmIdempotencyConflictError,
|
|
10
10
|
CrmRecordNotFoundError
|
|
@@ -12,27 +12,30 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
crmHttpBaseUrl,
|
|
14
14
|
isCrmHttpKey
|
|
15
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-hvntk8p5.js";
|
|
16
16
|
import {
|
|
17
17
|
CrmPolicyRefusedError
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-caqstrxb.js";
|
|
19
19
|
import {
|
|
20
20
|
CRM_MAX_ID_CHARS,
|
|
21
21
|
CrmInputError,
|
|
22
22
|
parseCrmLogActivityArgs,
|
|
23
23
|
parseCrmUpdateStatusArgs,
|
|
24
24
|
parseCrmUpsertRecordsArgs
|
|
25
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-xt9gdsa0.js";
|
|
26
26
|
import {
|
|
27
|
+
RawCrmStatusPolicy,
|
|
28
|
+
RawCrmStatusesByType,
|
|
27
29
|
crmOutreachConfigured,
|
|
28
30
|
parseInstanceConfig
|
|
29
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-rvx0cncy.js";
|
|
30
32
|
import {
|
|
31
33
|
__require
|
|
32
34
|
} from "./chunk-5wgwscd0.js";
|
|
33
35
|
|
|
34
36
|
// src/deploy/crm.ts
|
|
35
37
|
import { readFileSync } from "node:fs";
|
|
38
|
+
import { parse as parseYaml } from "yaml";
|
|
36
39
|
function httpProvider(ctx, command) {
|
|
37
40
|
const { instance } = ctx;
|
|
38
41
|
const provider = instance.crm.provider;
|
|
@@ -78,6 +81,11 @@ async function crmCheck(ctx, options, deps) {
|
|
|
78
81
|
});
|
|
79
82
|
return reportConformance(report, provider.baseUrl, deps.log);
|
|
80
83
|
}
|
|
84
|
+
var STDIN_KEYS = [
|
|
85
|
+
{ flag: "--key-stdin", name: "the crm/1 key" },
|
|
86
|
+
{ flag: "--readonly-key-stdin", name: "the read-only key" },
|
|
87
|
+
{ flag: "--harness-key-stdin", name: "the harness key" }
|
|
88
|
+
];
|
|
81
89
|
async function crmCheckTarget(options, deps) {
|
|
82
90
|
let baseUrl;
|
|
83
91
|
try {
|
|
@@ -87,20 +95,86 @@ async function crmCheckTarget(options, deps) {
|
|
|
87
95
|
}
|
|
88
96
|
if (!/^[\x21-\x7e]{1,256}$/.test(options.tenantId))
|
|
89
97
|
throw new Error("--tenant must be the tenant id the server's /meta names");
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
const writeInputs = [
|
|
99
|
+
["--harness-url", options.harnessUrl !== undefined],
|
|
100
|
+
["--readonly-key-stdin", options.readOnlyKeyStdin === true],
|
|
101
|
+
["--status-policy", options.statusPolicyFile !== undefined],
|
|
102
|
+
["--suppressed-statuses", options.suppressedStatusesFile !== undefined],
|
|
103
|
+
["--spend-weekly-allowance", options.spendWeeklyAllowance === true]
|
|
104
|
+
].filter(([, given]) => given).map(([flag]) => flag);
|
|
105
|
+
if (!options.write && writeInputs.length > 0)
|
|
106
|
+
throw new Error(`${writeInputs.join(", ")} only feed the write cases: add --write, against a sandbox tenant`);
|
|
107
|
+
const { createCrmHttpHarness, crmHarnessUrl } = await import("./chunk-gpq7e1kq.js");
|
|
108
|
+
let harnessUrl;
|
|
109
|
+
try {
|
|
110
|
+
harnessUrl = options.harnessUrl === undefined ? undefined : crmHarnessUrl(options.harnessUrl);
|
|
111
|
+
} catch {
|
|
112
|
+
throw new Error("--harness-url must be an HTTPS URL with no userinfo, query or fragment");
|
|
113
|
+
}
|
|
114
|
+
const statusPolicy = options.statusPolicyFile === undefined ? undefined : deploymentInput(deps, "--status-policy", options.statusPolicyFile, RawCrmStatusPolicy);
|
|
115
|
+
const suppressedStatuses = options.suppressedStatusesFile === undefined ? undefined : deploymentInput(deps, "--suppressed-statuses", options.suppressedStatusesFile, RawCrmStatusesByType);
|
|
116
|
+
const wanted = STDIN_KEYS.filter(({ flag }) => flag === "--key-stdin" || flag === "--readonly-key-stdin" && options.readOnlyKeyStdin === true || flag === "--harness-key-stdin" && harnessUrl !== undefined);
|
|
117
|
+
const keys = stdinKeys(await deps.readStdin(), wanted);
|
|
118
|
+
const key = (flag) => keys[wanted.findIndex((entry) => entry.flag === flag)];
|
|
119
|
+
const apiKey = key("--key-stdin");
|
|
120
|
+
const readOnlyApiKey = key("--readonly-key-stdin");
|
|
121
|
+
const harnessKey = key("--harness-key-stdin");
|
|
122
|
+
const harness = harnessUrl === undefined || harnessKey === undefined ? undefined : createCrmHttpHarness({
|
|
123
|
+
url: harnessUrl,
|
|
124
|
+
apiKey: harnessKey,
|
|
125
|
+
tenantId: options.tenantId,
|
|
126
|
+
fetchImpl: deps.fetch
|
|
127
|
+
});
|
|
93
128
|
const report = await deps.runConformance({
|
|
94
129
|
baseUrl,
|
|
95
130
|
apiKey,
|
|
96
131
|
tenantId: options.tenantId,
|
|
97
132
|
write: options.write,
|
|
98
|
-
strict: !options.allowSkips
|
|
133
|
+
strict: !options.allowSkips,
|
|
134
|
+
...readOnlyApiKey === undefined ? {} : { readOnlyApiKey },
|
|
135
|
+
...statusPolicy === undefined ? {} : { statusPolicy },
|
|
136
|
+
...suppressedStatuses === undefined ? {} : { suppressedStatuses },
|
|
137
|
+
...options.spendWeeklyAllowance === true ? { spendWeeklyAllowance: true } : {},
|
|
138
|
+
...harness === undefined ? {} : { harness }
|
|
99
139
|
});
|
|
100
|
-
return reportConformance(report, baseUrl, deps.log);
|
|
140
|
+
return reportConformance(report, baseUrl, deps.log, harnessUrl);
|
|
141
|
+
}
|
|
142
|
+
function stdinKeys(text, wanted) {
|
|
143
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== "");
|
|
144
|
+
if (wanted.length === 1) {
|
|
145
|
+
if (lines.length !== 1 || !isCrmHttpKey(lines[0]))
|
|
146
|
+
throw new Error("stdin did not hold a usable crm/1 key (--key-stdin)");
|
|
147
|
+
return lines;
|
|
148
|
+
}
|
|
149
|
+
if (lines.length !== wanted.length)
|
|
150
|
+
throw new Error(`stdin must hold ${wanted.length} keys, one per line: ${wanted.map(({ name, flag }) => `${name} (${flag})`).join(", then ")}; it held ${lines.length}`);
|
|
151
|
+
lines.forEach((line, index) => {
|
|
152
|
+
const entry = wanted[index];
|
|
153
|
+
if (!isCrmHttpKey(line))
|
|
154
|
+
throw new Error(`stdin line ${index + 1}, ${entry.name} (${entry.flag}), is not a usable key: 16 to 1024 visible ASCII characters, no spaces`);
|
|
155
|
+
});
|
|
156
|
+
return lines;
|
|
157
|
+
}
|
|
158
|
+
function deploymentInput(deps, flag, path, schema) {
|
|
159
|
+
let text;
|
|
160
|
+
try {
|
|
161
|
+
text = deps.readFile(path);
|
|
162
|
+
} catch {
|
|
163
|
+
throw new Error(`${flag} ${path} could not be read`);
|
|
164
|
+
}
|
|
165
|
+
let value;
|
|
166
|
+
try {
|
|
167
|
+
value = parseYaml(text);
|
|
168
|
+
} catch {
|
|
169
|
+
throw new Error(`${flag} ${path} is not JSON or YAML`);
|
|
170
|
+
}
|
|
171
|
+
const parsed = schema.safeParse(value);
|
|
172
|
+
if (!parsed.success)
|
|
173
|
+
throw new Error(`${flag} ${path}: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "(top level)"}: ${issue.message}`).join("; ")}`);
|
|
174
|
+
return parsed.data;
|
|
101
175
|
}
|
|
102
|
-
function reportConformance(report, baseUrl, log) {
|
|
103
|
-
log(`crm/1 conformance against ${baseUrl}`);
|
|
176
|
+
function reportConformance(report, baseUrl, log, harnessUrl) {
|
|
177
|
+
log(`crm/1 conformance against ${baseUrl}${harnessUrl === undefined ? "" : `, harness ${harnessUrl}`}`);
|
|
104
178
|
for (const entry of report.cases) {
|
|
105
179
|
const mark = entry.outcome === "fail" ? "FAIL" : entry.outcome;
|
|
106
180
|
log(` ${mark.padEnd(4)} ${entry.id}: ${entry.title}${entry.detail === undefined ? "" : ` (${entry.detail})`}`);
|
|
@@ -181,7 +255,7 @@ async function crmReplay(ctx, options, deps) {
|
|
|
181
255
|
const crm = crmCapability(config);
|
|
182
256
|
if (crm === undefined || crm.statuses.company.length === 0 || crm.statuses.contact.length === 0)
|
|
183
257
|
throw new Error(`${ctx.paths.configPath}: crm-replay needs capabilities.crm with its statuses`);
|
|
184
|
-
const { createDynamoCrmClient, deterministicRecordId } = await import("./chunk-
|
|
258
|
+
const { createDynamoCrmClient, deterministicRecordId } = await import("./chunk-7k4e3d7n.js");
|
|
185
259
|
const writes = parseCrmReplay(deps.readFile(options.from), crm, deterministicRecordId);
|
|
186
260
|
const counts = REPLAY_OPS.map((op) => `${writes.filter((write) => write.op === op).length} ${op}`).join(", ");
|
|
187
261
|
if (ctx.dryRun === true) {
|
|
@@ -282,12 +356,13 @@ function defaultCrmCommandDeps() {
|
|
|
282
356
|
putSecretString,
|
|
283
357
|
readSecretString,
|
|
284
358
|
runConformance: async (target) => {
|
|
285
|
-
const { runCrmConformance } = await import("./chunk-
|
|
359
|
+
const { runCrmConformance } = await import("./chunk-p6yjbtqz.js");
|
|
286
360
|
return runCrmConformance(target);
|
|
287
361
|
},
|
|
288
362
|
crmTableName: crmTableNameFromStack,
|
|
289
363
|
documentClient: operatorDocumentClient,
|
|
290
364
|
readFile: (path) => readFileSync(path, "utf8"),
|
|
365
|
+
fetch: (input, init) => fetch(input, init),
|
|
291
366
|
log: (line) => console.log(line)
|
|
292
367
|
};
|
|
293
368
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarations for `@deployfoundation/foundation-deploy/crm-conformance`
|
|
3
|
+
* (src/crm-conformance.ts): the `crm/1` conformance suite. Self-contained on
|
|
4
|
+
* purpose: the source behind them is bundled from workspace packages that are
|
|
5
|
+
* never published. test/crm-conformance-export.test.ts fails the typecheck
|
|
6
|
+
* when these and the source drift apart.
|
|
7
|
+
*
|
|
8
|
+
* docs/crm-provider-contract.md#conformance describes each option.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A fetch the suite sends every request through; default the global `fetch`. */
|
|
12
|
+
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
13
|
+
|
|
14
|
+
export type CrmRecordType = "company" | "contact";
|
|
15
|
+
|
|
16
|
+
/** The agent's allowed status changes on one record type. */
|
|
17
|
+
export interface CrmStatusRule {
|
|
18
|
+
/** From each status, the statuses the agent may move a record to. No entry, no exit. */
|
|
19
|
+
agentTransitions: Readonly<Record<string, readonly string[]>>;
|
|
20
|
+
/** Apply `personSet` to a status a person set; otherwise who set it does not matter. */
|
|
21
|
+
respectHumanChanges: boolean;
|
|
22
|
+
/** `advance`: only forward along `ladder`. `lock`: never. */
|
|
23
|
+
personSet: "lock" | "advance";
|
|
24
|
+
/** The evidence ladder, earliest status first. */
|
|
25
|
+
ladder: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Absent for a record type, any configured status of that type may be set. */
|
|
29
|
+
export type CrmStatusPolicy = Readonly<Partial<Record<CrmRecordType, CrmStatusRule>>>;
|
|
30
|
+
|
|
31
|
+
/** What only a person can do in a CRM, done directly by the server's own test code. */
|
|
32
|
+
export interface CrmConformanceHarness {
|
|
33
|
+
/** Set a record's status as a person would: stamps `person`, bumps statusVersion. */
|
|
34
|
+
setStatusAsPerson(recordType: CrmRecordType, recordId: string, status: string): Promise<void>;
|
|
35
|
+
/** Set the do-not-contact flag on a contact or a company record. */
|
|
36
|
+
setDoNotContact(recordType: CrmRecordType, recordId: string): Promise<void>;
|
|
37
|
+
/** Suppress an email address for the tenant. */
|
|
38
|
+
suppressEmail(email: string): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Log a note as a person (`attributes.by = person`); returns its activity
|
|
41
|
+
* id. The summary may exceed 4000 characters; the server must truncate it
|
|
42
|
+
* on read.
|
|
43
|
+
*/
|
|
44
|
+
addPersonNote(recordType: CrmRecordType, recordId: string, summary: string): Promise<string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CrmConformanceTarget {
|
|
48
|
+
baseUrl: string;
|
|
49
|
+
apiKey: string;
|
|
50
|
+
tenantId: string;
|
|
51
|
+
fetchImpl?: FetchLike;
|
|
52
|
+
/** Run cases that create records and spend outreach allowance. */
|
|
53
|
+
write?: boolean;
|
|
54
|
+
/** Also drive the weekly first-touch cap to its limit (CI against a throwaway database only). */
|
|
55
|
+
spendWeeklyAllowance?: boolean;
|
|
56
|
+
/** A key for the same tenant without write scopes: writes must be 403 forbidden. */
|
|
57
|
+
readOnlyApiKey?: string;
|
|
58
|
+
/**
|
|
59
|
+
* A status the agent may not move a fresh record of this type to (from the
|
|
60
|
+
* type's first status). Absent, it is read off `statusPolicy`.
|
|
61
|
+
*/
|
|
62
|
+
disallowedTransition?: { recordType: CrmRecordType; to: string };
|
|
63
|
+
/** The deployment's status policy, as the proxy bakes it. */
|
|
64
|
+
statusPolicy?: CrmStatusPolicy;
|
|
65
|
+
/** The deployment's suppressed statuses. */
|
|
66
|
+
suppressedStatuses?: { company: readonly string[]; contact: readonly string[] };
|
|
67
|
+
harness?: CrmConformanceHarness;
|
|
68
|
+
/** Count a case skipped for want of a harness as a failure. */
|
|
69
|
+
strict?: boolean;
|
|
70
|
+
/** Prefix for created externalIds and idempotency keys; default a random id. */
|
|
71
|
+
runId?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type CrmConformanceOutcome = "pass" | "fail" | "skip";
|
|
75
|
+
|
|
76
|
+
export interface CrmConformanceCase {
|
|
77
|
+
id: string;
|
|
78
|
+
title: string;
|
|
79
|
+
outcome: CrmConformanceOutcome;
|
|
80
|
+
/** Why it failed or was skipped: fixed text, codes and statuses, never a reply body. */
|
|
81
|
+
detail?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface CrmConformanceReport {
|
|
85
|
+
contract: "crm/1";
|
|
86
|
+
passed: number;
|
|
87
|
+
failed: number;
|
|
88
|
+
skipped: number;
|
|
89
|
+
cases: CrmConformanceCase[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Run every case against one server and report each outcome. Never throws for a server fault. */
|
|
93
|
+
export declare function runCrmConformance(
|
|
94
|
+
target: CrmConformanceTarget,
|
|
95
|
+
): Promise<CrmConformanceReport>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runCrmConformance
|
|
3
|
+
} from "../chunk-vcz9zbb3.js";
|
|
4
|
+
import"../chunk-hvntk8p5.js";
|
|
5
|
+
import"../chunk-caqstrxb.js";
|
|
6
|
+
import"../chunk-xt9gdsa0.js";
|
|
7
|
+
import"../chunk-rvx0cncy.js";
|
|
8
|
+
import"../chunk-5wgwscd0.js";
|
|
9
|
+
export {
|
|
10
|
+
runCrmConformance
|
|
11
|
+
};
|
package/dist/src/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
FoundationPipeline,
|
|
11
11
|
NewsletterStack,
|
|
12
12
|
deployStatements
|
|
13
|
-
} from "../chunk-
|
|
13
|
+
} from "../chunk-68jxyg2n.js";
|
|
14
14
|
import {
|
|
15
15
|
BUN_VERSION,
|
|
16
16
|
LAMBDA_ENTRY_POINTS,
|
|
@@ -34,7 +34,6 @@ import {
|
|
|
34
34
|
skipBundle,
|
|
35
35
|
verifyManifest
|
|
36
36
|
} from "../chunk-pxhftba1.js";
|
|
37
|
-
import"../chunk-dz91avrb.js";
|
|
38
37
|
import {
|
|
39
38
|
LIVE_ENDPOINT_NAME,
|
|
40
39
|
WEB_SEARCH_CONNECTOR_VERSION,
|
|
@@ -47,8 +46,9 @@ import {
|
|
|
47
46
|
loadInstanceFile,
|
|
48
47
|
provisionsIntegration,
|
|
49
48
|
requiresAuthenticatedQueue
|
|
50
|
-
} from "../chunk-
|
|
51
|
-
import"../chunk-
|
|
49
|
+
} from "../chunk-pmjbzsq3.js";
|
|
50
|
+
import"../chunk-dz91avrb.js";
|
|
51
|
+
import"../chunk-rvx0cncy.js";
|
|
52
52
|
import"../chunk-5wgwscd0.js";
|
|
53
53
|
export {
|
|
54
54
|
BUILD_TIMEOUT,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deployfoundation/foundation-deploy",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.55",
|
|
4
4
|
"description": "Deploys one Foundation instance from a published, signed release: the CDK app and the deploy tool.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -9,7 +9,16 @@
|
|
|
9
9
|
"foundation-deploy": "./dist/bin/foundation-deploy.js"
|
|
10
10
|
},
|
|
11
11
|
"exports": {
|
|
12
|
-
".": "./dist/src/index.js"
|
|
12
|
+
".": "./dist/src/index.js",
|
|
13
|
+
"./crm-conformance": {
|
|
14
|
+
"types": "./dist/src/crm-conformance.d.ts",
|
|
15
|
+
"default": "./dist/src/crm-conformance.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"typesVersions": {
|
|
19
|
+
"*": {
|
|
20
|
+
"crm-conformance": ["./dist/src/crm-conformance.d.ts"]
|
|
21
|
+
}
|
|
13
22
|
},
|
|
14
23
|
"files": [
|
|
15
24
|
"dist",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@deployfoundation/foundation-deploy/crm-conformance`: the `crm/1`
|
|
3
|
+
* conformance suite, for a CRM's own CI to run in-process with its own
|
|
4
|
+
* harness (docs/crm-provider-contract.md#conformance).
|
|
5
|
+
*
|
|
6
|
+
* import { runCrmConformance } from "@deployfoundation/foundation-deploy/crm-conformance";
|
|
7
|
+
*
|
|
8
|
+
* This is the published package's stable surface for the suite: the function,
|
|
9
|
+
* its target and report, and the harness interface. Its declarations are
|
|
10
|
+
* hand-written in `types/crm-conformance.d.ts`, because the source behind it
|
|
11
|
+
* lives in workspace packages that are bundled, never published;
|
|
12
|
+
* `test/crm-conformance-export.test.ts` holds the two to the same types.
|
|
13
|
+
*/
|
|
14
|
+
export type { CrmRecordType } from "@deployfoundation/foundation-connectors/crm";
|
|
15
|
+
export type { FetchLike } from "@deployfoundation/foundation-connectors/github";
|
|
16
|
+
export {
|
|
17
|
+
type CrmConformanceCase,
|
|
18
|
+
type CrmConformanceHarness,
|
|
19
|
+
type CrmConformanceOutcome,
|
|
20
|
+
type CrmConformanceReport,
|
|
21
|
+
type CrmConformanceTarget,
|
|
22
|
+
runCrmConformance,
|
|
23
|
+
} from "@deployfoundation/foundation-capability-crm/contract/conformance";
|
|
24
|
+
export type {
|
|
25
|
+
CrmStatusPolicy,
|
|
26
|
+
CrmStatusRule,
|
|
27
|
+
} from "@deployfoundation/foundation-capability-crm/proxy/policy";
|
package/src/deploy/cli.ts
CHANGED
|
@@ -113,6 +113,20 @@ crm-check options
|
|
|
113
113
|
--write with --base-url only: also run the write cases, which
|
|
114
114
|
create records and spend the week's outreach allowance;
|
|
115
115
|
point it at a sandbox tenant
|
|
116
|
+
with --base-url --write, what the remaining write cases need:
|
|
117
|
+
--harness-url <url> the CRM's harness endpoint (crm-harness/1), which does
|
|
118
|
+
--harness-key-stdin what only a person can; its key comes on stdin
|
|
119
|
+
--readonly-key-stdin a key for the same tenant without write scopes, on stdin
|
|
120
|
+
--status-policy <file>
|
|
121
|
+
the deployment's capabilities.crm.statusPolicy (JSON
|
|
122
|
+
or YAML)
|
|
123
|
+
--suppressed-statuses <file>
|
|
124
|
+
its capabilities.crm.outreach.suppressedStatuses
|
|
125
|
+
--spend-weekly-allowance
|
|
126
|
+
drive this week's outreach caps to their limits
|
|
127
|
+
(a throwaway database only)
|
|
128
|
+
Keys on stdin go one per line, in this order: the crm/1 key, the read-only
|
|
129
|
+
key, the harness key (each only when its flag is given).
|
|
116
130
|
|
|
117
131
|
crm-replay options
|
|
118
132
|
--from <file> REQUIRED: the writes to replay, one JSON object per line
|
|
@@ -128,6 +142,24 @@ function flag(args: readonly string[], name: string): string | undefined {
|
|
|
128
142
|
return index === -1 ? undefined : args[index + 1];
|
|
129
143
|
}
|
|
130
144
|
|
|
145
|
+
/** A flag that must carry a value when given: refuses `--x` alone or followed by another flag. */
|
|
146
|
+
function valueFlag(args: readonly string[], name: string, what: string): string | undefined {
|
|
147
|
+
if (!args.includes(name)) return undefined;
|
|
148
|
+
const value = flag(args, name);
|
|
149
|
+
if (value === undefined || value.startsWith("-")) throw new Error(`${name} needs ${what}`);
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `crm-check` inputs only the `--base-url` form takes: they feed the write cases. */
|
|
154
|
+
const CRM_CHECK_TARGET_FLAGS = [
|
|
155
|
+
"--harness-url",
|
|
156
|
+
"--harness-key-stdin",
|
|
157
|
+
"--readonly-key-stdin",
|
|
158
|
+
"--status-policy",
|
|
159
|
+
"--suppressed-statuses",
|
|
160
|
+
"--spend-weekly-allowance",
|
|
161
|
+
] as const;
|
|
162
|
+
|
|
131
163
|
/** The AWS context for a command, with the instance file already resolved. */
|
|
132
164
|
function contextFor(args: readonly string[]): AwsContext {
|
|
133
165
|
const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
|
|
@@ -235,6 +267,15 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
|
|
|
235
267
|
const tenantId = flag(args, "--tenant");
|
|
236
268
|
if (baseUrl === undefined || tenantId === undefined || !args.includes("--key-stdin"))
|
|
237
269
|
throw new Error("crm-check --base-url needs --tenant <id> and --key-stdin");
|
|
270
|
+
const harnessUrl = valueFlag(args, "--harness-url", "the CRM's harness endpoint URL");
|
|
271
|
+
// The harness acts as a person, so it is never called without its key,
|
|
272
|
+
// and the key, like every key here, comes on stdin.
|
|
273
|
+
if ((harnessUrl !== undefined) !== args.includes("--harness-key-stdin"))
|
|
274
|
+
throw new Error(
|
|
275
|
+
"crm-check --harness-url <url> and --harness-key-stdin go together: the harness key comes on stdin",
|
|
276
|
+
);
|
|
277
|
+
const statusPolicyFile = valueFlag(args, "--status-policy", "a file");
|
|
278
|
+
const suppressedStatusesFile = valueFlag(args, "--suppressed-statuses", "a file");
|
|
238
279
|
const { crmCheckTarget, defaultCrmCommandDeps } = await import("./crm.ts");
|
|
239
280
|
return crmCheckTarget(
|
|
240
281
|
{
|
|
@@ -242,6 +283,11 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
|
|
|
242
283
|
tenantId,
|
|
243
284
|
write: args.includes("--write"),
|
|
244
285
|
allowSkips: args.includes("--allow-skips"),
|
|
286
|
+
readOnlyKeyStdin: args.includes("--readonly-key-stdin"),
|
|
287
|
+
spendWeeklyAllowance: args.includes("--spend-weekly-allowance"),
|
|
288
|
+
...(harnessUrl === undefined ? {} : { harnessUrl }),
|
|
289
|
+
...(statusPolicyFile === undefined ? {} : { statusPolicyFile }),
|
|
290
|
+
...(suppressedStatusesFile === undefined ? {} : { suppressedStatusesFile }),
|
|
245
291
|
},
|
|
246
292
|
defaultCrmCommandDeps(),
|
|
247
293
|
);
|
|
@@ -330,6 +376,11 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
|
|
|
330
376
|
throw new Error(
|
|
331
377
|
"crm-check --write creates records and spends the week's outreach allowance, so it never runs against the instance's own tenant: use --base-url with a sandbox tenant",
|
|
332
378
|
);
|
|
379
|
+
const targetOnly = CRM_CHECK_TARGET_FLAGS.filter((name) => args.includes(name));
|
|
380
|
+
if (targetOnly.length > 0)
|
|
381
|
+
throw new Error(
|
|
382
|
+
`crm-check --instance runs the read cases with the deployment's own key and policy; ${targetOnly.join(", ")} feed the write cases: use --base-url --write with a sandbox tenant`,
|
|
383
|
+
);
|
|
333
384
|
const { crmCheck, defaultCrmCommandDeps } = await import("./crm.ts");
|
|
334
385
|
return crmCheck(ctx, { allowSkips: args.includes("--allow-skips") }, defaultCrmCommandDeps());
|
|
335
386
|
}
|
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,
|