@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
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import {
|
|
2
|
+
aws,
|
|
3
|
+
putSecretString,
|
|
4
|
+
readSecretString,
|
|
5
|
+
secretExists
|
|
6
|
+
} from "./chunk-nvsj7bbw.js";
|
|
7
|
+
import"./chunk-fjdgxw8h.js";
|
|
8
|
+
import {
|
|
9
|
+
CrmIdempotencyConflictError,
|
|
10
|
+
CrmRecordNotFoundError
|
|
11
|
+
} from "./chunk-tey0gh86.js";
|
|
12
|
+
import {
|
|
13
|
+
crmHttpBaseUrl,
|
|
14
|
+
isCrmHttpKey
|
|
15
|
+
} from "./chunk-m76jt5rc.js";
|
|
16
|
+
import {
|
|
17
|
+
CrmPolicyRefusedError
|
|
18
|
+
} from "./chunk-p7zkm4pp.js";
|
|
19
|
+
import {
|
|
20
|
+
CRM_MAX_ID_CHARS,
|
|
21
|
+
CrmInputError,
|
|
22
|
+
parseCrmLogActivityArgs,
|
|
23
|
+
parseCrmUpdateStatusArgs,
|
|
24
|
+
parseCrmUpsertRecordsArgs
|
|
25
|
+
} from "./chunk-3614v9rr.js";
|
|
26
|
+
import {
|
|
27
|
+
crmOutreachConfigured,
|
|
28
|
+
parseInstanceConfig
|
|
29
|
+
} from "./chunk-96fjaysn.js";
|
|
30
|
+
import {
|
|
31
|
+
__require
|
|
32
|
+
} from "./chunk-5wgwscd0.js";
|
|
33
|
+
|
|
34
|
+
// src/deploy/crm.ts
|
|
35
|
+
import { readFileSync } from "node:fs";
|
|
36
|
+
function httpProvider(ctx, command) {
|
|
37
|
+
const { instance } = ctx;
|
|
38
|
+
const provider = instance.crm.provider;
|
|
39
|
+
if (!instance.integrations.crm || provider.kind !== "http")
|
|
40
|
+
throw new Error(`${command} needs crm.provider kind http (and integrations.crm) in ${ctx.paths.path}; the DynamoDB provider has no CRM key or base URL`);
|
|
41
|
+
return provider;
|
|
42
|
+
}
|
|
43
|
+
async function crmKey(ctx, options, deps) {
|
|
44
|
+
httpProvider(ctx, "crm-key");
|
|
45
|
+
const secret = ctx.names.secretCrmHttp;
|
|
46
|
+
if (!options.stdin)
|
|
47
|
+
throw new Error("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`");
|
|
48
|
+
const key = (await deps.readStdin()).trim();
|
|
49
|
+
if (!isCrmHttpKey(key))
|
|
50
|
+
throw new Error("stdin did not hold a usable crm/1 key: 16 to 1024 visible ASCII characters, no spaces, one per run");
|
|
51
|
+
if (!await deps.secretExists(ctx, secret))
|
|
52
|
+
throw new Error(`secret ${secret} does not exist yet: deploy with crm.provider kind http first, which creates it empty`);
|
|
53
|
+
await deps.putSecretString(ctx, secret, JSON.stringify({ api_key: key }));
|
|
54
|
+
deps.log(`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}`);
|
|
55
|
+
return { secret };
|
|
56
|
+
}
|
|
57
|
+
async function crmCheck(ctx, options, deps) {
|
|
58
|
+
const provider = httpProvider(ctx, "crm-check");
|
|
59
|
+
const secret = ctx.names.secretCrmHttp;
|
|
60
|
+
let apiKey;
|
|
61
|
+
try {
|
|
62
|
+
const raw = JSON.parse(await deps.readSecretString(ctx, secret));
|
|
63
|
+
if (typeof raw === "object" && raw !== null && "api_key" in raw)
|
|
64
|
+
apiKey = raw.api_key;
|
|
65
|
+
} catch {}
|
|
66
|
+
if (!isCrmHttpKey(apiKey))
|
|
67
|
+
throw new Error(`${secret} holds no crm/1 key yet: run foundation-deploy crm-key first`);
|
|
68
|
+
const crm = crmCapability(instanceConfig(ctx, deps));
|
|
69
|
+
const report = await deps.runConformance({
|
|
70
|
+
baseUrl: provider.baseUrl,
|
|
71
|
+
apiKey,
|
|
72
|
+
tenantId: provider.expectedTenant,
|
|
73
|
+
strict: !options.allowSkips,
|
|
74
|
+
...crm === undefined ? {} : {
|
|
75
|
+
statusPolicy: crm.statusPolicy,
|
|
76
|
+
...crmOutreachConfigured(crm.outreach) ? { suppressedStatuses: crm.outreach.suppressedStatuses } : {}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return reportConformance(report, provider.baseUrl, deps.log);
|
|
80
|
+
}
|
|
81
|
+
async function crmCheckTarget(options, deps) {
|
|
82
|
+
let baseUrl;
|
|
83
|
+
try {
|
|
84
|
+
baseUrl = crmHttpBaseUrl(options.baseUrl);
|
|
85
|
+
} catch {
|
|
86
|
+
throw new Error("--base-url must be an HTTPS URL with no userinfo, query or fragment");
|
|
87
|
+
}
|
|
88
|
+
if (!/^[\x21-\x7e]{1,256}$/.test(options.tenantId))
|
|
89
|
+
throw new Error("--tenant must be the tenant id the server's /meta names");
|
|
90
|
+
const apiKey = (await deps.readStdin()).trim();
|
|
91
|
+
if (!isCrmHttpKey(apiKey))
|
|
92
|
+
throw new Error("stdin did not hold a usable crm/1 key (--key-stdin)");
|
|
93
|
+
const report = await deps.runConformance({
|
|
94
|
+
baseUrl,
|
|
95
|
+
apiKey,
|
|
96
|
+
tenantId: options.tenantId,
|
|
97
|
+
write: options.write,
|
|
98
|
+
strict: !options.allowSkips
|
|
99
|
+
});
|
|
100
|
+
return reportConformance(report, baseUrl, deps.log);
|
|
101
|
+
}
|
|
102
|
+
function reportConformance(report, baseUrl, log) {
|
|
103
|
+
log(`crm/1 conformance against ${baseUrl}`);
|
|
104
|
+
for (const entry of report.cases) {
|
|
105
|
+
const mark = entry.outcome === "fail" ? "FAIL" : entry.outcome;
|
|
106
|
+
log(` ${mark.padEnd(4)} ${entry.id}: ${entry.title}${entry.detail === undefined ? "" : ` (${entry.detail})`}`);
|
|
107
|
+
}
|
|
108
|
+
log(`${report.passed} passed, ${report.failed} failed, ${report.skipped} skipped`);
|
|
109
|
+
return report.failed > 0 ? 1 : 0;
|
|
110
|
+
}
|
|
111
|
+
function instanceConfig(ctx, deps) {
|
|
112
|
+
return parseInstanceConfig(deps.readFile(ctx.paths.configPath));
|
|
113
|
+
}
|
|
114
|
+
function crmCapability(config) {
|
|
115
|
+
const crm = config.capabilities.crm;
|
|
116
|
+
return crm.enabled ? crm : undefined;
|
|
117
|
+
}
|
|
118
|
+
var REPLAY_OPS = ["upsert_records", "update_status", "log_activity"];
|
|
119
|
+
function parseCrmReplay(text, crm, recordIdFor) {
|
|
120
|
+
const writes = [];
|
|
121
|
+
const statuses = {
|
|
122
|
+
company: new Set(crm.statuses.company),
|
|
123
|
+
contact: new Set(crm.statuses.contact)
|
|
124
|
+
};
|
|
125
|
+
const activityTypes = new Set(crm.activityTypes);
|
|
126
|
+
text.split(`
|
|
127
|
+
`).forEach((raw, index) => {
|
|
128
|
+
const line = index + 1;
|
|
129
|
+
if (raw.trim() === "")
|
|
130
|
+
return;
|
|
131
|
+
let value;
|
|
132
|
+
try {
|
|
133
|
+
value = JSON.parse(raw);
|
|
134
|
+
} catch {
|
|
135
|
+
throw new Error(`line ${line} is not a JSON object`);
|
|
136
|
+
}
|
|
137
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
138
|
+
throw new Error(`line ${line} is not a JSON object`);
|
|
139
|
+
const { op, externalId, ...rest } = value;
|
|
140
|
+
if (typeof op !== "string" || !REPLAY_OPS.includes(op))
|
|
141
|
+
throw new Error(`line ${line}: op must be one of ${REPLAY_OPS.join(", ")}`);
|
|
142
|
+
try {
|
|
143
|
+
if (op === "upsert_records") {
|
|
144
|
+
if (externalId !== undefined)
|
|
145
|
+
throw new Error("upsert_records names records inside");
|
|
146
|
+
writes.push({ line, op, args: parseCrmUpsertRecordsArgs(rest, crm.maxBatchSize) });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (typeof externalId !== "string" || externalId.trim() === "" || externalId.length > CRM_MAX_ID_CHARS)
|
|
150
|
+
throw new Error(`externalId must be 1 to ${CRM_MAX_ID_CHARS} characters`);
|
|
151
|
+
if (Object.hasOwn(rest, "recordId"))
|
|
152
|
+
throw new Error("records are named by externalId, never by a provider's recordId");
|
|
153
|
+
const recordType = rest.recordType;
|
|
154
|
+
if (recordType !== "company" && recordType !== "contact")
|
|
155
|
+
throw new Error("recordType must be company or contact");
|
|
156
|
+
const named = { ...rest, recordId: recordIdFor(recordType, externalId) };
|
|
157
|
+
if (op === "update_status") {
|
|
158
|
+
const args2 = parseCrmUpdateStatusArgs(named);
|
|
159
|
+
if (!statuses[args2.recordType].has(args2.status))
|
|
160
|
+
throw new Error(`status is not a configured ${args2.recordType} status`);
|
|
161
|
+
writes.push({ line, op, args: args2 });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const args = parseCrmLogActivityArgs(named);
|
|
165
|
+
if (!activityTypes.has(args.activityType))
|
|
166
|
+
throw new Error("activityType is not a configured activity type");
|
|
167
|
+
writes.push({ line, op: "log_activity", args });
|
|
168
|
+
} catch (error) {
|
|
169
|
+
throw new Error(`line ${line}: ${error instanceof Error ? error.message : String(error)}`);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
return writes;
|
|
173
|
+
}
|
|
174
|
+
async function crmReplay(ctx, options, deps) {
|
|
175
|
+
const { instance } = ctx;
|
|
176
|
+
if (!instance.integrations.crmStorage)
|
|
177
|
+
throw new Error(`crm-replay needs the instance's CRM table (integrations.crm) in ${ctx.paths.path}`);
|
|
178
|
+
if (instance.crm.provider.kind !== "dynamodb")
|
|
179
|
+
throw new Error("crm-replay writes into the DynamoDB provider's table: set crm.provider back to kind dynamodb, deploy, then replay");
|
|
180
|
+
const config = instanceConfig(ctx, deps);
|
|
181
|
+
const crm = crmCapability(config);
|
|
182
|
+
if (crm === undefined || crm.statuses.company.length === 0 || crm.statuses.contact.length === 0)
|
|
183
|
+
throw new Error(`${ctx.paths.configPath}: crm-replay needs capabilities.crm with its statuses`);
|
|
184
|
+
const { createDynamoCrmClient, deterministicRecordId } = await import("./chunk-mh1206ne.js");
|
|
185
|
+
const writes = parseCrmReplay(deps.readFile(options.from), crm, deterministicRecordId);
|
|
186
|
+
const counts = REPLAY_OPS.map((op) => `${writes.filter((write) => write.op === op).length} ${op}`).join(", ");
|
|
187
|
+
if (ctx.dryRun === true) {
|
|
188
|
+
deps.log(`dry run: ${writes.length} writes would be replayed (${counts})`);
|
|
189
|
+
return { applied: 0, refused: [] };
|
|
190
|
+
}
|
|
191
|
+
const outreach = crmOutreachConfigured(crm.outreach) ? { ...crm.outreach, timezone: config.timezone } : undefined;
|
|
192
|
+
const client = createDynamoCrmClient({
|
|
193
|
+
documentClient: await deps.documentClient(ctx),
|
|
194
|
+
tableName: await deps.crmTableName(ctx),
|
|
195
|
+
tenantId: instance.name,
|
|
196
|
+
defaultStatuses: {
|
|
197
|
+
company: crm.statuses.company[0],
|
|
198
|
+
contact: crm.statuses.contact[0]
|
|
199
|
+
},
|
|
200
|
+
maxBatchSize: crm.maxBatchSize,
|
|
201
|
+
actor: {
|
|
202
|
+
teamId: instance.slack.teamId,
|
|
203
|
+
userId: "foundation-deploy",
|
|
204
|
+
channelId: "crm-replay",
|
|
205
|
+
sessionKey: `crm-replay:${instance.name}`
|
|
206
|
+
},
|
|
207
|
+
statusPolicy: crm.statusPolicy,
|
|
208
|
+
...outreach === undefined ? {} : { outreach },
|
|
209
|
+
countsOutreach: false
|
|
210
|
+
});
|
|
211
|
+
deps.log(`replaying ${writes.length} writes (${counts})`);
|
|
212
|
+
const result = { applied: 0, refused: [] };
|
|
213
|
+
for (const write of writes) {
|
|
214
|
+
try {
|
|
215
|
+
if (write.op === "upsert_records")
|
|
216
|
+
await client.upsertRecords(write.args);
|
|
217
|
+
else if (write.op === "update_status")
|
|
218
|
+
await client.updateStatus(write.args);
|
|
219
|
+
else
|
|
220
|
+
await client.logActivity(write.args);
|
|
221
|
+
result.applied += 1;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const code = replayRefusal(error);
|
|
224
|
+
if (code === undefined)
|
|
225
|
+
throw new Error(`line ${write.line} (${write.op}) failed: ${error instanceof Error ? error.name : "error"}; lines before it were replayed, and rerunning the file skips them`);
|
|
226
|
+
result.refused.push({ line: write.line, op: write.op, code });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
deps.log(`${result.applied} applied, ${result.refused.length} refused`);
|
|
230
|
+
for (const refused of result.refused)
|
|
231
|
+
deps.log(` line ${refused.line} (${refused.op}): ${refused.code}`);
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
function replayRefusal(error) {
|
|
235
|
+
if (error instanceof CrmPolicyRefusedError)
|
|
236
|
+
return error.code;
|
|
237
|
+
if (error instanceof CrmRecordNotFoundError)
|
|
238
|
+
return "not_found";
|
|
239
|
+
if (error instanceof CrmIdempotencyConflictError)
|
|
240
|
+
return "conflict";
|
|
241
|
+
if (error instanceof CrmInputError)
|
|
242
|
+
return "invalid_input";
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
async function readPipedStdin() {
|
|
246
|
+
if (process.stdin.isTTY === true)
|
|
247
|
+
throw new Error("stdin is a terminal, where the key would be echoed: pipe it in instead, e.g. `pbpaste | foundation-deploy …`");
|
|
248
|
+
const chunks = [];
|
|
249
|
+
for await (const chunk of process.stdin)
|
|
250
|
+
chunks.push(Buffer.from(chunk));
|
|
251
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
252
|
+
}
|
|
253
|
+
async function crmTableNameFromStack(ctx) {
|
|
254
|
+
const names = (await aws(ctx, [
|
|
255
|
+
"cloudformation",
|
|
256
|
+
"describe-stack-resources",
|
|
257
|
+
"--stack-name",
|
|
258
|
+
ctx.names.data,
|
|
259
|
+
"--query",
|
|
260
|
+
"StackResources[?ResourceType=='AWS::DynamoDB::Table' && starts_with(LogicalResourceId, 'CrmTable')].PhysicalResourceId",
|
|
261
|
+
"--output",
|
|
262
|
+
"text"
|
|
263
|
+
])).split(/\s+/).filter((name) => name !== "" && name !== "None");
|
|
264
|
+
if (names.length !== 1)
|
|
265
|
+
throw new Error(`expected one CRM table in ${ctx.names.data}, found ${names.length}: is integrations.crm deployed?`);
|
|
266
|
+
return names[0];
|
|
267
|
+
}
|
|
268
|
+
async function operatorDocumentClient(ctx) {
|
|
269
|
+
const [{ DynamoDBClient }, { DynamoDBDocumentClient }] = await Promise.all([
|
|
270
|
+
import("@aws-sdk/client-dynamodb"),
|
|
271
|
+
import("@aws-sdk/lib-dynamodb")
|
|
272
|
+
]);
|
|
273
|
+
return DynamoDBDocumentClient.from(new DynamoDBClient({
|
|
274
|
+
region: ctx.region,
|
|
275
|
+
...ctx.profile === "" ? {} : { profile: ctx.profile }
|
|
276
|
+
}), { marshallOptions: { removeUndefinedValues: true } });
|
|
277
|
+
}
|
|
278
|
+
function defaultCrmCommandDeps() {
|
|
279
|
+
return {
|
|
280
|
+
readStdin: readPipedStdin,
|
|
281
|
+
secretExists,
|
|
282
|
+
putSecretString,
|
|
283
|
+
readSecretString,
|
|
284
|
+
runConformance: async (target) => {
|
|
285
|
+
const { runCrmConformance } = await import("./chunk-jf5rw6h4.js");
|
|
286
|
+
return runCrmConformance(target);
|
|
287
|
+
},
|
|
288
|
+
crmTableName: crmTableNameFromStack,
|
|
289
|
+
documentClient: operatorDocumentClient,
|
|
290
|
+
readFile: (path) => readFileSync(path, "utf8"),
|
|
291
|
+
log: (line) => console.log(line)
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
export {
|
|
295
|
+
crmCheck,
|
|
296
|
+
crmCheckTarget,
|
|
297
|
+
crmKey,
|
|
298
|
+
crmReplay,
|
|
299
|
+
defaultCrmCommandDeps,
|
|
300
|
+
parseCrmReplay
|
|
301
|
+
};
|