@deployfoundation/foundation-deploy 0.2.51 → 0.2.53
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 +4 -1
- package/dist/bin/app.js +6 -3
- package/dist/bin/foundation-deploy.js +141 -305
- package/dist/bin/release-account.js +1 -0
- package/dist/chunk-1pww63wd.js +301 -0
- package/dist/chunk-3614v9rr.js +907 -0
- package/dist/chunk-5wgwscd0.js +4 -0
- package/dist/{chunk-gnz747z2.js → chunk-96fjaysn.js} +204 -884
- package/dist/chunk-fjdgxw8h.js +516 -0
- package/dist/chunk-jf5rw6h4.js +1117 -0
- package/dist/chunk-m76jt5rc.js +376 -0
- package/dist/chunk-mh1206ne.js +1764 -0
- package/dist/chunk-nvsj7bbw.js +259 -0
- package/dist/chunk-p7zkm4pp.js +151 -0
- package/dist/chunk-pxhftba1.js +336 -0
- package/dist/{chunk-efqdhd2e.js → chunk-spq5kr1t.js} +333 -90
- package/dist/chunk-tey0gh86.js +38 -0
- package/dist/src/index.js +17 -13
- package/package.json +3 -1
- package/src/deploy/cli.ts +71 -0
- package/src/deploy/crm.ts +491 -0
- package/src/deploy/runtime-secret.ts +9 -0
- package/src/stacks/agent-stack.ts +68 -11
- package/src/stacks/api-stack.ts +340 -87
- package/src/stacks/data-stack.ts +27 -1
- package/src/stacks/secret-bearing-reads.ts +131 -0
package/src/deploy/cli.ts
CHANGED
|
@@ -35,6 +35,9 @@ export const COMMANDS = [
|
|
|
35
35
|
"slack-manifest",
|
|
36
36
|
"stage-customization",
|
|
37
37
|
"job-key",
|
|
38
|
+
"crm-key",
|
|
39
|
+
"crm-check",
|
|
40
|
+
"crm-replay",
|
|
38
41
|
] as const;
|
|
39
42
|
|
|
40
43
|
export type Command = (typeof COMMANDS)[number];
|
|
@@ -56,6 +59,12 @@ commands
|
|
|
56
59
|
slack-manifest print the instance's Slack app manifest as JSON
|
|
57
60
|
stage-customization validate and stage a tenant-owned runtime config
|
|
58
61
|
job-key <id> print a webhook job's URL and signing key as JSON
|
|
62
|
+
crm-key write the company CRM's crm/1 key, read from stdin, into
|
|
63
|
+
the instance's <secretPrefix>/crm/http secret
|
|
64
|
+
crm-check run the crm/1 conformance suite against the instance's
|
|
65
|
+
CRM (crm.provider kind http) with its stored key
|
|
66
|
+
crm-replay replay the agent's CRM writes (JSONL) into the
|
|
67
|
+
instance's own table after a rollback to dynamodb
|
|
59
68
|
|
|
60
69
|
options
|
|
61
70
|
--instance <path> REQUIRED: path to the deployment's instance YAML
|
|
@@ -91,6 +100,24 @@ job-key options
|
|
|
91
100
|
--put-secret <id> write {url, key} into this Secrets Manager secret
|
|
92
101
|
(created if absent) instead of printing the key
|
|
93
102
|
|
|
103
|
+
crm-key options
|
|
104
|
+
--stdin REQUIRED: read the key from a pipe, never argv, e.g.
|
|
105
|
+
pbpaste | foundation-deploy crm-key --instance <path> --stdin
|
|
106
|
+
|
|
107
|
+
crm-check options
|
|
108
|
+
--allow-skips pass despite cases only a person can set up (they need
|
|
109
|
+
the CRM's own harness); without it they count as failed
|
|
110
|
+
--base-url <url> check any crm/1 server instead of the instance's: needs
|
|
111
|
+
--tenant <id> the tenant its /meta names, and the key on stdin
|
|
112
|
+
--key-stdin (--key-stdin). No --instance in this form.
|
|
113
|
+
--write with --base-url only: also run the write cases, which
|
|
114
|
+
create records and spend the week's outreach allowance;
|
|
115
|
+
point it at a sandbox tenant
|
|
116
|
+
|
|
117
|
+
crm-replay options
|
|
118
|
+
--from <file> REQUIRED: the writes to replay, one JSON object per line
|
|
119
|
+
(docs/crm-provider-contract.md); --dry-run validates only
|
|
120
|
+
|
|
94
121
|
github-app-create options
|
|
95
122
|
--org <org> GitHub org (default: the instance's github.org)
|
|
96
123
|
--port <n> local callback port (default 8765)
|
|
@@ -199,6 +226,27 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
|
|
|
199
226
|
return 0;
|
|
200
227
|
}
|
|
201
228
|
|
|
229
|
+
// A CRM's own CI checks its server with the published tool and no instance
|
|
230
|
+
// file at all: everything the suite needs is a flag, and the key a pipe.
|
|
231
|
+
if (command === "crm-check" && args.includes("--base-url")) {
|
|
232
|
+
if (args.some((arg) => arg === "--instance" || arg.startsWith("--instance=")))
|
|
233
|
+
throw new Error("crm-check takes --instance or --base-url, not both");
|
|
234
|
+
const baseUrl = flag(args, "--base-url");
|
|
235
|
+
const tenantId = flag(args, "--tenant");
|
|
236
|
+
if (baseUrl === undefined || tenantId === undefined || !args.includes("--key-stdin"))
|
|
237
|
+
throw new Error("crm-check --base-url needs --tenant <id> and --key-stdin");
|
|
238
|
+
const { crmCheckTarget, defaultCrmCommandDeps } = await import("./crm.ts");
|
|
239
|
+
return crmCheckTarget(
|
|
240
|
+
{
|
|
241
|
+
baseUrl,
|
|
242
|
+
tenantId,
|
|
243
|
+
write: args.includes("--write"),
|
|
244
|
+
allowSkips: args.includes("--allow-skips"),
|
|
245
|
+
},
|
|
246
|
+
defaultCrmCommandDeps(),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
202
250
|
if (command === "github-app-create") {
|
|
203
251
|
const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
|
|
204
252
|
console.log(instanceBanner(paths));
|
|
@@ -270,6 +318,29 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
|
|
|
270
318
|
console.log(` ${result.status}: ${result.runtimeConfigPath}`);
|
|
271
319
|
return 0;
|
|
272
320
|
}
|
|
321
|
+
// Loaded on demand: the CRM commands bring the conformance suite and,
|
|
322
|
+
// for a replay, the AWS SDK's DynamoDB client.
|
|
323
|
+
case "crm-key": {
|
|
324
|
+
const { crmKey, defaultCrmCommandDeps } = await import("./crm.ts");
|
|
325
|
+
await crmKey(ctx, { stdin: args.includes("--stdin") }, defaultCrmCommandDeps());
|
|
326
|
+
return 0;
|
|
327
|
+
}
|
|
328
|
+
case "crm-check": {
|
|
329
|
+
if (args.includes("--write"))
|
|
330
|
+
throw new Error(
|
|
331
|
+
"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
|
+
);
|
|
333
|
+
const { crmCheck, defaultCrmCommandDeps } = await import("./crm.ts");
|
|
334
|
+
return crmCheck(ctx, { allowSkips: args.includes("--allow-skips") }, defaultCrmCommandDeps());
|
|
335
|
+
}
|
|
336
|
+
case "crm-replay": {
|
|
337
|
+
const from = flag(args, "--from");
|
|
338
|
+
if (from === undefined || from.startsWith("-"))
|
|
339
|
+
throw new Error("crm-replay needs --from <file>: the writes to replay, as JSONL");
|
|
340
|
+
const { crmReplay, defaultCrmCommandDeps } = await import("./crm.ts");
|
|
341
|
+
const result = await crmReplay(ctx, { from }, defaultCrmCommandDeps());
|
|
342
|
+
return result.refused.length > 0 ? 1 : 0;
|
|
343
|
+
}
|
|
273
344
|
default:
|
|
274
345
|
console.error(`unhandled command: ${command}\n\n${USAGE}`);
|
|
275
346
|
return 1;
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator's side of a company's own CRM (`crm.provider` kind `http`,
|
|
3
|
+
* docs/crm-provider-contract.md):
|
|
4
|
+
*
|
|
5
|
+
* foundation-deploy crm-key --instance <path> --stdin
|
|
6
|
+
* foundation-deploy crm-check --instance <path> [--allow-skips]
|
|
7
|
+
* foundation-deploy crm-check --base-url <url> --tenant <id> --key-stdin [--write] [--allow-skips]
|
|
8
|
+
* foundation-deploy crm-replay --instance <path> --from <writes.jsonl> [--dry-run]
|
|
9
|
+
*
|
|
10
|
+
* - `crm-key` writes the `crm/1` key into the secret FoundationData created
|
|
11
|
+
* empty. The key arrives on a pipe and goes to the AWS CLI through a 0600
|
|
12
|
+
* temp file: never argv, never shell history, never printed.
|
|
13
|
+
* - `crm-check` runs the `crm/1` conformance suite. Against the instance it
|
|
14
|
+
* uses the deployment's base URL, tenant and stored key and runs only the
|
|
15
|
+
* read cases, since the write cases create records and spend the week's
|
|
16
|
+
* 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-replay` is the rollback half of a cutover: once `crm.provider` is
|
|
21
|
+
* back on `dynamodb`, it writes the agent's writes made in the company's CRM
|
|
22
|
+
* since the cutover (exported as JSONL) into the instance's own table,
|
|
23
|
+
* through the same DynamoDB store the proxy uses, under the deployment's
|
|
24
|
+
* policy. Drafts move each contact's touch clock but are not counted again:
|
|
25
|
+
* the shared ledger counted them when they happened.
|
|
26
|
+
*
|
|
27
|
+
* A CRM reply body never reaches this output: the suite reports fixed text,
|
|
28
|
+
* codes and statuses only, and a refused replay line is reported by its line
|
|
29
|
+
* number and refusal code.
|
|
30
|
+
*/
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import type {
|
|
33
|
+
CrmConformanceReport,
|
|
34
|
+
CrmConformanceTarget,
|
|
35
|
+
} from "@deployfoundation/foundation-capability-crm/contract/conformance";
|
|
36
|
+
import {
|
|
37
|
+
CrmIdempotencyConflictError,
|
|
38
|
+
CrmRecordNotFoundError,
|
|
39
|
+
} from "@deployfoundation/foundation-capability-crm/proxy/errors";
|
|
40
|
+
import {
|
|
41
|
+
type CrmOutreachPolicy,
|
|
42
|
+
CrmPolicyRefusedError,
|
|
43
|
+
} from "@deployfoundation/foundation-capability-crm/proxy/policy";
|
|
44
|
+
import type { CrmDocumentClient } from "@deployfoundation/foundation-capability-crm/proxy/repository";
|
|
45
|
+
import {
|
|
46
|
+
CRM_MAX_ID_CHARS,
|
|
47
|
+
CrmInputError,
|
|
48
|
+
type CrmLogActivityArgs,
|
|
49
|
+
type CrmRecordType,
|
|
50
|
+
type CrmUpdateStatusArgs,
|
|
51
|
+
type CrmUpsertRecordsArgs,
|
|
52
|
+
parseCrmLogActivityArgs,
|
|
53
|
+
parseCrmUpdateStatusArgs,
|
|
54
|
+
parseCrmUpsertRecordsArgs,
|
|
55
|
+
} from "@deployfoundation/foundation-connectors/crm";
|
|
56
|
+
import { crmHttpBaseUrl, isCrmHttpKey } from "@deployfoundation/foundation-connectors/crm-http";
|
|
57
|
+
import {
|
|
58
|
+
type CrmCapabilityConfig,
|
|
59
|
+
type InstanceConfig,
|
|
60
|
+
crmOutreachConfigured,
|
|
61
|
+
parseInstanceConfig,
|
|
62
|
+
} from "@deployfoundation/foundation-core";
|
|
63
|
+
import { type AwsContext, aws, putSecretString, readSecretString, secretExists } from "./aws.ts";
|
|
64
|
+
|
|
65
|
+
/** What these commands reach outside the process; tests replace every one. */
|
|
66
|
+
export interface CrmCommandDeps {
|
|
67
|
+
/** Everything on stdin, or a refusal when stdin is a terminal (it would echo). */
|
|
68
|
+
readStdin: () => Promise<string>;
|
|
69
|
+
secretExists: (ctx: AwsContext, secretId: string) => Promise<boolean>;
|
|
70
|
+
putSecretString: (ctx: AwsContext, secretId: string, value: string) => Promise<void>;
|
|
71
|
+
readSecretString: (ctx: AwsContext, secretId: string) => Promise<string>;
|
|
72
|
+
runConformance: (target: CrmConformanceTarget) => Promise<CrmConformanceReport>;
|
|
73
|
+
/** The physical name of the instance's CRM table. */
|
|
74
|
+
crmTableName: (ctx: AwsContext) => Promise<string>;
|
|
75
|
+
/** A DocumentClient on the operator's own credentials. */
|
|
76
|
+
documentClient: (ctx: AwsContext) => Promise<CrmDocumentClient>;
|
|
77
|
+
readFile: (path: string) => string;
|
|
78
|
+
log: (line: string) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The deployment's `http` provider, or a refusal naming what is missing. */
|
|
82
|
+
function httpProvider(ctx: AwsContext, command: string) {
|
|
83
|
+
const { instance } = ctx;
|
|
84
|
+
const provider = instance.crm.provider;
|
|
85
|
+
if (!instance.integrations.crm || provider.kind !== "http")
|
|
86
|
+
throw new Error(
|
|
87
|
+
`${command} needs crm.provider kind http (and integrations.crm) in ${ctx.paths.path}; the DynamoDB provider has no CRM key or base URL`,
|
|
88
|
+
);
|
|
89
|
+
return provider;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- crm-key ---------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
export interface CrmKeyResult {
|
|
95
|
+
secret: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Write the `crm/1` key from stdin into `<secretPrefix>/crm/http`. The secret
|
|
100
|
+
* must exist (FoundationData creates it on the first deploy with the `http`
|
|
101
|
+
* provider), so a typo in the instance cannot create a stray secret.
|
|
102
|
+
*/
|
|
103
|
+
export async function crmKey(
|
|
104
|
+
ctx: AwsContext,
|
|
105
|
+
options: { stdin: boolean },
|
|
106
|
+
deps: CrmCommandDeps,
|
|
107
|
+
): Promise<CrmKeyResult> {
|
|
108
|
+
httpProvider(ctx, "crm-key");
|
|
109
|
+
const secret = ctx.names.secretCrmHttp;
|
|
110
|
+
if (!options.stdin)
|
|
111
|
+
throw new Error(
|
|
112
|
+
"crm-key reads the key from stdin only, so it never lands in argv or shell history: pipe it in with --stdin, e.g. `pbpaste | foundation-deploy crm-key --instance <path> --stdin`",
|
|
113
|
+
);
|
|
114
|
+
const key = (await deps.readStdin()).trim();
|
|
115
|
+
if (!isCrmHttpKey(key))
|
|
116
|
+
throw new Error(
|
|
117
|
+
"stdin did not hold a usable crm/1 key: 16 to 1024 visible ASCII characters, no spaces, one per run",
|
|
118
|
+
);
|
|
119
|
+
if (!(await deps.secretExists(ctx, secret)))
|
|
120
|
+
throw new Error(
|
|
121
|
+
`secret ${secret} does not exist yet: deploy with crm.provider kind http first, which creates it empty`,
|
|
122
|
+
);
|
|
123
|
+
await deps.putSecretString(ctx, secret, JSON.stringify({ api_key: key }));
|
|
124
|
+
deps.log(
|
|
125
|
+
`wrote the crm/1 key to ${secret}. The CRM proxy reads it within 5 minutes, or at once after a 401. Check it with: foundation-deploy crm-check --instance ${ctx.paths.path}`,
|
|
126
|
+
);
|
|
127
|
+
return { secret };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- crm-check -------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
export interface CrmCheckOptions {
|
|
133
|
+
allowSkips: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The suite's read cases against the deployment's CRM, with its stored key,
|
|
138
|
+
* tenant and baked policy. Returns the process exit code.
|
|
139
|
+
*/
|
|
140
|
+
export async function crmCheck(
|
|
141
|
+
ctx: AwsContext,
|
|
142
|
+
options: CrmCheckOptions,
|
|
143
|
+
deps: CrmCommandDeps,
|
|
144
|
+
): Promise<number> {
|
|
145
|
+
const provider = httpProvider(ctx, "crm-check");
|
|
146
|
+
const secret = ctx.names.secretCrmHttp;
|
|
147
|
+
let apiKey: unknown;
|
|
148
|
+
try {
|
|
149
|
+
const raw = JSON.parse(await deps.readSecretString(ctx, secret)) as unknown;
|
|
150
|
+
if (typeof raw === "object" && raw !== null && "api_key" in raw) apiKey = raw.api_key;
|
|
151
|
+
} catch {
|
|
152
|
+
// Unreadable or not JSON: the same as no key, reported below.
|
|
153
|
+
}
|
|
154
|
+
if (!isCrmHttpKey(apiKey))
|
|
155
|
+
throw new Error(`${secret} holds no crm/1 key yet: run foundation-deploy crm-key first`);
|
|
156
|
+
const crm = crmCapability(instanceConfig(ctx, deps));
|
|
157
|
+
const report = await deps.runConformance({
|
|
158
|
+
baseUrl: provider.baseUrl,
|
|
159
|
+
apiKey,
|
|
160
|
+
tenantId: provider.expectedTenant,
|
|
161
|
+
strict: !options.allowSkips,
|
|
162
|
+
...(crm === undefined
|
|
163
|
+
? {}
|
|
164
|
+
: {
|
|
165
|
+
statusPolicy: crm.statusPolicy,
|
|
166
|
+
...(crmOutreachConfigured(crm.outreach)
|
|
167
|
+
? { suppressedStatuses: crm.outreach.suppressedStatuses }
|
|
168
|
+
: {}),
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
return reportConformance(report, provider.baseUrl, deps.log);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface CrmCheckTargetOptions extends CrmCheckOptions {
|
|
175
|
+
baseUrl: string;
|
|
176
|
+
tenantId: string;
|
|
177
|
+
write: boolean;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 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.
|
|
183
|
+
*/
|
|
184
|
+
export async function crmCheckTarget(
|
|
185
|
+
options: CrmCheckTargetOptions,
|
|
186
|
+
deps: Pick<CrmCommandDeps, "readStdin" | "runConformance" | "log">,
|
|
187
|
+
): Promise<number> {
|
|
188
|
+
let baseUrl: string;
|
|
189
|
+
try {
|
|
190
|
+
baseUrl = crmHttpBaseUrl(options.baseUrl);
|
|
191
|
+
} catch {
|
|
192
|
+
// The connector's own message says only that it must be HTTPS.
|
|
193
|
+
throw new Error("--base-url must be an HTTPS URL with no userinfo, query or fragment");
|
|
194
|
+
}
|
|
195
|
+
if (!/^[\x21-\x7e]{1,256}$/.test(options.tenantId))
|
|
196
|
+
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)");
|
|
199
|
+
const report = await deps.runConformance({
|
|
200
|
+
baseUrl,
|
|
201
|
+
apiKey,
|
|
202
|
+
tenantId: options.tenantId,
|
|
203
|
+
write: options.write,
|
|
204
|
+
strict: !options.allowSkips,
|
|
205
|
+
});
|
|
206
|
+
return reportConformance(report, baseUrl, deps.log);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function reportConformance(
|
|
210
|
+
report: CrmConformanceReport,
|
|
211
|
+
baseUrl: string,
|
|
212
|
+
log: (line: string) => void,
|
|
213
|
+
): number {
|
|
214
|
+
log(`crm/1 conformance against ${baseUrl}`);
|
|
215
|
+
for (const entry of report.cases) {
|
|
216
|
+
const mark = entry.outcome === "fail" ? "FAIL" : entry.outcome;
|
|
217
|
+
log(
|
|
218
|
+
` ${mark.padEnd(4)} ${entry.id}: ${entry.title}${entry.detail === undefined ? "" : ` (${entry.detail})`}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
log(`${report.passed} passed, ${report.failed} failed, ${report.skipped} skipped`);
|
|
222
|
+
return report.failed > 0 ? 1 : 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function instanceConfig(ctx: AwsContext, deps: Pick<CrmCommandDeps, "readFile">): InstanceConfig {
|
|
226
|
+
return parseInstanceConfig(deps.readFile(ctx.paths.configPath));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The instance's `capabilities.crm`, when it is on. */
|
|
230
|
+
function crmCapability(config: InstanceConfig): CrmCapabilityConfig | undefined {
|
|
231
|
+
const crm = config.capabilities.crm;
|
|
232
|
+
return crm.enabled ? crm : undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// --- crm-replay ------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* One write in a replay file, one JSON object per line:
|
|
239
|
+
*
|
|
240
|
+
* {"op":"upsert_records","idempotencyKey":"…","companies":[…],"contacts":[…]}
|
|
241
|
+
* {"op":"update_status","idempotencyKey":"…","recordType":"contact","externalId":"…","status":"…"}
|
|
242
|
+
* {"op":"log_activity","idempotencyKey":"…","recordType":"contact","externalId":"…",
|
|
243
|
+
* "activityType":"…","summary":"…","occurredAt":"…","attributes":{…}}
|
|
244
|
+
*
|
|
245
|
+
* Records are named by `externalId`, which both providers share; the
|
|
246
|
+
* DynamoDB store derives its own record id from it.
|
|
247
|
+
*/
|
|
248
|
+
export type CrmReplayWrite =
|
|
249
|
+
| { line: number; op: "upsert_records"; args: CrmUpsertRecordsArgs }
|
|
250
|
+
| { line: number; op: "update_status"; args: CrmUpdateStatusArgs }
|
|
251
|
+
| { line: number; op: "log_activity"; args: CrmLogActivityArgs };
|
|
252
|
+
|
|
253
|
+
export interface CrmReplayResult {
|
|
254
|
+
applied: number;
|
|
255
|
+
refused: { line: number; op: CrmReplayWrite["op"]; code: string }[];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const REPLAY_OPS = ["upsert_records", "update_status", "log_activity"] as const;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Parse and validate a whole replay file against the deployment's CRM
|
|
262
|
+
* policy. Nothing is written unless every line passes, so a file this cannot
|
|
263
|
+
* replay (a status the deployment does not have, an operation it does not
|
|
264
|
+
* take) is fixed before anything changes. Errors name the line and the rule,
|
|
265
|
+
* never its content.
|
|
266
|
+
*/
|
|
267
|
+
export function parseCrmReplay(
|
|
268
|
+
text: string,
|
|
269
|
+
crm: CrmCapabilityConfig,
|
|
270
|
+
recordIdFor: (recordType: CrmRecordType, externalId: string) => string,
|
|
271
|
+
): CrmReplayWrite[] {
|
|
272
|
+
const writes: CrmReplayWrite[] = [];
|
|
273
|
+
const statuses = {
|
|
274
|
+
company: new Set(crm.statuses.company),
|
|
275
|
+
contact: new Set(crm.statuses.contact),
|
|
276
|
+
};
|
|
277
|
+
const activityTypes = new Set(crm.activityTypes);
|
|
278
|
+
text.split("\n").forEach((raw, index) => {
|
|
279
|
+
const line = index + 1;
|
|
280
|
+
if (raw.trim() === "") return;
|
|
281
|
+
let value: unknown;
|
|
282
|
+
try {
|
|
283
|
+
value = JSON.parse(raw);
|
|
284
|
+
} catch {
|
|
285
|
+
// JSON.parse quotes the offending text; the line number is enough.
|
|
286
|
+
throw new Error(`line ${line} is not a JSON object`);
|
|
287
|
+
}
|
|
288
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
289
|
+
throw new Error(`line ${line} is not a JSON object`);
|
|
290
|
+
const { op, externalId, ...rest } = value as Record<string, unknown>;
|
|
291
|
+
if (typeof op !== "string" || !(REPLAY_OPS as readonly string[]).includes(op))
|
|
292
|
+
throw new Error(`line ${line}: op must be one of ${REPLAY_OPS.join(", ")}`);
|
|
293
|
+
try {
|
|
294
|
+
if (op === "upsert_records") {
|
|
295
|
+
if (externalId !== undefined) throw new Error("upsert_records names records inside");
|
|
296
|
+
writes.push({ line, op, args: parseCrmUpsertRecordsArgs(rest, crm.maxBatchSize) });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (
|
|
300
|
+
typeof externalId !== "string" ||
|
|
301
|
+
externalId.trim() === "" ||
|
|
302
|
+
externalId.length > CRM_MAX_ID_CHARS
|
|
303
|
+
)
|
|
304
|
+
throw new Error(`externalId must be 1 to ${CRM_MAX_ID_CHARS} characters`);
|
|
305
|
+
// An id is a provider's own; only the externalId means the same record in both.
|
|
306
|
+
if (Object.hasOwn(rest, "recordId"))
|
|
307
|
+
throw new Error("records are named by externalId, never by a provider's recordId");
|
|
308
|
+
const recordType = rest.recordType;
|
|
309
|
+
if (recordType !== "company" && recordType !== "contact")
|
|
310
|
+
throw new Error("recordType must be company or contact");
|
|
311
|
+
const named = { ...rest, recordId: recordIdFor(recordType, externalId) };
|
|
312
|
+
if (op === "update_status") {
|
|
313
|
+
const args = parseCrmUpdateStatusArgs(named);
|
|
314
|
+
if (!statuses[args.recordType].has(args.status))
|
|
315
|
+
throw new Error(`status is not a configured ${args.recordType} status`);
|
|
316
|
+
writes.push({ line, op, args });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const args = parseCrmLogActivityArgs(named);
|
|
320
|
+
if (!activityTypes.has(args.activityType))
|
|
321
|
+
throw new Error("activityType is not a configured activity type");
|
|
322
|
+
writes.push({ line, op: "log_activity", args });
|
|
323
|
+
} catch (error) {
|
|
324
|
+
// Parser and rule messages name the rule, never the value.
|
|
325
|
+
throw new Error(`line ${line}: ${error instanceof Error ? error.message : String(error)}`);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
return writes;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Replay a file of the agent's CRM writes into the instance's own table.
|
|
333
|
+
* Each line keeps its idempotency key, so running the same file twice writes
|
|
334
|
+
* nothing new. A write the policy refuses (a status change a later one made
|
|
335
|
+
* stale, a contact already drafted) is reported and the rest go on; any
|
|
336
|
+
* other failure stops at that line.
|
|
337
|
+
*/
|
|
338
|
+
export async function crmReplay(
|
|
339
|
+
ctx: AwsContext,
|
|
340
|
+
options: { from: string },
|
|
341
|
+
deps: CrmCommandDeps,
|
|
342
|
+
): Promise<CrmReplayResult> {
|
|
343
|
+
const { instance } = ctx;
|
|
344
|
+
if (!instance.integrations.crmStorage)
|
|
345
|
+
throw new Error(
|
|
346
|
+
`crm-replay needs the instance's CRM table (integrations.crm) in ${ctx.paths.path}`,
|
|
347
|
+
);
|
|
348
|
+
if (instance.crm.provider.kind !== "dynamodb")
|
|
349
|
+
throw new Error(
|
|
350
|
+
"crm-replay writes into the DynamoDB provider's table: set crm.provider back to kind dynamodb, deploy, then replay",
|
|
351
|
+
);
|
|
352
|
+
const config = instanceConfig(ctx, deps);
|
|
353
|
+
const crm = crmCapability(config);
|
|
354
|
+
if (crm === undefined || crm.statuses.company.length === 0 || crm.statuses.contact.length === 0)
|
|
355
|
+
throw new Error(`${ctx.paths.configPath}: crm-replay needs capabilities.crm with its statuses`);
|
|
356
|
+
// Loaded here, not at the top: it brings the AWS SDK's DynamoDB client,
|
|
357
|
+
// which no other command needs.
|
|
358
|
+
const { createDynamoCrmClient, deterministicRecordId } = await import(
|
|
359
|
+
"@deployfoundation/foundation-capability-crm/proxy/repository"
|
|
360
|
+
);
|
|
361
|
+
const writes = parseCrmReplay(deps.readFile(options.from), crm, deterministicRecordId);
|
|
362
|
+
const counts = REPLAY_OPS.map(
|
|
363
|
+
(op) => `${writes.filter((write) => write.op === op).length} ${op}`,
|
|
364
|
+
).join(", ");
|
|
365
|
+
if (ctx.dryRun === true) {
|
|
366
|
+
deps.log(`dry run: ${writes.length} writes would be replayed (${counts})`);
|
|
367
|
+
return { applied: 0, refused: [] };
|
|
368
|
+
}
|
|
369
|
+
const outreach: CrmOutreachPolicy | undefined = crmOutreachConfigured(crm.outreach)
|
|
370
|
+
? { ...crm.outreach, timezone: config.timezone }
|
|
371
|
+
: undefined;
|
|
372
|
+
const client = createDynamoCrmClient({
|
|
373
|
+
documentClient: await deps.documentClient(ctx),
|
|
374
|
+
tableName: await deps.crmTableName(ctx),
|
|
375
|
+
// The tenant the CRM proxy serves: the instance's name, as it bakes it.
|
|
376
|
+
tenantId: instance.name,
|
|
377
|
+
defaultStatuses: {
|
|
378
|
+
company: crm.statuses.company[0] as string,
|
|
379
|
+
contact: crm.statuses.contact[0] as string,
|
|
380
|
+
},
|
|
381
|
+
maxBatchSize: crm.maxBatchSize,
|
|
382
|
+
actor: {
|
|
383
|
+
teamId: instance.slack.teamId,
|
|
384
|
+
userId: "foundation-deploy",
|
|
385
|
+
channelId: "crm-replay",
|
|
386
|
+
sessionKey: `crm-replay:${instance.name}`,
|
|
387
|
+
},
|
|
388
|
+
statusPolicy: crm.statusPolicy,
|
|
389
|
+
...(outreach === undefined ? {} : { outreach }),
|
|
390
|
+
countsOutreach: false,
|
|
391
|
+
});
|
|
392
|
+
deps.log(`replaying ${writes.length} writes (${counts})`);
|
|
393
|
+
const result: CrmReplayResult = { applied: 0, refused: [] };
|
|
394
|
+
for (const write of writes) {
|
|
395
|
+
try {
|
|
396
|
+
if (write.op === "upsert_records") await client.upsertRecords(write.args);
|
|
397
|
+
else if (write.op === "update_status") await client.updateStatus(write.args);
|
|
398
|
+
else await client.logActivity(write.args);
|
|
399
|
+
result.applied += 1;
|
|
400
|
+
} catch (error) {
|
|
401
|
+
const code = replayRefusal(error);
|
|
402
|
+
if (code === undefined)
|
|
403
|
+
throw new Error(
|
|
404
|
+
`line ${write.line} (${write.op}) failed: ${error instanceof Error ? error.name : "error"}; lines before it were replayed, and rerunning the file skips them`,
|
|
405
|
+
);
|
|
406
|
+
result.refused.push({ line: write.line, op: write.op, code });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
deps.log(`${result.applied} applied, ${result.refused.length} refused`);
|
|
410
|
+
for (const refused of result.refused)
|
|
411
|
+
deps.log(` line ${refused.line} (${refused.op}): ${refused.code}`);
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The fixed code for a write the store refused, or undefined for a failure. */
|
|
416
|
+
function replayRefusal(error: unknown): string | undefined {
|
|
417
|
+
if (error instanceof CrmPolicyRefusedError) return error.code;
|
|
418
|
+
if (error instanceof CrmRecordNotFoundError) return "not_found";
|
|
419
|
+
if (error instanceof CrmIdempotencyConflictError) return "conflict";
|
|
420
|
+
if (error instanceof CrmInputError) return "invalid_input";
|
|
421
|
+
return undefined;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// --- production dependencies ------------------------------------------------
|
|
425
|
+
|
|
426
|
+
/** Every byte on stdin; refuses a terminal, where the key would be echoed. */
|
|
427
|
+
async function readPipedStdin(): Promise<string> {
|
|
428
|
+
if (process.stdin.isTTY === true)
|
|
429
|
+
throw new Error(
|
|
430
|
+
"stdin is a terminal, where the key would be echoed: pipe it in instead, e.g. `pbpaste | foundation-deploy …`",
|
|
431
|
+
);
|
|
432
|
+
const chunks: Buffer[] = [];
|
|
433
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk as Uint8Array));
|
|
434
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** The CRM table FoundationData created; its name is CloudFormation's. */
|
|
438
|
+
async function crmTableNameFromStack(ctx: AwsContext): Promise<string> {
|
|
439
|
+
const names = (
|
|
440
|
+
await aws(ctx, [
|
|
441
|
+
"cloudformation",
|
|
442
|
+
"describe-stack-resources",
|
|
443
|
+
"--stack-name",
|
|
444
|
+
ctx.names.data,
|
|
445
|
+
"--query",
|
|
446
|
+
"StackResources[?ResourceType=='AWS::DynamoDB::Table' && starts_with(LogicalResourceId, 'CrmTable')].PhysicalResourceId",
|
|
447
|
+
"--output",
|
|
448
|
+
"text",
|
|
449
|
+
])
|
|
450
|
+
)
|
|
451
|
+
.split(/\s+/)
|
|
452
|
+
.filter((name) => name !== "" && name !== "None");
|
|
453
|
+
if (names.length !== 1)
|
|
454
|
+
throw new Error(
|
|
455
|
+
`expected one CRM table in ${ctx.names.data}, found ${names.length}: is integrations.crm deployed?`,
|
|
456
|
+
);
|
|
457
|
+
return names[0] as string;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function operatorDocumentClient(ctx: AwsContext): Promise<CrmDocumentClient> {
|
|
461
|
+
const [{ DynamoDBClient }, { DynamoDBDocumentClient }] = await Promise.all([
|
|
462
|
+
import("@aws-sdk/client-dynamodb"),
|
|
463
|
+
import("@aws-sdk/lib-dynamodb"),
|
|
464
|
+
]);
|
|
465
|
+
return DynamoDBDocumentClient.from(
|
|
466
|
+
new DynamoDBClient({
|
|
467
|
+
region: ctx.region,
|
|
468
|
+
...(ctx.profile === "" ? {} : { profile: ctx.profile }),
|
|
469
|
+
}),
|
|
470
|
+
{ marshallOptions: { removeUndefinedValues: true } },
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function defaultCrmCommandDeps(): CrmCommandDeps {
|
|
475
|
+
return {
|
|
476
|
+
readStdin: readPipedStdin,
|
|
477
|
+
secretExists,
|
|
478
|
+
putSecretString,
|
|
479
|
+
readSecretString,
|
|
480
|
+
runConformance: async (target) => {
|
|
481
|
+
const { runCrmConformance } = await import(
|
|
482
|
+
"@deployfoundation/foundation-capability-crm/contract/conformance"
|
|
483
|
+
);
|
|
484
|
+
return runCrmConformance(target);
|
|
485
|
+
},
|
|
486
|
+
crmTableName: crmTableNameFromStack,
|
|
487
|
+
documentClient: operatorDocumentClient,
|
|
488
|
+
readFile: (path) => readFileSync(path, "utf8"),
|
|
489
|
+
log: (line) => console.log(line),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
instanceNames as namesFor,
|
|
13
13
|
slackCommandPrefix,
|
|
14
14
|
} from "@deployfoundation/foundation-core/instance";
|
|
15
|
+
import { mongoCollectionPolicyEnv } from "@deployfoundation/foundation-core/mongodb-collections";
|
|
15
16
|
|
|
16
17
|
/** The pinned AgentCore endpoint a deploy promotes and the invoker calls. */
|
|
17
18
|
export const LIVE_ENDPOINT = "live";
|
|
@@ -258,6 +259,14 @@ export function composeRuntimeSecret(inputs: RuntimeSecretInputs): Record<string
|
|
|
258
259
|
// LAZILY, so a secret still holding the CDK placeholder costs nothing
|
|
259
260
|
// until someone asks a MongoDB question.
|
|
260
261
|
FOUNDATION_MONGODB_SECRET_ID: names.secretMongodbReadonly,
|
|
262
|
+
// Collections the MongoDB tools refuse beyond the compiled-in defaults,
|
|
263
|
+
// from the instance file and never from config.yaml, so only a reviewed
|
|
264
|
+
// deploy changes it. Absent when the instance file adds nothing: the
|
|
265
|
+
// defaults hold without it.
|
|
266
|
+
...(instance.mongodbReadonly.deniedCollections.length > 0 ||
|
|
267
|
+
instance.mongodbReadonly.unsafeReadDefaultDeniedCollections.length > 0
|
|
268
|
+
? { FOUNDATION_MONGODB_COLLECTION_POLICY: mongoCollectionPolicyEnv(instance.mongodbReadonly) }
|
|
269
|
+
: {}),
|
|
261
270
|
// CRM storage, the Otter credential, the Knock OAuth secrets, and the
|
|
262
271
|
// Upwork/E*TRADE OAuth secrets are intentionally absent here: the
|
|
263
272
|
// shell-capable agent receives only their fixed proxy ARNs.
|