@reventlessdev/reventless-aws 3.0.0-alpha.290 → 3.0.0-alpha.292
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/CHANGELOG.md +15 -0
- package/package.json +11 -11
- package/src/Platform.res +115 -9
- package/src/Platform.res.mjs +101 -12
- package/src/adapter/Api/Platform_AdminScan_Ops.res +7 -3
- package/src/adapter/Api/Platform_AdminScan_Ops.res.mjs +2 -1
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +61 -1
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +29 -2
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res +248 -6
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res.mjs +181 -9
- package/src/plugin/runtime/PluginRuntime_Builder.res +16 -0
- package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +6 -0
- package/src/util/Util_ShellConfig.res +18 -3
- package/src/util/Util_ShellConfig.res.mjs +5 -1
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res +174 -0
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res.mjs +130 -0
- package/tests/Util_ShellConfigTest.res +41 -0
- package/tests/Util_ShellConfigTest.res.mjs +51 -5
|
@@ -38,7 +38,7 @@ export function response(ctx) {
|
|
|
38
38
|
}
|
|
39
39
|
`;
|
|
40
40
|
|
|
41
|
-
function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, opts) {
|
|
41
|
+
function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, bakedManifest, opts) {
|
|
42
42
|
let opts$1 = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
|
|
43
43
|
let name = "PlatformUIDefinitions";
|
|
44
44
|
let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name + "Lambda", Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name + "Lambda", "Platform", "Identity", "Platform", undefined, undefined, undefined, undefined), opts$1);
|
|
@@ -46,6 +46,7 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, opt
|
|
|
46
46
|
pluginReadModelTableName,
|
|
47
47
|
offloadBucketName
|
|
48
48
|
]).apply(param => {
|
|
49
|
+
let offloadBucket = param[1];
|
|
49
50
|
new (Aws.iam.RolePolicy)(name + "LambdaPolicy", {
|
|
50
51
|
policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "LambdaPolicy", [
|
|
51
52
|
{
|
|
@@ -64,12 +65,30 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, opt
|
|
|
64
65
|
Sid: "AllowOffloadGet",
|
|
65
66
|
Effect: "Allow",
|
|
66
67
|
Action: ["s3:GetObject"],
|
|
67
|
-
Resource: "arn:aws:s3:::" +
|
|
68
|
+
Resource: "arn:aws:s3:::" + offloadBucket + "/*"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
Sid: "AllowOffloadList",
|
|
72
|
+
Effect: "Allow",
|
|
73
|
+
Action: ["s3:ListBucket"],
|
|
74
|
+
Resource: "arn:aws:s3:::" + offloadBucket
|
|
68
75
|
}
|
|
69
76
|
])),
|
|
70
77
|
role: lambdaRole.id
|
|
71
78
|
}, opts$1);
|
|
72
79
|
});
|
|
80
|
+
let bakeSelectionsJson = bakedManifest !== undefined ? JSON.stringify(bakedManifest.components.map(sel => {
|
|
81
|
+
let entry = Object.fromEntries([[
|
|
82
|
+
"plugin",
|
|
83
|
+
sel.plugin
|
|
84
|
+
]]);
|
|
85
|
+
let strings = (key, value) => Stdlib_Option.forEach(value, names => {
|
|
86
|
+
entry[key] = names.map(prim => prim);
|
|
87
|
+
});
|
|
88
|
+
strings("views", sel.views);
|
|
89
|
+
strings("commands", sel.commands);
|
|
90
|
+
return entry;
|
|
91
|
+
})) : "";
|
|
73
92
|
let adminEntryJson = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(Platform_Admin_Structure$ReventlessCore.pluginId, Platform_Admin_Structure$ReventlessCore.structure));
|
|
74
93
|
let packageDirs = Object.fromEntries([[
|
|
75
94
|
"@reventlessdev/reventless-aws",
|
|
@@ -104,6 +123,10 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, opt
|
|
|
104
123
|
"OFFLOAD_BUCKET",
|
|
105
124
|
offloadBucketName
|
|
106
125
|
],
|
|
126
|
+
[
|
|
127
|
+
"BAKE_SELECTIONS",
|
|
128
|
+
bakeSelectionsJson
|
|
129
|
+
],
|
|
107
130
|
[
|
|
108
131
|
"NODE_OPTIONS",
|
|
109
132
|
Util_Bundle$ReventlessAws.esmLoaderNodeOptions
|
|
@@ -143,6 +166,10 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, opt
|
|
|
143
166
|
}, opts$1);
|
|
144
167
|
AppSync_Resolver_Native$ReventlessAws.makeUnitJsResolver(name + "Resolver", api, dataSource.name, "Query", "Platform_ComponentDefinitions", resolverCode, opts$1);
|
|
145
168
|
schemaReady.apply(() => AppSync_Resolver_Native$ReventlessAws.makeUnitJsResolver(name + "StructuresResolver", api, dataSource.name, "Query", "Platform_PluginStructures", completeResolverCode, opts$1));
|
|
169
|
+
return {
|
|
170
|
+
roleId: lambdaRole.id,
|
|
171
|
+
functionName: lambda.name
|
|
172
|
+
};
|
|
146
173
|
}
|
|
147
174
|
|
|
148
175
|
export {
|
|
@@ -216,7 +216,18 @@ let resolveStructure = (
|
|
|
216
216
|
->Option.flatMap(JSON.Decode.string) {
|
|
217
217
|
| None => Promise.resolve(item)
|
|
218
218
|
| Some(key) =>
|
|
219
|
-
fetch(key)
|
|
219
|
+
fetch(key)
|
|
220
|
+
// The bucket answers for the whole platform, so a failure here says which
|
|
221
|
+
// plugin's ref could not be read — otherwise the S3 error names the key
|
|
222
|
+
// and the bucket, and finding the row it came from is a table scan by hand.
|
|
223
|
+
->Promise.catch(e => {
|
|
224
|
+
let plugin = item->str("name")->Option.getOr("<unnamed>")
|
|
225
|
+
JsError.throwWithMessage(
|
|
226
|
+
`offloaded structure for plugin ${plugin} is unreadable at ${key}: ` ++
|
|
227
|
+
e->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown error"),
|
|
228
|
+
)
|
|
229
|
+
})
|
|
230
|
+
->Promise.then(bytes => {
|
|
220
231
|
let resolved = Dict.fromArray(item->Dict.toArray)
|
|
221
232
|
resolved->Dict.set("structure", JSON.parseOrThrow(bytes))
|
|
222
233
|
Promise.resolve(resolved)
|
|
@@ -236,14 +247,214 @@ let isComplete = (event: JSON.t): bool =>
|
|
|
236
247
|
->Option.flatMap(JSON.Decode.bool)
|
|
237
248
|
->Option.getOr(false)
|
|
238
249
|
|
|
250
|
+
// ── Bake mode ────────────────────────────────────────────────────────────────
|
|
251
|
+
// The third thing this function does with the same scan: write the curated
|
|
252
|
+
// manifest as a static object instead of answering a query with it.
|
|
253
|
+
//
|
|
254
|
+
// It belongs here rather than in a tool of its own for the reason the two GraphQL
|
|
255
|
+
// fields already share a data source — there is one place that decides what a
|
|
256
|
+
// deployed plugin's structure is, and the scan, the offload resolution and the
|
|
257
|
+
// version collapse are that decision. A separate reader would be a second copy of
|
|
258
|
+
// all three, free to drift about which version of a plugin is deployed.
|
|
259
|
+
//
|
|
260
|
+
// Invoked directly, after every plugin stack is up: the manifest describes the
|
|
261
|
+
// whole deployment, and no single stack's deploy is the moment that is settled.
|
|
262
|
+
|
|
263
|
+
// The persisted structure is served raw on the query paths — nothing decodes it —
|
|
264
|
+
// but curation filters the RECORD and re-encodes through the shared encoder,
|
|
265
|
+
// which is what makes an include-list naming everything produce the bytes the
|
|
266
|
+
// query returns. So the bake decodes, and heals first: healing fills the list
|
|
267
|
+
// fields an older persisted structure simply has no key for, and those are
|
|
268
|
+
// exactly the fields the schema requires.
|
|
269
|
+
//
|
|
270
|
+
// A structure that still fails to decode fails the bake, naming the plugin. The
|
|
271
|
+
// alternative — skipping it — ships a shop silently missing a section.
|
|
272
|
+
let structureOf = (item: dict<JSON.t>, ~name as _: string): option<(
|
|
273
|
+
string,
|
|
274
|
+
Reventless.Plugin.pluginStructure,
|
|
275
|
+
)> =>
|
|
276
|
+
switch (item->str("name"), item->Dict.get("structure")->Option.flatMap(JSON.Decode.object)) {
|
|
277
|
+
| (Some(pluginId), Some(structure)) =>
|
|
278
|
+
let healed = structure->healStructure->JSON.Encode.object
|
|
279
|
+
switch healed->S.parseJsonOrThrow(Reventless.Plugin.pluginStructureSchema) {
|
|
280
|
+
| decoded => Some((pluginId, decoded))
|
|
281
|
+
| exception _ =>
|
|
282
|
+
JsError.throwWithMessage(
|
|
283
|
+
`baked manifest: the structure persisted for "${pluginId}" cannot be decoded — ` ++
|
|
284
|
+
`it was written by a framework version this platform can no longer read. ` ++
|
|
285
|
+
`Redeploy that plugin, or drop it from the include-list.`,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
| _ => None
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let bakeSelection = (json: JSON.t): option<ReventlessCore.Platform_BakedManifest.selection> => {
|
|
292
|
+
let strings = (o, key) =>
|
|
293
|
+
o
|
|
294
|
+
->Dict.get(key)
|
|
295
|
+
->Option.flatMap(JSON.Decode.array)
|
|
296
|
+
->Option.map(a => a->Array.filterMap(JSON.Decode.string))
|
|
297
|
+
json
|
|
298
|
+
->JSON.Decode.object
|
|
299
|
+
->Option.flatMap(o =>
|
|
300
|
+
o
|
|
301
|
+
->Dict.get("plugin")
|
|
302
|
+
->Option.flatMap(JSON.Decode.string)
|
|
303
|
+
->Option.map(plugin => {
|
|
304
|
+
ReventlessCore.Platform_BakedManifest.plugin,
|
|
305
|
+
views: strings(o, "views"),
|
|
306
|
+
commands: strings(o, "commands"),
|
|
307
|
+
})
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// The declaration travels as an env var because it is deploy input — stated once
|
|
312
|
+
// in the platform program, beside the bucket it is written to. The target travels
|
|
313
|
+
// in the invocation payload instead: the bucket only exists inside the host-UI
|
|
314
|
+
// half of the deploy, long after this function is built, and a caller that had to
|
|
315
|
+
// know the include-list as well would be free to bake something the deployment
|
|
316
|
+
// never declared.
|
|
317
|
+
let bakeSelections = (): array<ReventlessCore.Platform_BakedManifest.selection> =>
|
|
318
|
+
switch NodeProcess.env->Dict.get("BAKE_SELECTIONS") {
|
|
319
|
+
| None | Some("") => []
|
|
320
|
+
| Some(raw) =>
|
|
321
|
+
switch raw->JSON.parseOrThrow->JSON.Decode.array {
|
|
322
|
+
| Some(entries) => entries->Array.filterMap(bakeSelection)
|
|
323
|
+
| None => []
|
|
324
|
+
| exception _ =>
|
|
325
|
+
JsError.throwWithMessage("baked manifest: BAKE_SELECTIONS is not a JSON array")
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
type bakeTarget = {bucket: string, key: string}
|
|
330
|
+
|
|
331
|
+
let bakeTargetOf = (event: JSON.t): option<bakeTarget> =>
|
|
332
|
+
event
|
|
333
|
+
->JSON.Decode.object
|
|
334
|
+
->Option.flatMap(o =>
|
|
335
|
+
switch (
|
|
336
|
+
o->Dict.get("bake")->Option.flatMap(JSON.Decode.bool),
|
|
337
|
+
o->Dict.get("bucket")->Option.flatMap(JSON.Decode.string),
|
|
338
|
+
) {
|
|
339
|
+
| (Some(true), Some(bucket)) =>
|
|
340
|
+
Some({
|
|
341
|
+
bucket,
|
|
342
|
+
key: o
|
|
343
|
+
->Dict.get("key")
|
|
344
|
+
->Option.flatMap(JSON.Decode.string)
|
|
345
|
+
->Option.getOr(ReventlessCore.Platform_BakedManifest.defaultKey),
|
|
346
|
+
})
|
|
347
|
+
| _ => None
|
|
348
|
+
}
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
// ── Registration freshness ───────────────────────────────────────────────────
|
|
352
|
+
// The read model this bake scans is updated asynchronously: the plugin stack
|
|
353
|
+
// publishes a re-detect, the plugin answers with its definition, the projection
|
|
354
|
+
// lands. Invoked seconds after the last stack finished, the scan can still
|
|
355
|
+
// describe the deploy before it — and a manifest baked from that is wrong in the
|
|
356
|
+
// one way nothing downstream can detect, because it is a perfectly well-formed
|
|
357
|
+
// description of the wrong deployment.
|
|
358
|
+
//
|
|
359
|
+
// So the invocation carries the structure key each plugin stack just wrote. That
|
|
360
|
+
// is an equality check rather than an inference from timestamps, and it costs
|
|
361
|
+
// nothing for a plugin that was not redeployed: its key already matches. A caller
|
|
362
|
+
// that supplies no expectations bakes whatever is current — the query paths never
|
|
363
|
+
// send any, and a hand-run bake should not need to.
|
|
364
|
+
let bakeExpectations = (event: JSON.t): dict<string> =>
|
|
365
|
+
event
|
|
366
|
+
->JSON.Decode.object
|
|
367
|
+
->Option.flatMap(o => o->Dict.get("expect"))
|
|
368
|
+
->Option.flatMap(JSON.Decode.object)
|
|
369
|
+
->Option.mapOr(Dict.make(), o =>
|
|
370
|
+
o
|
|
371
|
+
->Dict.toArray
|
|
372
|
+
->Array.filterMap(((plugin, key)) => key->JSON.Decode.string->Option.map(k => (plugin, k)))
|
|
373
|
+
->Dict.fromArray
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
// The offload key a scanned row carries. None for a structure held inline, which
|
|
377
|
+
// on a deployed platform means the row predates offloading — it cannot match an
|
|
378
|
+
// expectation, and saying so beats baking it.
|
|
379
|
+
let structureRefKey = (item: dict<JSON.t>): option<string> =>
|
|
380
|
+
item
|
|
381
|
+
->Dict.get("structure")
|
|
382
|
+
->Option.flatMap(JSON.Decode.object)
|
|
383
|
+
->Option.flatMap(o => o->Dict.get(Reventless.Offload.sentinelKey))
|
|
384
|
+
->Option.flatMap(JSON.Decode.object)
|
|
385
|
+
->Option.flatMap(r => r->Dict.get("key"))
|
|
386
|
+
->Option.flatMap(JSON.Decode.string)
|
|
387
|
+
|
|
388
|
+
// Compared against the collapsed latest version per plugin, the same view the
|
|
389
|
+
// bake itself takes — an older version's row lingering on the table is not the
|
|
390
|
+
// registration anyone is waiting for.
|
|
391
|
+
let pendingRegistrations = (items: array<dict<JSON.t>>, ~expect: dict<string>): array<string> => {
|
|
392
|
+
let current =
|
|
393
|
+
Platform_AdminScan_Ops.latestByName(
|
|
394
|
+
items,
|
|
395
|
+
~nameVersionOf=item => item->str("name"),
|
|
396
|
+
~toEntry=(item, ~name) => item->structureRefKey->Option.map(key => (name, key)),
|
|
397
|
+
)->Dict.fromArray
|
|
398
|
+
expect
|
|
399
|
+
->Dict.toArray
|
|
400
|
+
->Array.filterMap(((plugin, key)) => current->Dict.get(plugin) == Some(key) ? None : Some(plugin))
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Every failure mode here is the deployment's own mistake — a name matching no
|
|
404
|
+
// component, a structure too old to read, a bucket the function may not write —
|
|
405
|
+
// and every one of them produces the same symptom if swallowed: a shop that
|
|
406
|
+
// renders nothing, with no line anywhere saying why. So the bake throws, and the
|
|
407
|
+
// pipeline step that invoked it fails.
|
|
408
|
+
let runBake = async (
|
|
409
|
+
~target: bakeTarget,
|
|
410
|
+
~structures: array<(string, Reventless.Plugin.pluginStructure)>,
|
|
411
|
+
): array<JSON.t> => {
|
|
412
|
+
let selections = bakeSelections()
|
|
413
|
+
if selections->Array.length == 0 {
|
|
414
|
+
JsError.throwWithMessage(
|
|
415
|
+
"baked manifest: BAKE_SELECTIONS is empty — this platform declares no bake, " ++
|
|
416
|
+
"so there is nothing to write and a shell pointed at the file would find none.",
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
switch ReventlessCore.Platform_BakedManifest.curate(~structures, ~selections) {
|
|
420
|
+
| Error(e) =>
|
|
421
|
+
JsError.throwWithMessage(ReventlessCore.Platform_BakedManifest.describe(e))
|
|
422
|
+
| Ok(manifest) =>
|
|
423
|
+
let body = JSON.stringify(manifest, ~space=2)
|
|
424
|
+
let _ = await AwsSdk.S3.PutObjectCommand.make({
|
|
425
|
+
bucket: target.bucket,
|
|
426
|
+
key: target.key,
|
|
427
|
+
body: AwsSdk.S3.PutObjectCommand.bodyFromString(body),
|
|
428
|
+
contentType: "application/json",
|
|
429
|
+
})->AwsSdk.S3.PutObjectCommand.send
|
|
430
|
+
[
|
|
431
|
+
Dict.fromArray([
|
|
432
|
+
("baked", JSON.Encode.bool(true)),
|
|
433
|
+
("bucket", JSON.Encode.string(target.bucket)),
|
|
434
|
+
("key", JSON.Encode.string(target.key)),
|
|
435
|
+
("plugins", JSON.Encode.int(selections->Array.length)),
|
|
436
|
+
("bytes", JSON.Encode.int(body->String.length)),
|
|
437
|
+
])->JSON.Encode.object,
|
|
438
|
+
]
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
239
442
|
let handler = async (event: JSON.t): array<JSON.t> => {
|
|
240
443
|
let complete = isComplete(event)
|
|
444
|
+
let bakeTarget = bakeTargetOf(event)
|
|
241
445
|
let toEntry = (item, ~name) => toEntryWith(~filter=!complete, item, ~name)
|
|
242
446
|
let admin = adminEntry->Option.mapOr([], e => [e])
|
|
243
447
|
switch NodeProcess.env->Dict.get("PLUGIN_RM_TABLE") {
|
|
244
448
|
| None | Some("") =>
|
|
245
449
|
Console.error("Platform_ComponentDefinitions: PLUGIN_RM_TABLE env var not set")
|
|
246
|
-
|
|
450
|
+
switch bakeTarget {
|
|
451
|
+
| Some(_) =>
|
|
452
|
+
JsError.throwWithMessage(
|
|
453
|
+
"baked manifest: PLUGIN_RM_TABLE env var not set — the bake would write an " ++
|
|
454
|
+
"empty shop rather than fail.",
|
|
455
|
+
)
|
|
456
|
+
| None => admin
|
|
457
|
+
}
|
|
247
458
|
| Some(table) =>
|
|
248
459
|
let rawItems = await Platform_AdminScan_Ops.scanAll(
|
|
249
460
|
~tableName=table,
|
|
@@ -256,9 +467,40 @@ let handler = async (event: JSON.t): array<JSON.t> => {
|
|
|
256
467
|
let fetch = Reventless.Offload.cachedFetch(key =>
|
|
257
468
|
AwsSdk.S3.GetObjectCommand.getString(~bucket, ~key)
|
|
258
469
|
)
|
|
259
|
-
let
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
470
|
+
let resolveAll = () => Promise.all(rawItems->Array.map(item => resolveStructure(fetch, item)))
|
|
471
|
+
switch bakeTarget {
|
|
472
|
+
| Some(target) =>
|
|
473
|
+
// Checked on the raw rows: the refs are what the deploy can predict, and a
|
|
474
|
+
// row that is behind should not have its structure fetched at all.
|
|
475
|
+
switch pendingRegistrations(rawItems, ~expect=bakeExpectations(event)) {
|
|
476
|
+
| [] =>
|
|
477
|
+
// The built-in admin entry is deliberately absent: it never enters the
|
|
478
|
+
// Plugin read model, and the in-memory bake curates the composed plugins
|
|
479
|
+
// only. A deployment naming it gets `UnknownPlugin`, on both platforms.
|
|
480
|
+
let structures = Platform_AdminScan_Ops.latestByName(
|
|
481
|
+
await resolveAll(),
|
|
482
|
+
~nameVersionOf=item => item->str("name"),
|
|
483
|
+
~toEntry=structureOf,
|
|
484
|
+
)
|
|
485
|
+
await runBake(~target, ~structures)
|
|
486
|
+
| pending =>
|
|
487
|
+
// Not an error — the deploy just has not finished arriving. Reported so the
|
|
488
|
+
// caller can invoke again rather than bake the previous deployment.
|
|
489
|
+
[
|
|
490
|
+
Dict.fromArray([
|
|
491
|
+
("baked", JSON.Encode.bool(false)),
|
|
492
|
+
("pending", pending->Array.map(JSON.Encode.string)->JSON.Encode.array),
|
|
493
|
+
])->JSON.Encode.object,
|
|
494
|
+
]
|
|
495
|
+
}
|
|
496
|
+
| None =>
|
|
497
|
+
let userEntries =
|
|
498
|
+
Platform_AdminScan_Ops.latestByName(
|
|
499
|
+
await resolveAll(),
|
|
500
|
+
~nameVersionOf=item => item->str("name"),
|
|
501
|
+
~toEntry,
|
|
502
|
+
)
|
|
503
|
+
Array.concat(admin, userEntries)
|
|
504
|
+
}
|
|
263
505
|
}
|
|
264
506
|
}
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
3
4
|
import * as Nodefs from "node:fs";
|
|
4
5
|
import * as S3$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/S3.res.mjs";
|
|
5
6
|
import * as Nodepath from "node:path";
|
|
6
7
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
8
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
9
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
7
10
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
11
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
12
|
+
import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
|
|
13
|
+
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
14
|
+
import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
|
|
15
|
+
import * as ClientS3 from "@aws-sdk/client-s3";
|
|
8
16
|
import * as Offload$Reventless from "@reventlessdev/reventless-spec/src/semantic/Offload.res.mjs";
|
|
9
17
|
import * as Platform_AdminScan_Ops$ReventlessAws from "./Platform_AdminScan_Ops.res.mjs";
|
|
18
|
+
import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
|
|
10
19
|
|
|
11
20
|
function str(item, key) {
|
|
12
21
|
return Stdlib_Option.flatMap(item[key], Stdlib_JSON.Decode.string);
|
|
@@ -210,7 +219,10 @@ function resolveStructure(fetch, item) {
|
|
|
210
219
|
}
|
|
211
220
|
let key = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(refJson), r => r["key"]), Stdlib_JSON.Decode.string);
|
|
212
221
|
if (key !== undefined) {
|
|
213
|
-
return fetch(key)
|
|
222
|
+
return Stdlib_Promise.$$catch(fetch(key), e => {
|
|
223
|
+
let plugin = Stdlib_Option.getOr(str(item, "name"), "<unnamed>");
|
|
224
|
+
return Stdlib_JsError.throwWithMessage(`offloaded structure for plugin ` + plugin + ` is unreadable at ` + key + `: ` + Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(e), Stdlib_JsExn.message), "unknown error"));
|
|
225
|
+
}).then(bytes => {
|
|
214
226
|
let resolved = Object.fromEntries(Object.entries(item));
|
|
215
227
|
resolved["structure"] = JSON.parse(bytes);
|
|
216
228
|
return Promise.resolve(resolved);
|
|
@@ -224,16 +236,147 @@ function isComplete(event) {
|
|
|
224
236
|
return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(event), o => o["complete"]), Stdlib_JSON.Decode.bool), false);
|
|
225
237
|
}
|
|
226
238
|
|
|
239
|
+
function structureOf(item, param) {
|
|
240
|
+
let match = str(item, "name");
|
|
241
|
+
let match$1 = Stdlib_Option.flatMap(item["structure"], Stdlib_JSON.Decode.object);
|
|
242
|
+
if (match === undefined) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (match$1 === undefined) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
let healed = healStructure(match$1);
|
|
249
|
+
let decoded;
|
|
250
|
+
try {
|
|
251
|
+
decoded = S.parseJsonOrThrow(healed, Plugin$Reventless.pluginStructureSchema);
|
|
252
|
+
} catch (exn) {
|
|
253
|
+
return Stdlib_JsError.throwWithMessage(`baked manifest: the structure persisted for "` + match + `" cannot be decoded — it was written by a framework version this platform can no longer read. Redeploy that plugin, or drop it from the include-list.`);
|
|
254
|
+
}
|
|
255
|
+
return [
|
|
256
|
+
match,
|
|
257
|
+
decoded
|
|
258
|
+
];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function bakeSelection(json) {
|
|
262
|
+
let strings = (o, key) => Stdlib_Option.map(Stdlib_Option.flatMap(o[key], Stdlib_JSON.Decode.array), a => Stdlib_Array.filterMap(a, Stdlib_JSON.Decode.string));
|
|
263
|
+
return Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), o => Stdlib_Option.map(Stdlib_Option.flatMap(o["plugin"], Stdlib_JSON.Decode.string), plugin => ({
|
|
264
|
+
plugin: plugin,
|
|
265
|
+
views: strings(o, "views"),
|
|
266
|
+
commands: strings(o, "commands")
|
|
267
|
+
})));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function bakeSelections() {
|
|
271
|
+
let raw = process.env["BAKE_SELECTIONS"];
|
|
272
|
+
if (raw === undefined) {
|
|
273
|
+
return [];
|
|
274
|
+
}
|
|
275
|
+
if (raw === "") {
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
let entries;
|
|
279
|
+
try {
|
|
280
|
+
entries = Stdlib_JSON.Decode.array(JSON.parse(raw));
|
|
281
|
+
} catch (exn) {
|
|
282
|
+
return Stdlib_JsError.throwWithMessage("baked manifest: BAKE_SELECTIONS is not a JSON array");
|
|
283
|
+
}
|
|
284
|
+
if (entries !== undefined) {
|
|
285
|
+
return Stdlib_Array.filterMap(entries, bakeSelection);
|
|
286
|
+
} else {
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function bakeTargetOf(event) {
|
|
292
|
+
return Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(event), o => {
|
|
293
|
+
let match = Stdlib_Option.flatMap(o["bake"], Stdlib_JSON.Decode.bool);
|
|
294
|
+
let match$1 = Stdlib_Option.flatMap(o["bucket"], Stdlib_JSON.Decode.string);
|
|
295
|
+
if (match !== undefined && match && match$1 !== undefined) {
|
|
296
|
+
return {
|
|
297
|
+
bucket: match$1,
|
|
298
|
+
key: Stdlib_Option.getOr(Stdlib_Option.flatMap(o["key"], Stdlib_JSON.Decode.string), Platform_BakedManifest$ReventlessCore.defaultKey)
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function bakeExpectations(event) {
|
|
305
|
+
return Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(event), o => o["expect"]), Stdlib_JSON.Decode.object), {}, o => Object.fromEntries(Stdlib_Array.filterMap(Object.entries(o), param => {
|
|
306
|
+
let plugin = param[0];
|
|
307
|
+
return Stdlib_Option.map(Stdlib_JSON.Decode.string(param[1]), k => [
|
|
308
|
+
plugin,
|
|
309
|
+
k
|
|
310
|
+
]);
|
|
311
|
+
})));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function structureRefKey(item) {
|
|
315
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(item["structure"], Stdlib_JSON.Decode.object), o => o[Offload$Reventless.sentinelKey]), Stdlib_JSON.Decode.object), r => r["key"]), Stdlib_JSON.Decode.string);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function pendingRegistrations(items, expect) {
|
|
319
|
+
let current = Object.fromEntries(Platform_AdminScan_Ops$ReventlessAws.latestByName(items, item => str(item, "name"), (item, name) => Stdlib_Option.map(structureRefKey(item), key => [
|
|
320
|
+
name,
|
|
321
|
+
key
|
|
322
|
+
])));
|
|
323
|
+
return Stdlib_Array.filterMap(Object.entries(expect), param => {
|
|
324
|
+
let plugin = param[0];
|
|
325
|
+
if (Primitive_object.equal(current[plugin], param[1])) {
|
|
326
|
+
return;
|
|
327
|
+
} else {
|
|
328
|
+
return plugin;
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function runBake(target, structures) {
|
|
334
|
+
let selections = bakeSelections();
|
|
335
|
+
if (selections.length === 0) {
|
|
336
|
+
Stdlib_JsError.throwWithMessage("baked manifest: BAKE_SELECTIONS is empty — this platform declares no bake, so there is nothing to write and a shell pointed at the file would find none.");
|
|
337
|
+
}
|
|
338
|
+
let e = Platform_BakedManifest$ReventlessCore.curate(structures, selections);
|
|
339
|
+
if (e.TAG !== "Ok") {
|
|
340
|
+
return Stdlib_JsError.throwWithMessage(Platform_BakedManifest$ReventlessCore.describe(e._0));
|
|
341
|
+
}
|
|
342
|
+
let body = JSON.stringify(e._0, undefined, 2);
|
|
343
|
+
await S3$AwsSdk.PutObjectCommand.send(new ClientS3.PutObjectCommand({
|
|
344
|
+
Bucket: target.bucket,
|
|
345
|
+
Key: target.key,
|
|
346
|
+
Body: body,
|
|
347
|
+
ContentType: "application/json"
|
|
348
|
+
}));
|
|
349
|
+
return [Object.fromEntries([
|
|
350
|
+
[
|
|
351
|
+
"baked",
|
|
352
|
+
true
|
|
353
|
+
],
|
|
354
|
+
[
|
|
355
|
+
"bucket",
|
|
356
|
+
target.bucket
|
|
357
|
+
],
|
|
358
|
+
[
|
|
359
|
+
"key",
|
|
360
|
+
target.key
|
|
361
|
+
],
|
|
362
|
+
[
|
|
363
|
+
"plugins",
|
|
364
|
+
selections.length
|
|
365
|
+
],
|
|
366
|
+
[
|
|
367
|
+
"bytes",
|
|
368
|
+
body.length
|
|
369
|
+
]
|
|
370
|
+
])];
|
|
371
|
+
}
|
|
372
|
+
|
|
227
373
|
async function handler(event) {
|
|
228
374
|
let complete = isComplete(event);
|
|
375
|
+
let bakeTarget = bakeTargetOf(event);
|
|
229
376
|
let toEntry = (item, name) => toEntryWith(!complete, item, name);
|
|
230
377
|
let admin = Stdlib_Option.mapOr(adminEntry, [], e => [e]);
|
|
231
378
|
let table = process.env["PLUGIN_RM_TABLE"];
|
|
232
|
-
if (table !== undefined) {
|
|
233
|
-
if (table === "") {
|
|
234
|
-
console.error("Platform_ComponentDefinitions: PLUGIN_RM_TABLE env var not set");
|
|
235
|
-
return admin;
|
|
236
|
-
}
|
|
379
|
+
if (table !== undefined && table !== "") {
|
|
237
380
|
let rawItems = await Platform_AdminScan_Ops$ReventlessAws.scanAll(table, "contains(#status, :connected)", Object.fromEntries([[
|
|
238
381
|
"#status",
|
|
239
382
|
"status"
|
|
@@ -243,12 +386,33 @@ async function handler(event) {
|
|
|
243
386
|
]]));
|
|
244
387
|
let bucket = Stdlib_Option.getOr(process.env["OFFLOAD_BUCKET"], "");
|
|
245
388
|
let fetch = Offload$Reventless.cachedFetch(key => S3$AwsSdk.GetObjectCommand.getString(bucket, key));
|
|
246
|
-
let
|
|
247
|
-
|
|
389
|
+
let resolveAll = () => Promise.all(rawItems.map(item => resolveStructure(fetch, item)));
|
|
390
|
+
if (bakeTarget !== undefined) {
|
|
391
|
+
let pending = pendingRegistrations(rawItems, bakeExpectations(event));
|
|
392
|
+
if (pending.length !== 0) {
|
|
393
|
+
return [Object.fromEntries([
|
|
394
|
+
[
|
|
395
|
+
"baked",
|
|
396
|
+
false
|
|
397
|
+
],
|
|
398
|
+
[
|
|
399
|
+
"pending",
|
|
400
|
+
pending.map(prim => prim)
|
|
401
|
+
]
|
|
402
|
+
])];
|
|
403
|
+
}
|
|
404
|
+
let structures = Platform_AdminScan_Ops$ReventlessAws.latestByName(await resolveAll(), item => str(item, "name"), structureOf);
|
|
405
|
+
return await runBake(bakeTarget, structures);
|
|
406
|
+
}
|
|
407
|
+
let userEntries = Platform_AdminScan_Ops$ReventlessAws.latestByName(await resolveAll(), item => str(item, "name"), toEntry);
|
|
248
408
|
return admin.concat(userEntries);
|
|
249
409
|
}
|
|
250
410
|
console.error("Platform_ComponentDefinitions: PLUGIN_RM_TABLE env var not set");
|
|
251
|
-
|
|
411
|
+
if (bakeTarget !== undefined) {
|
|
412
|
+
return Stdlib_JsError.throwWithMessage("baked manifest: PLUGIN_RM_TABLE env var not set — the bake would write an empty shop rather than fail.");
|
|
413
|
+
} else {
|
|
414
|
+
return admin;
|
|
415
|
+
}
|
|
252
416
|
}
|
|
253
417
|
|
|
254
418
|
export {
|
|
@@ -266,6 +430,14 @@ export {
|
|
|
266
430
|
adminEntry,
|
|
267
431
|
resolveStructure,
|
|
268
432
|
isComplete,
|
|
433
|
+
structureOf,
|
|
434
|
+
bakeSelection,
|
|
435
|
+
bakeSelections,
|
|
436
|
+
bakeTargetOf,
|
|
437
|
+
bakeExpectations,
|
|
438
|
+
structureRefKey,
|
|
439
|
+
pendingRegistrations,
|
|
440
|
+
runBake,
|
|
269
441
|
handler,
|
|
270
442
|
}
|
|
271
443
|
/* adminEntry Not a pure module */
|
|
@@ -186,6 +186,20 @@ let heartbeatConfigRef: ref<heartbeatConfig> = ref({
|
|
|
186
186
|
let registerHeartbeatConfig = (~pluginId, ~heartbeatTimeout, ~epQueueUrl=?, ()) =>
|
|
187
187
|
heartbeatConfigRef := {pluginId, heartbeatTimeout, epQueueUrl}
|
|
188
188
|
|
|
189
|
+
// Resolves once this plugin's EventCollector Lambda exists at its new code.
|
|
190
|
+
// `Platform.deployPlugin` gates the deploy-time re-detect on it: the re-detect
|
|
191
|
+
// only asks the platform to re-run the connect handshake, and this is the Lambda
|
|
192
|
+
// that ANSWERS it, with the `pluginDefinition` its deployed code carries. Fired
|
|
193
|
+
// before this Lambda is updated, the plugin answers with the previous deploy's
|
|
194
|
+
// definition, `Connect` sees a definition it already holds and emits nothing, and
|
|
195
|
+
// the row keeps the old structure — with no second re-detect to correct it.
|
|
196
|
+
//
|
|
197
|
+
// A bare `Output<unit>` rather than an `option<Output<_>>`: an Output is a
|
|
198
|
+
// JS Proxy, and wrapping one in a ReScript option produces the nested-Some
|
|
199
|
+
// sentinel instead of the value. Empty until `forPluginEventCollector` runs,
|
|
200
|
+
// which the gate reads only after `P.make()` has returned.
|
|
201
|
+
let eventCollectorReadyRef: ref<Pulumi.Output.t<unit>> = ref(Pulumi.Output.make())
|
|
202
|
+
|
|
189
203
|
// Per-flavor commandHandlerConfig override for the two DCB command-handler Lambdas
|
|
190
204
|
// (`<Plugin>DcbCmdHandler` sync / `<Plugin>DcbAsyncCmdHandler` async); populated by
|
|
191
205
|
// Platform.MakeWithConfig from `commandHandlerConfig.stateChanges.{sync,async}`.
|
|
@@ -624,6 +638,8 @@ module Make = (
|
|
|
624
638
|
~opts,
|
|
625
639
|
)
|
|
626
640
|
|
|
641
|
+
eventCollectorReadyRef := runtime.parts.lambda->Pulumi.Output.apply(_ => ())
|
|
642
|
+
|
|
627
643
|
// Admin-only cross-plugin SNS subscription permissions. The admin EC
|
|
628
644
|
// Lambda's manageSubscriptions hook (Phase 3 Step 1) creates SNS
|
|
629
645
|
// subscriptions at runtime as plugins connect/disconnect; that needs
|
|
@@ -149,6 +149,10 @@ function registerHeartbeatConfig(pluginId, heartbeatTimeout, epQueueUrl, param)
|
|
|
149
149
|
};
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
let eventCollectorReadyRef = {
|
|
153
|
+
contents: Pulumi.output()
|
|
154
|
+
};
|
|
155
|
+
|
|
152
156
|
let syncStateChangesConfigRef = {
|
|
153
157
|
contents: {}
|
|
154
158
|
};
|
|
@@ -381,6 +385,7 @@ function Make(EventCollectorChannel) {
|
|
|
381
385
|
eventTopics: eventTopics,
|
|
382
386
|
resources: resources
|
|
383
387
|
}], runtime, opts);
|
|
388
|
+
eventCollectorReadyRef.contents = runtime.parts.lambda.apply(param => {});
|
|
384
389
|
let isAdminEventCollector = Stdlib_Option.isNone(Plugin_Helpers$ReventlessCore.eventCollectorContextRef.contents[name]);
|
|
385
390
|
if (isAdminEventCollector) {
|
|
386
391
|
new (Aws.iam.RolePolicy)(name + `-snsManageSubs`, {
|
|
@@ -542,6 +547,7 @@ export {
|
|
|
542
547
|
registerDcbTableName,
|
|
543
548
|
heartbeatConfigRef,
|
|
544
549
|
registerHeartbeatConfig,
|
|
550
|
+
eventCollectorReadyRef,
|
|
545
551
|
syncStateChangesConfigRef,
|
|
546
552
|
asyncStateChangesConfigRef,
|
|
547
553
|
setStateChangesConfig,
|