@reventlessdev/reventless-aws 3.0.0-alpha.253 → 3.0.0-alpha.255
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 +10 -10
- package/src/Platform.res +134 -20
- package/src/Platform.res.mjs +95 -6
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +14 -0
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +4 -1
- package/src/adapter/Api/Platform_UIFragments_Lambda.res +14 -0
- package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +4 -1
- package/src/adapter/Geocoder/Geocoder_AwsLocation.res +14 -0
- package/src/adapter/Geocoder/Geocoder_AwsLocation.res.mjs +4 -1
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +5 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +3 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +5 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +3 -0
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +57 -8
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +15 -3
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +5 -0
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +3 -0
- package/src/adapter/StateTopic/StateTopic_AppSync.res +14 -0
- package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +4 -1
- package/src/adapter/Upload/Upload_Claim_S3.res +375 -0
- package/src/adapter/Upload/Upload_Claim_S3.res.mjs +206 -0
- package/src/adapter/Upload/Upload_Claim_S3_Ops.res +279 -0
- package/src/adapter/Upload/Upload_Claim_S3_Ops.res.mjs +261 -0
- package/src/adapter/Upload/Upload_PendingTag.res +41 -0
- package/src/adapter/Upload/Upload_PendingTag.res.mjs +15 -0
- package/src/adapter/Upload/Upload_Presign_S3.res +26 -2
- package/src/adapter/Upload/Upload_Presign_S3.res.mjs +5 -1
- package/src/adapter/Upload/Upload_Presign_S3_Ops.res +13 -1
- package/src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs +3 -1
- package/src/capability/Capability_ObjectStore_S3.res +42 -1
- package/src/capability/Capability_ObjectStore_S3.res.mjs +19 -1
- package/src/components/Api/AppSync_Adapter.res +36 -0
- package/src/components/Api/AppSync_Adapter.res.mjs +14 -0
- package/src/util/Util_DeadLetterQueue.res +1 -0
- package/src/util/Util_DeadLetterQueue.res.mjs +6 -2
- package/src/util/Util_LambdaLogging.res +83 -0
- package/src/util/Util_LambdaLogging.res.mjs +59 -0
- package/src/util/Util_LogRetention.res +86 -0
- package/src/util/Util_LogRetention.res.mjs +43 -0
- package/src/util/Util_StoreLayout.res +37 -0
- package/src/util/Util_StoreLayout.res.mjs +18 -0
- package/tests/Upload_ClaimTest.res +132 -0
- package/tests/Upload_ClaimTest.res.mjs +101 -0
- package/tests/Util_LogRetentionTest.res +122 -0
- package/tests/Util_LogRetentionTest.res.mjs +95 -0
- package/tests/Util_StoreLayoutTest.res +63 -0
- package/tests/Util_StoreLayoutTest.res.mjs +42 -0
|
@@ -186,6 +186,9 @@ function finish() {
|
|
|
186
186
|
return;
|
|
187
187
|
}
|
|
188
188
|
}));
|
|
189
|
+
Stdlib_Option.forEach(cfg.logLevel, level => {
|
|
190
|
+
envVars["LOG_LEVEL"] = Pulumi.output(level);
|
|
191
|
+
});
|
|
189
192
|
let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Runtime/AggregateEntryPoint.mjs", packageDirs, undefined);
|
|
190
193
|
Stdlib_Option.forEach(cfg.sqsBatchSize, CommandTopicChannel_SQS_Async$ReventlessAws.setBatchSize);
|
|
191
194
|
let vpcConfig = pgSelection !== undefined ? pgSelection.securityGroupId.apply(sgId => ({
|
|
@@ -115,7 +115,9 @@ let makeFromCodeAsset: (
|
|
|
115
115
|
~logRetentionDays: int=?,
|
|
116
116
|
/** Provision the DCB append-retry/conflict CloudWatch metric filters on this
|
|
117
117
|
Lambda's log group (command-handler Lambdas only). Requires a managed log
|
|
118
|
-
group, so it only takes effect when
|
|
118
|
+
group, so it only takes effect when one is created — i.e. when
|
|
119
|
+
`~logRetentionDays` is set or the stack tier manages its groups
|
|
120
|
+
(`Util_LogRetention.managesLogGroup`). */
|
|
119
121
|
~dcbMetrics: bool=?,
|
|
120
122
|
/** Place the Lambda in a VPC (needed to reach RDS/managed Postgres). When set,
|
|
121
123
|
the execution role also gets the EC2 network-interface permissions AWS
|
|
@@ -143,6 +145,18 @@ let makeFromCodeAsset: (
|
|
|
143
145
|
let opts =
|
|
144
146
|
opts->Option.map(ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions)
|
|
145
147
|
|
|
148
|
+
// The environment tier drives retention, default log level and whether the log
|
|
149
|
+
// group is Pulumi-managed. Resolved once from the stack name and the shared
|
|
150
|
+
// prod allow-list (the same notion of "production" that names the host-shell
|
|
151
|
+
// domain and the store layout).
|
|
152
|
+
let stack = Pulumi.Pulumi.getStackName()
|
|
153
|
+
let prodStacks = Util_HostUiDomain.resolveProdStacks()
|
|
154
|
+
// Stacks explicitly held on auto-created log groups pending a `pulumi import`
|
|
155
|
+
// (none today — the escape hatch for adopting a pre-existing stack later).
|
|
156
|
+
let unmanagedStacks = Util_LogRetention.parseUnmanagedStacks(
|
|
157
|
+
Util_LocalConfig.get("unmanagedLogGroupStacks")->Option.getOr(""),
|
|
158
|
+
)
|
|
159
|
+
|
|
146
160
|
// One attribution identity for every resource provisioned here: same owning
|
|
147
161
|
// component and kind throughout, distinct piece roles — the function itself,
|
|
148
162
|
// the execution identity it assumes, and its log group are three pieces of one
|
|
@@ -219,9 +233,7 @@ let makeFromCodeAsset: (
|
|
|
219
233
|
->Option.getOr([])
|
|
220
234
|
->Pulumi.Input.make
|
|
221
235
|
|
|
222
|
-
let variables = Dict.fromArray([
|
|
223
|
-
("Environment", Pulumi.Pulumi.getStackName()->Pulumi.Input.make),
|
|
224
|
-
])
|
|
236
|
+
let variables = Dict.fromArray([("Environment", stack->Pulumi.Input.make)])
|
|
225
237
|
envVars->Dict.forEachWithKey((value, key) => {
|
|
226
238
|
variables->Dict.set(key, value)
|
|
227
239
|
})
|
|
@@ -229,6 +241,12 @@ let makeFromCodeAsset: (
|
|
|
229
241
|
variables->Dict.set(key, value)
|
|
230
242
|
})
|
|
231
243
|
|
|
244
|
+
// Default the logger's minimum level per environment tier when nothing already
|
|
245
|
+
// pinned one (caller `envVars` or a consumer-registered `additionalEnvVars`
|
|
246
|
+
// win) — for every Lambda, via the shared helper so the bespoke
|
|
247
|
+
// `Lambda.Function.make` builders apply the identical policy.
|
|
248
|
+
Util_LambdaLogging.applyLogLevelDefault(variables)
|
|
249
|
+
|
|
232
250
|
// ESM self-containment (Option C): every code archive built by
|
|
233
251
|
// Util_Bundle.buildCodeArchive ships register-hook.mjs + layer-resolver.mjs at
|
|
234
252
|
// /var/task. --import registers the resolve hook so the ESM entry point (and the
|
|
@@ -263,13 +281,44 @@ let makeFromCodeAsset: (
|
|
|
263
281
|
~opts?,
|
|
264
282
|
)
|
|
265
283
|
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
284
|
+
// Managed CloudWatch log-group with explicit retention. Created when the caller
|
|
285
|
+
// pins `~logRetentionDays` (the app-developer opt-in) OR the stack manages its
|
|
286
|
+
// groups — every stack by default, bar any named in `unmanagedLogGroupStacks`
|
|
287
|
+
// (see `Util_LogRetention.managesLogGroup`). When neither holds, Lambda
|
|
288
|
+
// auto-creates an unmanaged group with no retention.
|
|
289
|
+
//
|
|
290
|
+
// Precedence for the retention value: explicit `~logRetentionDays` → the
|
|
291
|
+
// `logRetentionDays` config key → the environment tier default.
|
|
292
|
+
//
|
|
293
|
+
// The group name derives from the function's *physical* name output, not the
|
|
294
|
+
// logical `name`. `Lambda.Function` has no `name` arg, so its physical name
|
|
295
|
+
// always carries Pulumi's `-<7hex>` suffix; a literal `/aws/lambda/${name}`
|
|
296
|
+
// would create an empty group beside the real one, leave orphan accumulation
|
|
297
|
+
// unchanged, and collide across stacks sharing an account.
|
|
298
|
+
let managedRetention = switch (
|
|
299
|
+
logRetentionDays,
|
|
300
|
+
Util_LogRetention.managesLogGroup(~stack, ~unmanagedStacks),
|
|
301
|
+
) {
|
|
302
|
+
| (Some(days), _) => Some(days)
|
|
303
|
+
| (None, true) =>
|
|
304
|
+
Some(
|
|
305
|
+
Util_LogRetention.retentionDaysFor(
|
|
306
|
+
~stack,
|
|
307
|
+
~prodStacks,
|
|
308
|
+
~configOverride=?Util_LocalConfig.get("logRetentionDays")->Option.flatMap(s =>
|
|
309
|
+
Int.fromString(s)
|
|
310
|
+
),
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
| (None, false) => None
|
|
314
|
+
}
|
|
315
|
+
managedRetention->Option.forEach(days => {
|
|
269
316
|
let logGroup = Cloudwatch.LogGroup.make(
|
|
270
317
|
~name=`${name}LogGroup`,
|
|
271
318
|
~args={
|
|
272
|
-
name:
|
|
319
|
+
name: lambda.name
|
|
320
|
+
->Pulumi.Output.apply(n => `/aws/lambda/${n}`)
|
|
321
|
+
->Pulumi.Output.asInput,
|
|
273
322
|
retentionInDays: days->Pulumi.Input.make,
|
|
274
323
|
tags: tagsFor(~resourceName=`${name}LogGroup`, ~role=Logs),
|
|
275
324
|
},
|
|
@@ -20,6 +20,10 @@ import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
|
|
|
20
20
|
import * as Util_Lambda$ReventlessAws from "../../util/Util_Lambda.res.mjs";
|
|
21
21
|
import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
|
|
22
22
|
import * as Util_IAM_Role$ReventlessAws from "../../util/Util_IAM_Role.res.mjs";
|
|
23
|
+
import * as Util_LocalConfig$ReventlessAws from "../../util/Util_LocalConfig.res.mjs";
|
|
24
|
+
import * as Util_HostUiDomain$ReventlessAws from "../../util/Util_HostUiDomain.res.mjs";
|
|
25
|
+
import * as Util_LogRetention$ReventlessAws from "../../util/Util_LogRetention.res.mjs";
|
|
26
|
+
import * as Util_LambdaLogging$ReventlessAws from "../../util/Util_LambdaLogging.res.mjs";
|
|
23
27
|
|
|
24
28
|
let additionalEnvVars = {};
|
|
25
29
|
|
|
@@ -50,6 +54,9 @@ function makeFromCodeAsset(name, unitKind, componentKind, code, sourceCodeHash,
|
|
|
50
54
|
let timeout = timeoutOpt !== undefined ? timeoutOpt : Runtime$ReventlessCore.CommandHandlerDefaults.timeout;
|
|
51
55
|
let dcbMetrics = dcbMetricsOpt !== undefined ? dcbMetricsOpt : false;
|
|
52
56
|
let opts$1 = Stdlib_Option.map(opts, Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions);
|
|
57
|
+
let stack = Pulumi.getStack();
|
|
58
|
+
let prodStacks = Util_HostUiDomain$ReventlessAws.resolveProdStacks();
|
|
59
|
+
let unmanagedStacks = Util_LogRetention$ReventlessAws.parseUnmanagedStacks(Stdlib_Option.getOr(Util_LocalConfig$ReventlessAws.get("unmanagedLogGroupStacks"), ""));
|
|
53
60
|
let tagsFor = (resourceName, role) => AWS_Tags$ReventlessAws.make(resourceName, componentKind, role, undefined, name, undefined, undefined, undefined);
|
|
54
61
|
let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name, Pulumi.output(AWS$ReventlessAws.Lambda.principal), tagsFor(name, "Identity"), opts$1);
|
|
55
62
|
Stdlib_Option.forEach(vpcConfig, param => {
|
|
@@ -87,7 +94,7 @@ function makeFromCodeAsset(name, unitKind, componentKind, code, sourceCodeHash,
|
|
|
87
94
|
let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
|
|
88
95
|
let variables = Object.fromEntries([[
|
|
89
96
|
"Environment",
|
|
90
|
-
|
|
97
|
+
stack
|
|
91
98
|
]]);
|
|
92
99
|
Stdlib_Dict.forEachWithKey(envVars, (value, key) => {
|
|
93
100
|
variables[key] = value;
|
|
@@ -95,6 +102,7 @@ function makeFromCodeAsset(name, unitKind, componentKind, code, sourceCodeHash,
|
|
|
95
102
|
Stdlib_Dict.forEachWithKey(additionalEnvVars, (value, key) => {
|
|
96
103
|
variables[key] = value;
|
|
97
104
|
});
|
|
105
|
+
Util_LambdaLogging$ReventlessAws.applyLogLevelDefault(variables);
|
|
98
106
|
variables["NODE_OPTIONS"] = Util_Bundle$ReventlessAws.esmLoaderNodeOptions;
|
|
99
107
|
variables["ESM_FALLBACK_DIRS"] = Util_Bundle$ReventlessAws.esmFallbackDirs;
|
|
100
108
|
let tags = tagsFor(name, "Runtime");
|
|
@@ -117,9 +125,13 @@ function makeFromCodeAsset(name, unitKind, componentKind, code, sourceCodeHash,
|
|
|
117
125
|
})),
|
|
118
126
|
vpcConfig: vpcConfig
|
|
119
127
|
}, opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
|
|
120
|
-
|
|
128
|
+
let match = Util_LogRetention$ReventlessAws.managesLogGroup(stack, unmanagedStacks);
|
|
129
|
+
let managedRetention = logRetentionDays !== undefined ? logRetentionDays : (
|
|
130
|
+
match ? Util_LogRetention$ReventlessAws.retentionDaysFor(stack, prodStacks, Stdlib_Option.flatMap(Util_LocalConfig$ReventlessAws.get("logRetentionDays"), s => Stdlib_Int.fromString(s, undefined))) : undefined
|
|
131
|
+
);
|
|
132
|
+
Stdlib_Option.forEach(managedRetention, days => {
|
|
121
133
|
let logGroup = new (Aws.cloudwatch.LogGroup)(name + `LogGroup`, {
|
|
122
|
-
name: `/aws/lambda/` +
|
|
134
|
+
name: lambda.name.apply(n => `/aws/lambda/` + n),
|
|
123
135
|
retentionInDays: days,
|
|
124
136
|
tags: tagsFor(name + `LogGroup`, "Logs")
|
|
125
137
|
}, opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
|
|
@@ -107,6 +107,11 @@ let forDcbCommandTopic = (
|
|
|
107
107
|
}
|
|
108
108
|
})
|
|
109
109
|
)
|
|
110
|
+
// First-class log-level override → LOG_LEVEL, winning over any generic
|
|
111
|
+
// envVars entry. When unset, makeFromCodeAsset applies the tier default.
|
|
112
|
+
cfg.logLevel->Option.forEach(level =>
|
|
113
|
+
envVars->Dict.set("LOG_LEVEL", level->Pulumi.Output.make->Pulumi.Output.asInput)
|
|
114
|
+
)
|
|
110
115
|
|
|
111
116
|
// Build HANDLER_CONFIG JSON: array of {spec, behavior} objects so the entry point
|
|
112
117
|
// can dynamically import both modules and apply the curried StateChangeSlice_Callback.Make
|
|
@@ -53,6 +53,9 @@ function forDcbCommandTopic(slicePaths, inboundSlicesOpt, dcbTableName, pluginNa
|
|
|
53
53
|
return;
|
|
54
54
|
}
|
|
55
55
|
}));
|
|
56
|
+
Stdlib_Option.forEach(cfg.logLevel, level => {
|
|
57
|
+
envVars["LOG_LEVEL"] = Pulumi.output(level);
|
|
58
|
+
});
|
|
56
59
|
let sliceModulesJson = slicePaths.map(param => {
|
|
57
60
|
let s = Stdlib_Option.getOr(JSON.stringify(param[0]), `""`);
|
|
58
61
|
let b = Stdlib_Option.getOr(JSON.stringify(param[1]), `""`);
|
|
@@ -237,6 +237,7 @@ let finish = (
|
|
|
237
237
|
("STATE_TOPIC_MAP", topicMapJson->Pulumi.Output.asInput),
|
|
238
238
|
("NODE_OPTIONS", Util_Bundle.esmLoaderNodeOptions->Pulumi.Input.make),
|
|
239
239
|
("ESM_FALLBACK_DIRS", Util_Bundle.esmFallbackDirs->Pulumi.Input.make),
|
|
240
|
+
Util_LambdaLogging.logLevelEntry(),
|
|
240
241
|
]),
|
|
241
242
|
}: Lambda.Function.functionEnvironment
|
|
242
243
|
)->Pulumi.Input.make,
|
|
@@ -244,6 +245,19 @@ let finish = (
|
|
|
244
245
|
~opts,
|
|
245
246
|
)
|
|
246
247
|
|
|
248
|
+
Util_LambdaLogging.makeManagedLogGroup(
|
|
249
|
+
~name=name ++ "StateTopicPublisher",
|
|
250
|
+
~lambdaName=lambda.name,
|
|
251
|
+
~tags=AWS.Tags.make(
|
|
252
|
+
~name=name ++ "StateTopicPublisherLogGroup",
|
|
253
|
+
~kind=ReventlessCore.QueryDb.componentType,
|
|
254
|
+
~role=Logs,
|
|
255
|
+
~component=name,
|
|
256
|
+
),
|
|
257
|
+
~opts,
|
|
258
|
+
(),
|
|
259
|
+
)
|
|
260
|
+
|
|
247
261
|
// One EventSourceMapping per stream, all targeting the shared Lambda.
|
|
248
262
|
// Retry posture: the handler throws on 5xx / network errors and logs-and-
|
|
249
263
|
// continues on 4xx. `maximumRetryAttempts: 3` bounds the per-record
|
|
@@ -12,6 +12,7 @@ import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/sr
|
|
|
12
12
|
import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
|
|
13
13
|
import * as Util_ReadModel$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_ReadModel.res.mjs";
|
|
14
14
|
import * as AppSync_EventsApi$ReventlessAws from "../Api/AppSync_EventsApi.res.mjs";
|
|
15
|
+
import * as Util_LambdaLogging$ReventlessAws from "../../util/Util_LambdaLogging.res.mjs";
|
|
15
16
|
import * as Util_DynamoDbStream$ReventlessAws from "../../util/Util_DynamoDbStream.res.mjs";
|
|
16
17
|
|
|
17
18
|
let registry = {};
|
|
@@ -119,11 +120,13 @@ function finish(eventsApi, opts) {
|
|
|
119
120
|
[
|
|
120
121
|
"ESM_FALLBACK_DIRS",
|
|
121
122
|
Util_Bundle$ReventlessAws.esmFallbackDirs
|
|
122
|
-
]
|
|
123
|
+
],
|
|
124
|
+
Util_LambdaLogging$ReventlessAws.logLevelEntry()
|
|
123
125
|
])
|
|
124
126
|
},
|
|
125
127
|
sourceCodeHash: match.sourceCodeHash
|
|
126
128
|
}, opts);
|
|
129
|
+
Util_LambdaLogging$ReventlessAws.makeManagedLogGroup(name + "StateTopicPublisher", lambda.name, AWS_Tags$ReventlessAws.make(name + "StateTopicPublisherLogGroup", QueryDb$ReventlessCore.componentType, "Logs", undefined, name, undefined, undefined, undefined), opts, undefined);
|
|
127
130
|
entries.forEach(entry => {
|
|
128
131
|
let esmName = entry.topicName + "Stream2" + name + "StateTopic";
|
|
129
132
|
new (Aws.lambda.EventSourceMapping)(esmName, {
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// The claim component: an uploaded object stops being provisional when a
|
|
2
|
+
// committed event references it.
|
|
3
|
+
//
|
|
4
|
+
// Deploy-time half of [docs/plans/done/upload-pending-claim-and-expiry.md] Step 2.
|
|
5
|
+
// `make` registers one event log whose events declare at least one
|
|
6
|
+
// `@storageRef` field; `finish` builds the single Lambda that serves all of
|
|
7
|
+
// them, its stream subscriptions, its IAM, and the alarm on how far behind it
|
|
8
|
+
// is. Registered from the platform's subscription hook and finished from inside
|
|
9
|
+
// it, for the same reason `StateTopic_AppSync` is: the hook fires from within a
|
|
10
|
+
// `Pulumi.Output.apply` chain, so a `finish` called after `P.make()` returns
|
|
11
|
+
// would drain an empty registry.
|
|
12
|
+
//
|
|
13
|
+
// It is not a plugin's concern in the domain sense — a ref minted by one plugin
|
|
14
|
+
// can be referenced by another's event, and no plugin owns a store's contents —
|
|
15
|
+
// but it is provisioned per plugin stack, because that is where the event log
|
|
16
|
+
// tables and their streams exist. The store side (bucket and served prefix)
|
|
17
|
+
// crosses in from the platform stack, which is where stores are provisioned.
|
|
18
|
+
//
|
|
19
|
+
// Input breadth is the thing to guard. Only event logs with a declared
|
|
20
|
+
// ref-bearing field are registered, and within one only the declared fields are
|
|
21
|
+
// read; if this ever grows toward "read every event" it has become a projection
|
|
22
|
+
// and belongs in that machinery instead.
|
|
23
|
+
|
|
24
|
+
open PulumiAws
|
|
25
|
+
|
|
26
|
+
// ── Registry ─────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
type entry = {
|
|
29
|
+
/** The event log's DynamoDB table. Doubles as the routing key: the handler
|
|
30
|
+
recovers it from each record's `eventSourceARN`. */
|
|
31
|
+
tableName: Pulumi.Output.t<string>,
|
|
32
|
+
streamArn: Pulumi.Output.t<string>,
|
|
33
|
+
refFields: array<ReventlessCore.StorageRefFields.eventRefFields>,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One registry per plugin — one claimer Lambda per plugin stack. `make`
|
|
37
|
+
appends, `finish` drains. */
|
|
38
|
+
let registry: dict<array<entry>> = Dict.make()
|
|
39
|
+
|
|
40
|
+
/** Event logs that declare a ref-bearing field but publish through a channel
|
|
41
|
+
this component cannot read (an SNS-backed event topic rather than a
|
|
42
|
+
DynamoDB stream). Their objects stay tagged forever, which is safe while the
|
|
43
|
+
lifecycle rule is off and data loss once it is on — so the deploy says so. */
|
|
44
|
+
let unreachable: dict<array<string>> = Dict.make()
|
|
45
|
+
|
|
46
|
+
let log = ReventlessCore.Logger.fromEnv()
|
|
47
|
+
|
|
48
|
+
let appendTo = (reg: dict<array<'a>>, key: string, value: 'a) =>
|
|
49
|
+
reg->Dict.set(key, reg->Dict.get(key)->Option.getOr([])->Array.concat([value]))
|
|
50
|
+
|
|
51
|
+
// ── Registration ─────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
Register one event log with the plugin's claimer.
|
|
55
|
+
|
|
56
|
+
`~eventSchema` is read for `@storageRef` declarations; an event log with none is
|
|
57
|
+
not registered at all, so a platform whose plugins declare no store provisions
|
|
58
|
+
no claimer. `~plugin` qualifies a store the annotation left unqualified, which
|
|
59
|
+
is the same resolution the deploy used when it provisioned the store.
|
|
60
|
+
|
|
61
|
+
`~isStreamBacked` says whether the event log's topic publishes through a
|
|
62
|
+
DynamoDB stream — the default for both aggregate and DCB logs — rather than
|
|
63
|
+
SNS. Decided by the caller because the platform already makes exactly that
|
|
64
|
+
call synchronously from `EventTopicPublisher_SNS`'s registry; asking the
|
|
65
|
+
resource here would only get the answer inside an `apply`, which is after
|
|
66
|
+
`finish` has drained the registry.
|
|
67
|
+
*/
|
|
68
|
+
let make = (
|
|
69
|
+
~plugin: string,
|
|
70
|
+
~eventLogName: string,
|
|
71
|
+
~eventSchema: S.t<unknown>,
|
|
72
|
+
~eventTopicOutputs: ReventlessInfra.EventTopic.outputs,
|
|
73
|
+
~isStreamBacked: bool,
|
|
74
|
+
) => {
|
|
75
|
+
let refFields = ReventlessCore.StorageRefFields.fromEventSchema(~plugin, eventSchema)
|
|
76
|
+
if refFields->Array.length > 0 {
|
|
77
|
+
// An SNS-backed topic carries the same events, but not on a channel this
|
|
78
|
+
// Lambda subscribes to. Recorded as a gap rather than skipped in silence:
|
|
79
|
+
// its objects would keep the pending tag forever, which reads as "working"
|
|
80
|
+
// right up until a store enables expiry.
|
|
81
|
+
switch isStreamBacked ? eventTopicOutputs.resources->Array.get(0) : None {
|
|
82
|
+
| None => unreachable->appendTo(plugin, eventLogName)
|
|
83
|
+
| Some(streamResource) =>
|
|
84
|
+
registry->appendTo(
|
|
85
|
+
plugin,
|
|
86
|
+
{
|
|
87
|
+
tableName: streamResource.name,
|
|
88
|
+
streamArn: streamResource->Util_DynamoDbStream.streamArnFromDynamoDbTableResource,
|
|
89
|
+
refFields,
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Finalize ─────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/** A store the claimer may untag in, resolved to what the handler needs. */
|
|
99
|
+
type storeConfig = {
|
|
100
|
+
qualified: string,
|
|
101
|
+
bucketName: string,
|
|
102
|
+
servedPrefix: string,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
Build the plugin's claimer from everything `make` registered.
|
|
107
|
+
|
|
108
|
+
`~stores` is the platform's declared object stores — in a plugin stack they
|
|
109
|
+
arrive from the platform stack's `objectStores` export, which is why it is an
|
|
110
|
+
Output. A thunk rather than the Output itself because most plugins register
|
|
111
|
+
nothing here, and resolving another stack's export to build a claimer that is
|
|
112
|
+
not going to exist is work with no reader.
|
|
113
|
+
|
|
114
|
+
`~iteratorAgeAlarmMs` is the lag the alarm fires at. Lag is the one failure mode
|
|
115
|
+
that deletes data: if this Lambda stops and nobody notices, referenced objects
|
|
116
|
+
keep the tag and an enabled lifecycle rule expires them. The default is an hour
|
|
117
|
+
against an expiry measured in days, so a stall is noticed with room to fix it.
|
|
118
|
+
*/
|
|
119
|
+
let finish = (
|
|
120
|
+
~plugin: string,
|
|
121
|
+
~stores: unit => Pulumi.Output.t<array<storeConfig>>,
|
|
122
|
+
~iteratorAgeAlarmMs: float=3_600_000.,
|
|
123
|
+
~opts: Pulumi.CustomResourceOptions.t,
|
|
124
|
+
) => {
|
|
125
|
+
switch unreachable->Dict.get(plugin)->Option.filter(logs => logs->Array.length > 0) {
|
|
126
|
+
| Some(logs) =>
|
|
127
|
+
log.warn(
|
|
128
|
+
~comp="Upload_Claim",
|
|
129
|
+
`event log(s) ${logs->Array.join(", ")} declare storage-ref fields but publish through an ` ++
|
|
130
|
+
`SNS event topic, which the claimer does not subscribe to — objects those events reference ` ++
|
|
131
|
+
`keep the pending tag. Do not enable a store's expiry rule while this is true.`,
|
|
132
|
+
)
|
|
133
|
+
unreachable->Dict.set(plugin, [])
|
|
134
|
+
| _ => ()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
switch registry->Dict.get(plugin) {
|
|
138
|
+
| None | Some([]) => ()
|
|
139
|
+
| Some(entries) =>
|
|
140
|
+
let name = plugin ++ "UploadClaim"
|
|
141
|
+
let stores = stores()
|
|
142
|
+
|
|
143
|
+
let lambdaRole = IAM.Role.makeWithDefaultPolicy(
|
|
144
|
+
~name=name ++ "Role",
|
|
145
|
+
~servicePrincipal=AWS.Lambda.principal->Pulumi.Output.make,
|
|
146
|
+
~tags=AWS.Tags.make(
|
|
147
|
+
~name=name ++ "Role",
|
|
148
|
+
~kind=ReventlessCore.ComponentType.Plugin,
|
|
149
|
+
~role=Identity,
|
|
150
|
+
~component=name,
|
|
151
|
+
),
|
|
152
|
+
~opts,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
// Read every registered stream; tag and untag under every declared store's
|
|
156
|
+
// served prefix, never a whole bucket. `GetObjectTagging` is what makes the
|
|
157
|
+
// untag a read-modify-write that preserves a deployment's own tags rather
|
|
158
|
+
// than a `DeleteObjectTagging` that would take them with it.
|
|
159
|
+
let _policy =
|
|
160
|
+
(entries->Array.map(e => e.streamArn)->Pulumi.Output.all, stores)
|
|
161
|
+
->Pulumi.Output.all2
|
|
162
|
+
->Pulumi.Output.apply(((streamArns, stores)) => {
|
|
163
|
+
open PolicyDocument
|
|
164
|
+
let objectArns =
|
|
165
|
+
stores->Array.map(s => `arn:aws:s3:::${s.bucketName}/${s.servedPrefix}/*`)
|
|
166
|
+
let _rolePolicy = IAM.RolePolicy.make(
|
|
167
|
+
~name=name ++ "Policy",
|
|
168
|
+
~args={
|
|
169
|
+
policy: PolicyDocument.make(
|
|
170
|
+
~id=name ++ "Policy",
|
|
171
|
+
~statements=[
|
|
172
|
+
{
|
|
173
|
+
sid: "AllowLambdaLogging",
|
|
174
|
+
effect: Allow,
|
|
175
|
+
actions: Actions([
|
|
176
|
+
"logs:CreateLogGroup",
|
|
177
|
+
"logs:CreateLogStream",
|
|
178
|
+
"logs:PutLogEvents",
|
|
179
|
+
]),
|
|
180
|
+
resources: Resource("arn:aws:logs:*:*:*"),
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
sid: "AllowReadEventLogStream",
|
|
184
|
+
effect: Allow,
|
|
185
|
+
actions: Actions([
|
|
186
|
+
"dynamodb:DescribeStream",
|
|
187
|
+
"dynamodb:GetRecords",
|
|
188
|
+
"dynamodb:GetShardIterator",
|
|
189
|
+
"dynamodb:ListStreams",
|
|
190
|
+
]),
|
|
191
|
+
resources: Resources(streamArns),
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
sid: "AllowClaimObjectTagging",
|
|
195
|
+
effect: Allow,
|
|
196
|
+
actions: Actions(["s3:GetObjectTagging", "s3:PutObjectTagging"]),
|
|
197
|
+
resources: Resources(objectArns),
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
)
|
|
201
|
+
->PolicyDocument.toJsonString
|
|
202
|
+
->Pulumi.Input.make,
|
|
203
|
+
role: lambdaRole.id->Pulumi.Output.asInput,
|
|
204
|
+
},
|
|
205
|
+
~opts,
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// `CLAIM_REF_FIELDS` — `{ <tableName>: { <eventType>: [refField] } }`. The
|
|
210
|
+
// whole of the handler's input: a table absent here is a table it reads no
|
|
211
|
+
// record from.
|
|
212
|
+
let refFieldsJson =
|
|
213
|
+
entries
|
|
214
|
+
->Array.map(e => e.tableName)
|
|
215
|
+
->Pulumi.Output.all
|
|
216
|
+
->Pulumi.Output.apply(tableNames => {
|
|
217
|
+
let byTable = Dict.make()
|
|
218
|
+
tableNames->Array.forEachWithIndex((tableName, i) =>
|
|
219
|
+
byTable->Dict.set(
|
|
220
|
+
tableName,
|
|
221
|
+
ReventlessCore.StorageRefFields.toJson((entries->Array.getUnsafe(i)).refFields),
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
byTable->JSON.Encode.object->JSON.stringify
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// `UPLOAD_STORES` — the same qualified-name → {bucket, prefix} shape the
|
|
228
|
+
// presign service reads, so mint and claim resolve a store identically.
|
|
229
|
+
let uploadStoresJson =
|
|
230
|
+
stores->Pulumi.Output.apply(stores =>
|
|
231
|
+
stores
|
|
232
|
+
->Array.map(s => (
|
|
233
|
+
s.qualified,
|
|
234
|
+
Dict.fromArray([
|
|
235
|
+
("bucket", JSON.Encode.string(s.bucketName)),
|
|
236
|
+
("prefix", JSON.Encode.string(s.servedPrefix)),
|
|
237
|
+
])->JSON.Encode.object,
|
|
238
|
+
))
|
|
239
|
+
->Dict.fromArray
|
|
240
|
+
->JSON.Encode.object
|
|
241
|
+
->JSON.stringify
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
// Compiled EntryPoint rather than a serialized closure, for the reason the
|
|
245
|
+
// presign service is one: the handler reaches the AWS SDK v3 S3 client, and
|
|
246
|
+
// a serialized closure bakes the deploy machine's SDK internals into an
|
|
247
|
+
// archive that then resolves its transitives from independently-versioned
|
|
248
|
+
// layer sources that disagree at cold start. The bundled `_Ops` module keeps
|
|
249
|
+
// bare `@aws-sdk/*` imports and resolves them through the resolve-hook, so
|
|
250
|
+
// one internally consistent SDK loads.
|
|
251
|
+
let packageDirs = Dict.fromArray([
|
|
252
|
+
(
|
|
253
|
+
"@reventlessdev/reventless-aws",
|
|
254
|
+
Util_Bundle.resolvePackageRoot("@reventlessdev/reventless-aws"),
|
|
255
|
+
),
|
|
256
|
+
])
|
|
257
|
+
let {code, sourceCodeHash} = Util_Bundle.buildCodeArchive(
|
|
258
|
+
~entryPointModule="@reventlessdev/reventless-aws/src/adapter/Upload/Upload_Claim_S3_Ops.res.mjs",
|
|
259
|
+
~packageDirs,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
let layers =
|
|
263
|
+
Lambda.reventlessLayerArn
|
|
264
|
+
->Option.map(arn => [arn->Pulumi.Input.make])
|
|
265
|
+
->Option.getOr([])
|
|
266
|
+
->Pulumi.Input.make
|
|
267
|
+
|
|
268
|
+
let lambda = Lambda.Function.make(
|
|
269
|
+
~name,
|
|
270
|
+
~args={
|
|
271
|
+
handler: "index.handler"->Pulumi.Input.make,
|
|
272
|
+
runtime: "nodejs22.x"->Pulumi.Input.make,
|
|
273
|
+
code: code->Pulumi.Input.make,
|
|
274
|
+
sourceCodeHash: sourceCodeHash->Pulumi.Input.make,
|
|
275
|
+
role: lambdaRole.arn->Pulumi.Output.asInput,
|
|
276
|
+
memorySize: 256->Pulumi.Input.make,
|
|
277
|
+
timeout: 60->Pulumi.Input.make,
|
|
278
|
+
layers,
|
|
279
|
+
tags: AWS.Tags.make(
|
|
280
|
+
~name,
|
|
281
|
+
~kind=ReventlessCore.ComponentType.Plugin,
|
|
282
|
+
~role=Runtime,
|
|
283
|
+
~component=name,
|
|
284
|
+
),
|
|
285
|
+
environment: (
|
|
286
|
+
{
|
|
287
|
+
Lambda.Function.variables: Dict.fromArray([
|
|
288
|
+
("Environment", Pulumi.Pulumi.getStackName()->Pulumi.Input.make),
|
|
289
|
+
("UPLOAD_STORES", uploadStoresJson->Pulumi.Output.asInput),
|
|
290
|
+
("CLAIM_REF_FIELDS", refFieldsJson->Pulumi.Output.asInput),
|
|
291
|
+
("NODE_OPTIONS", Util_Bundle.esmLoaderNodeOptions->Pulumi.Input.make),
|
|
292
|
+
("ESM_FALLBACK_DIRS", Util_Bundle.esmFallbackDirs->Pulumi.Input.make),
|
|
293
|
+
Util_LambdaLogging.logLevelEntry(),
|
|
294
|
+
]),
|
|
295
|
+
}: Lambda.Function.functionEnvironment
|
|
296
|
+
)->Pulumi.Input.make,
|
|
297
|
+
},
|
|
298
|
+
~opts,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
Util_LambdaLogging.makeManagedLogGroup(
|
|
302
|
+
~name,
|
|
303
|
+
~lambdaName=lambda.name,
|
|
304
|
+
~tags=AWS.Tags.make(
|
|
305
|
+
~name=name ++ "LogGroup",
|
|
306
|
+
~kind=ReventlessCore.ComponentType.Plugin,
|
|
307
|
+
~role=Logs,
|
|
308
|
+
~component=name,
|
|
309
|
+
),
|
|
310
|
+
~opts,
|
|
311
|
+
(),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
// One mapping per event log stream, all targeting the one Lambda.
|
|
315
|
+
// `maximumRetryAttempts` bounds a permanently failing record so it cannot
|
|
316
|
+
// wedge the shard — the default of -1 retries forever, which would turn one
|
|
317
|
+
// bad record into unbounded claim lag, which is the failure that deletes
|
|
318
|
+
// data. `bisectBatchOnFunctionError` isolates it across those retries.
|
|
319
|
+
entries->Array.forEachWithIndex((entry, i) => {
|
|
320
|
+
let esmName = `${name}Stream${i->Int.toString}`
|
|
321
|
+
let _esm = EventSourceMapping.make(
|
|
322
|
+
~name=esmName,
|
|
323
|
+
~args={
|
|
324
|
+
EventSourceMapping.functionName: lambda.arn->Pulumi.Output.asInput,
|
|
325
|
+
eventSourceArn: entry.streamArn->Pulumi.Output.asInput,
|
|
326
|
+
startingPosition: LATEST,
|
|
327
|
+
maximumRetryAttempts: 3,
|
|
328
|
+
bisectBatchOnFunctionError: true,
|
|
329
|
+
tags: AWS.Tags.make(
|
|
330
|
+
~name=esmName,
|
|
331
|
+
~kind=ReventlessCore.ComponentType.Plugin,
|
|
332
|
+
~role=EventSourceMapping,
|
|
333
|
+
~component=name,
|
|
334
|
+
),
|
|
335
|
+
},
|
|
336
|
+
~opts=Some(opts),
|
|
337
|
+
)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
// How far behind the claimer is, alarmed rather than merely emitted.
|
|
341
|
+
// `IteratorAge` is the stream consumer's own lag and needs no custom metric;
|
|
342
|
+
// `treatMissingData: "notBreaching"` keeps a quiet platform — no events, so
|
|
343
|
+
// no data points — out of alarm.
|
|
344
|
+
let _alarm = Cloudwatch.MetricAlarm.make(
|
|
345
|
+
~name=name ++ "Lag",
|
|
346
|
+
~args={
|
|
347
|
+
comparisonOperator: "GreaterThanThreshold"->Pulumi.Input.make,
|
|
348
|
+
evaluationPeriods: 3->Pulumi.Input.make,
|
|
349
|
+
metricName: "IteratorAge"->Pulumi.Input.make,
|
|
350
|
+
namespace: "AWS/Lambda"->Pulumi.Input.make,
|
|
351
|
+
period: 300->Pulumi.Input.make,
|
|
352
|
+
statistic: "Maximum"->Pulumi.Input.make,
|
|
353
|
+
threshold: iteratorAgeAlarmMs->Pulumi.Input.make,
|
|
354
|
+
dimensions: lambda.name
|
|
355
|
+
->Pulumi.Output.apply(fn => Dict.fromArray([("FunctionName", fn)]))
|
|
356
|
+
->Pulumi.Output.asInput,
|
|
357
|
+
treatMissingData: "notBreaching"->Pulumi.Input.make,
|
|
358
|
+
alarmDescription: (`${name} is behind on committed events. While it is behind, objects an ` ++
|
|
359
|
+
`event already references still carry the pending tag — and a store with an expiry rule ` ++
|
|
360
|
+
`enabled will delete them.`)->Pulumi.Input.make,
|
|
361
|
+
tags: AWS.Tags.make(
|
|
362
|
+
~name=name ++ "Lag",
|
|
363
|
+
~kind=ReventlessCore.ComponentType.Plugin,
|
|
364
|
+
~role=Other("Alarm"),
|
|
365
|
+
~component=name,
|
|
366
|
+
),
|
|
367
|
+
},
|
|
368
|
+
~opts,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
// Drain, so a second construction in the same process (tests, dev reload)
|
|
372
|
+
// starts from empty rather than re-registering every stream.
|
|
373
|
+
registry->Dict.set(plugin, [])
|
|
374
|
+
}
|
|
375
|
+
}
|