@reventlessdev/reventless-aws 3.0.0-alpha.253 → 3.0.0-alpha.254
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 +8 -0
- package/package.json +9 -9
- package/src/Platform.res +134 -20
- package/src/Platform.res.mjs +95 -6
- 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 +66 -8
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +16 -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/Upload/Upload_Claim_S3.res +361 -0
- package/src/adapter/Upload/Upload_Claim_S3.res.mjs +203 -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 +12 -2
- package/src/adapter/Upload/Upload_Presign_S3.res.mjs +1 -0
- 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_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
|
@@ -37,6 +37,14 @@
|
|
|
37
37
|
|
|
38
38
|
open PulumiAws
|
|
39
39
|
|
|
40
|
+
/** One prefix in this bucket whose never-claimed uploads expire, and after how
|
|
41
|
+
many days. A list rather than a scalar because a shared-layout bucket holds
|
|
42
|
+
several stores, and expiry is opt-in per store. */
|
|
43
|
+
type pendingExpiry = {
|
|
44
|
+
prefix: string,
|
|
45
|
+
days: int,
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
// Browser direct-PUT needs CORS. These defaults match what deployments wrote by
|
|
41
49
|
// hand; `~corsRules` overrides them for a store the browser never touches.
|
|
42
50
|
let defaultCorsRules: S3.Bucket.corsRules = [
|
|
@@ -64,13 +72,19 @@ let defaultCorsRules: S3.Bucket.corsRules = [
|
|
|
64
72
|
`~protect` (default on) blocks `pulumi destroy` and accidental replacement.
|
|
65
73
|
`~forceDestroy` is its counterpart for disposable stacks: a protected bucket
|
|
66
74
|
cannot be torn down, so a PR stack would leak exactly the buckets that
|
|
67
|
-
sharing exists to save.
|
|
75
|
+
sharing exists to save.
|
|
76
|
+
|
|
77
|
+
`~expirePending` turns on the sweep of never-claimed uploads. One entry per
|
|
78
|
+
prefix rather than one setting for the bucket, because a shared-layout
|
|
79
|
+
bucket holds several stores and the setting is each store's own — see the
|
|
80
|
+
comment on the rule below before setting it. */
|
|
68
81
|
let make = (
|
|
69
82
|
~name: string,
|
|
70
83
|
~keyPrefix: string=Upload_Presign_S3.defaultServedPrefix,
|
|
71
84
|
~plugin: option<string>=?,
|
|
72
85
|
~protect: bool=true,
|
|
73
86
|
~forceDestroy: bool=false,
|
|
87
|
+
~expirePending: array<pendingExpiry>=[],
|
|
74
88
|
~corsRules: S3.Bucket.corsRules=defaultCorsRules,
|
|
75
89
|
~opts: option<Pulumi.CustomResourceOptions.t>=?,
|
|
76
90
|
): ReventlessInfra.Platform.objectStore => {
|
|
@@ -92,11 +106,38 @@ let make = (
|
|
|
92
106
|
protect,
|
|
93
107
|
}
|
|
94
108
|
|
|
109
|
+
// Expire objects nobody ever claimed — the half of abandonment the release
|
|
110
|
+
// path cannot reach, because the client that would have released them is
|
|
111
|
+
// gone (tab closed, navigation, dead network).
|
|
112
|
+
//
|
|
113
|
+
// Filtered on the pending tag **and** this store's served prefix, never on
|
|
114
|
+
// either alone, and written per store rather than once per bucket — a shared
|
|
115
|
+
// bucket holds several stores, and expiring a neighbouring store's objects on
|
|
116
|
+
// this store's setting would be exactly the wrong kind of surprise on a
|
|
117
|
+
// bucket created with `protect: true`.
|
|
118
|
+
//
|
|
119
|
+
// What makes it safe is what it *cannot* match. An object minted before this
|
|
120
|
+
// mechanism existed carries no tag; a claimed object has had its tag stripped.
|
|
121
|
+
// Neither is inside the filter, so the rule's blast radius is precisely
|
|
122
|
+
// "uploaded here, and no committed event references it". The one way it
|
|
123
|
+
// deletes live data is a claim component that stopped and was not noticed —
|
|
124
|
+
// which is why the claimer alarms on its own lag, why this is opt-in per
|
|
125
|
+
// store, and why the plan sequences reconciliation before the first store
|
|
126
|
+
// turns it on.
|
|
127
|
+
let lifecycleRules = expirePending->Array.map(({prefix, days}) => {
|
|
128
|
+
S3.Bucket.id: `reventless-pending-expiry-${prefix->String.replaceAll("/", "-")}`,
|
|
129
|
+
enabled: true,
|
|
130
|
+
prefix: `${prefix}/`,
|
|
131
|
+
tags: Dict.fromArray([(Upload_PendingTag.key, Upload_PendingTag.value)]),
|
|
132
|
+
expiration: {days: days},
|
|
133
|
+
})
|
|
134
|
+
|
|
95
135
|
let bucket = S3.Bucket.make(
|
|
96
136
|
~name,
|
|
97
137
|
~args={
|
|
98
138
|
corsRules: corsRules->Pulumi.Input.make,
|
|
99
139
|
forceDestroy: forceDestroy->Pulumi.Input.make,
|
|
140
|
+
lifecycleRules: lifecycleRules->Pulumi.Input.make,
|
|
100
141
|
tags: AWS.Tags.make(
|
|
101
142
|
~name,
|
|
102
143
|
~kind,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as Aws from "@pulumi/aws";
|
|
4
4
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
5
|
import * as AWS_Tags$ReventlessAws from "../adapter/AWS_Tags.res.mjs";
|
|
6
|
+
import * as Upload_PendingTag$ReventlessAws from "../adapter/Upload/Upload_PendingTag.res.mjs";
|
|
6
7
|
import * as Upload_Presign_S3$ReventlessAws from "../adapter/Upload/Upload_Presign_S3.res.mjs";
|
|
7
8
|
|
|
8
9
|
let defaultCorsRules = [{
|
|
@@ -18,10 +19,11 @@ let defaultCorsRules = [{
|
|
|
18
19
|
maxAgeSeconds: 3000
|
|
19
20
|
}];
|
|
20
21
|
|
|
21
|
-
function make(name, keyPrefixOpt, plugin, protectOpt, forceDestroyOpt, corsRulesOpt, opts) {
|
|
22
|
+
function make(name, keyPrefixOpt, plugin, protectOpt, forceDestroyOpt, expirePendingOpt, corsRulesOpt, opts) {
|
|
22
23
|
let keyPrefix = keyPrefixOpt !== undefined ? keyPrefixOpt : Upload_Presign_S3$ReventlessAws.defaultServedPrefix;
|
|
23
24
|
let protect = protectOpt !== undefined ? protectOpt : true;
|
|
24
25
|
let forceDestroy = forceDestroyOpt !== undefined ? forceDestroyOpt : false;
|
|
26
|
+
let expirePending = expirePendingOpt !== undefined ? expirePendingOpt : [];
|
|
25
27
|
let corsRules = corsRulesOpt !== undefined ? corsRulesOpt : defaultCorsRules;
|
|
26
28
|
let match = plugin !== undefined ? [
|
|
27
29
|
"Plugin",
|
|
@@ -32,9 +34,25 @@ function make(name, keyPrefixOpt, plugin, protectOpt, forceDestroyOpt, corsRules
|
|
|
32
34
|
];
|
|
33
35
|
let newrecord = {...Stdlib_Option.getOr(opts, {})};
|
|
34
36
|
newrecord.protect = protect;
|
|
37
|
+
let lifecycleRules = expirePending.map(param => {
|
|
38
|
+
let prefix = param.prefix;
|
|
39
|
+
return {
|
|
40
|
+
enabled: true,
|
|
41
|
+
expiration: {
|
|
42
|
+
days: param.days
|
|
43
|
+
},
|
|
44
|
+
id: `reventless-pending-expiry-` + prefix.replaceAll("/", "-"),
|
|
45
|
+
prefix: prefix + `/`,
|
|
46
|
+
tags: Object.fromEntries([[
|
|
47
|
+
Upload_PendingTag$ReventlessAws.key,
|
|
48
|
+
Upload_PendingTag$ReventlessAws.value
|
|
49
|
+
]])
|
|
50
|
+
};
|
|
51
|
+
});
|
|
35
52
|
let bucket = new (Aws.s3.Bucket)(name, {
|
|
36
53
|
corsRules: corsRules,
|
|
37
54
|
forceDestroy: forceDestroy,
|
|
55
|
+
lifecycleRules: lifecycleRules,
|
|
38
56
|
tags: AWS_Tags$ReventlessAws.make(name, match[0], {
|
|
39
57
|
TAG: "Other",
|
|
40
58
|
_0: "ObjectStore"
|
|
@@ -549,6 +549,42 @@ let _makeApiResourceWith = (
|
|
|
549
549
|
}
|
|
550
550
|
let graphQLApi = AppSync.GraphQLApi.make(~name, ~args=apiArgs, ~opts=Some(customOpts))
|
|
551
551
|
|
|
552
|
+
// Managed log group with tiered retention for AppSync's own field-resolver logs,
|
|
553
|
+
// which land in `/aws/appsync/apis/<id>` and otherwise live forever with no
|
|
554
|
+
// retention. Managed on every stack by default (bar any in
|
|
555
|
+
// `unmanagedLogGroupStacks`), same as the Lambda groups. The name derives from
|
|
556
|
+
// the API id output so it matches the group AppSync writes to, and Pulumi
|
|
557
|
+
// therefore tears it down with the API.
|
|
558
|
+
let stack = Pulumi.Pulumi.getStackName()
|
|
559
|
+
let prodStacks = Util_HostUiDomain.resolveProdStacks()
|
|
560
|
+
let unmanagedStacks = Util_LogRetention.parseUnmanagedStacks(
|
|
561
|
+
Util_LocalConfig.get("unmanagedLogGroupStacks")->Option.getOr(""),
|
|
562
|
+
)
|
|
563
|
+
if Util_LogRetention.managesLogGroup(~stack, ~unmanagedStacks) {
|
|
564
|
+
let _ = Cloudwatch.LogGroup.make(
|
|
565
|
+
~name=`${name}AppSyncLogGroup`,
|
|
566
|
+
~args={
|
|
567
|
+
name: graphQLApi.id
|
|
568
|
+
->Pulumi.Output.apply(id => `/aws/appsync/apis/${id}`)
|
|
569
|
+
->Pulumi.Output.asInput,
|
|
570
|
+
retentionInDays: Util_LogRetention.retentionDaysFor(
|
|
571
|
+
~stack,
|
|
572
|
+
~prodStacks,
|
|
573
|
+
~configOverride=?Util_LocalConfig.get("logRetentionDays")->Option.flatMap(s =>
|
|
574
|
+
Int.fromString(s)
|
|
575
|
+
),
|
|
576
|
+
)->Pulumi.Input.make,
|
|
577
|
+
tags: AWS.Tags.make(
|
|
578
|
+
~name=`${name}AppSyncLogGroup`,
|
|
579
|
+
~kind=ReventlessCore.ComponentType.Plugin,
|
|
580
|
+
~role=Logs,
|
|
581
|
+
~scope=Plugin,
|
|
582
|
+
),
|
|
583
|
+
},
|
|
584
|
+
~opts=customOpts,
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
|
|
552
588
|
(graphQLApi->Pulumi.Output.make, iamRole->Pulumi.Output.make)
|
|
553
589
|
}
|
|
554
590
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as Effect from "@reventlessdev/rescript-effect/src/Effect.res.mjs";
|
|
4
|
+
import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
|
|
4
5
|
import * as Aws from "@pulumi/aws";
|
|
5
6
|
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
6
7
|
import * as Nodecrypto from "node:crypto";
|
|
@@ -15,7 +16,10 @@ import * as AWS_Tags$ReventlessAws from "../../adapter/AWS_Tags.res.mjs";
|
|
|
15
16
|
import * as ClientAppsync from "@aws-sdk/client-appsync";
|
|
16
17
|
import * as Auth_Cognito$ReventlessAws from "../../adapter/Auth/Auth_Cognito.res.mjs";
|
|
17
18
|
import * as AppSync_Error$ReventlessAws from "../../errors/AppSync_Error.res.mjs";
|
|
19
|
+
import * as Util_LocalConfig$ReventlessAws from "../../util/Util_LocalConfig.res.mjs";
|
|
18
20
|
import * as GraphQL_Stitcher$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_Stitcher.res.mjs";
|
|
21
|
+
import * as Util_HostUiDomain$ReventlessAws from "../../util/Util_HostUiDomain.res.mjs";
|
|
22
|
+
import * as Util_LogRetention$ReventlessAws from "../../util/Util_LogRetention.res.mjs";
|
|
19
23
|
import * as AppSync_SdlDecorate$ReventlessAws from "./AppSync_SdlDecorate.res.mjs";
|
|
20
24
|
import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
|
|
21
25
|
|
|
@@ -308,6 +312,16 @@ function _makeApiResourceWith(name, schema, userPoolConfig, opts) {
|
|
|
308
312
|
tags: apiArgs_tags
|
|
309
313
|
};
|
|
310
314
|
let graphQLApi = new (Aws.appsync.GraphQLApi)(name, apiArgs, customOpts);
|
|
315
|
+
let stack = Pulumi.getStack();
|
|
316
|
+
let prodStacks = Util_HostUiDomain$ReventlessAws.resolveProdStacks();
|
|
317
|
+
let unmanagedStacks = Util_LogRetention$ReventlessAws.parseUnmanagedStacks(Stdlib_Option.getOr(Util_LocalConfig$ReventlessAws.get("unmanagedLogGroupStacks"), ""));
|
|
318
|
+
if (Util_LogRetention$ReventlessAws.managesLogGroup(stack, unmanagedStacks)) {
|
|
319
|
+
new (Aws.cloudwatch.LogGroup)(name + `AppSyncLogGroup`, {
|
|
320
|
+
name: graphQLApi.id.apply(id => `/aws/appsync/apis/` + id),
|
|
321
|
+
retentionInDays: Util_LogRetention$ReventlessAws.retentionDaysFor(stack, prodStacks, Stdlib_Option.flatMap(Util_LocalConfig$ReventlessAws.get("logRetentionDays"), s => Stdlib_Int.fromString(s, undefined))),
|
|
322
|
+
tags: AWS_Tags$ReventlessAws.make(name + `AppSyncLogGroup`, "Plugin", "Logs", "Plugin", undefined, undefined, undefined, undefined)
|
|
323
|
+
}, customOpts);
|
|
324
|
+
}
|
|
311
325
|
return [
|
|
312
326
|
Pulumi.output(graphQLApi),
|
|
313
327
|
Pulumi.output(iamRole)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// How long a stack keeps its logs, and how verbose they are — tiered by
|
|
2
|
+
// environment, the same way `Util_StoreLayout` tiers bucket layout and
|
|
3
|
+
// `Util_HostUiDomain` tiers the host-shell FQDN. Pure functions so both
|
|
4
|
+
// decisions are decidable and unit-testable without touching Pulumi.
|
|
5
|
+
//
|
|
6
|
+
// Retention and level pull in opposite directions between prod and dev, which is
|
|
7
|
+
// the whole reason a single global setting cannot serve both:
|
|
8
|
+
//
|
|
9
|
+
// prod — quiet (`info`) and long-lived (365d): incident + forensic window
|
|
10
|
+
// dev — loud (`debug`) and short-lived (3–7d): consumed within hours
|
|
11
|
+
//
|
|
12
|
+
// The `prodStacks` allow-list is `Util_HostUiDomain.resolveProdStacks` — the one
|
|
13
|
+
// notion of "is this stack production" the platform already shares for domain
|
|
14
|
+
// naming and store layout, on purpose. A `configOverride` (from the Pulumi
|
|
15
|
+
// `logRetentionDays` / `logLevel` config keys) wins over the tier default so a
|
|
16
|
+
// stack can be dialled without a code change.
|
|
17
|
+
|
|
18
|
+
// CloudWatch only accepts a fixed enum of retention values; every number below is
|
|
19
|
+
// a member of it. `0` means "never expire" and is a valid explicit opt-in, but
|
|
20
|
+
// never a tier default — hot CloudWatch storage billed forever is what this
|
|
21
|
+
// tiering exists to bound.
|
|
22
|
+
|
|
23
|
+
/** Retention in days for a stack. prod/main = 365, beta = 30, `pr-*` = 3,
|
|
24
|
+
everything else (alpha and any unlisted stack) = 7. A `configOverride` wins. */
|
|
25
|
+
let retentionDaysFor = (
|
|
26
|
+
~stack: string,
|
|
27
|
+
~prodStacks: array<string>,
|
|
28
|
+
~configOverride: option<int>=?,
|
|
29
|
+
): int =>
|
|
30
|
+
switch configOverride {
|
|
31
|
+
| Some(days) => days
|
|
32
|
+
| None =>
|
|
33
|
+
switch stack {
|
|
34
|
+
| s if prodStacks->Array.includes(s) => 365
|
|
35
|
+
| "beta" => 30
|
|
36
|
+
| s if s->String.startsWith("pr-") => 3
|
|
37
|
+
| _ => 7
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The default `LOG_LEVEL` for a stack. prod/main and beta stay at `info` so
|
|
42
|
+
pre-prod behaves like prod and debug detail never leaks payloads in prod;
|
|
43
|
+
every other stack defaults to `debug` for verbose feedback during active
|
|
44
|
+
development (paired with short retention, so cheap). A `configOverride`
|
|
45
|
+
wins. */
|
|
46
|
+
let logLevelFor = (
|
|
47
|
+
~stack: string,
|
|
48
|
+
~prodStacks: array<string>,
|
|
49
|
+
~configOverride: option<string>=?,
|
|
50
|
+
): string =>
|
|
51
|
+
switch configOverride {
|
|
52
|
+
| Some(level) => level
|
|
53
|
+
| None =>
|
|
54
|
+
switch stack {
|
|
55
|
+
| s if prodStacks->Array.includes(s) => "info"
|
|
56
|
+
| "beta" => "info"
|
|
57
|
+
| _ => "debug"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
Whether the framework creates a *managed* CloudWatch log group for this stack, or
|
|
63
|
+
leaves Lambda/AppSync to auto-create an unmanaged one.
|
|
64
|
+
|
|
65
|
+
**Managed for every stack by default.** A stack deployed on this framework gets
|
|
66
|
+
its log groups from Pulumi on the first `pulumi up`, before Lambda/AppSync would
|
|
67
|
+
lazily auto-create them — so on a fresh stack there is nothing to adopt and no
|
|
68
|
+
`ResourceAlreadyExists` hazard. Every current stack is `alpha`, and there is no
|
|
69
|
+
prod stack to migrate, so extending management to all stacks costs nothing today.
|
|
70
|
+
|
|
71
|
+
The one case that still needs care is *adopting* a stack that predates managed
|
|
72
|
+
groups: it already carries Lambda's auto-created group, and `CreateLogGroup`
|
|
73
|
+
fails with `ResourceAlreadyExists` rather than adopting it. When such a stack
|
|
74
|
+
ever appears, either `pulumi import` its groups first, or name it in the
|
|
75
|
+
`unmanagedLogGroupStacks` config key to keep it on auto-created groups until the
|
|
76
|
+
import is done. That escape hatch is the "prepared for the future" seam — turning
|
|
77
|
+
a prod adoption into a config flip rather than an edit to this function.
|
|
78
|
+
*/
|
|
79
|
+
let managesLogGroup = (~stack: string, ~unmanagedStacks: array<string>=[]): bool =>
|
|
80
|
+
!(unmanagedStacks->Array.includes(stack))
|
|
81
|
+
|
|
82
|
+
/** Parse the `unmanagedLogGroupStacks` config value — a CSV of stack names kept
|
|
83
|
+
on auto-created groups pending a `pulumi import`. Same tolerant parse as the
|
|
84
|
+
prod-stacks list: trims, drops empty entries. */
|
|
85
|
+
let parseUnmanagedStacks = (csv: string): array<string> =>
|
|
86
|
+
csv->String.split(",")->Array.map(String.trim)->Array.filter(s => s !== "")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
function retentionDaysFor(stack, prodStacks, configOverride) {
|
|
5
|
+
if (configOverride !== undefined) {
|
|
6
|
+
return configOverride;
|
|
7
|
+
} else if (prodStacks.includes(stack)) {
|
|
8
|
+
return 365;
|
|
9
|
+
} else if (stack === "beta") {
|
|
10
|
+
return 30;
|
|
11
|
+
} else if (stack.startsWith("pr-")) {
|
|
12
|
+
return 3;
|
|
13
|
+
} else {
|
|
14
|
+
return 7;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function logLevelFor(stack, prodStacks, configOverride) {
|
|
19
|
+
if (configOverride !== undefined) {
|
|
20
|
+
return configOverride;
|
|
21
|
+
} else if (prodStacks.includes(stack) || stack === "beta") {
|
|
22
|
+
return "info";
|
|
23
|
+
} else {
|
|
24
|
+
return "debug";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function managesLogGroup(stack, unmanagedStacksOpt) {
|
|
29
|
+
let unmanagedStacks = unmanagedStacksOpt !== undefined ? unmanagedStacksOpt : [];
|
|
30
|
+
return !unmanagedStacks.includes(stack);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseUnmanagedStacks(csv) {
|
|
34
|
+
return csv.split(",").map(prim => prim.trim()).filter(s => s !== "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
retentionDaysFor,
|
|
39
|
+
logLevelFor,
|
|
40
|
+
managesLogGroup,
|
|
41
|
+
parseUnmanagedStacks,
|
|
42
|
+
}
|
|
43
|
+
/* No side effect */
|
|
@@ -189,3 +189,40 @@ let coverageFor = (~required: array<string>, ~provisioned: array<string>): cover
|
|
|
189
189
|
| (missing, provisioned) => Missing({missing, provisioned})
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
After how many days a store expires objects still tagged pending — `None`, which
|
|
195
|
+
is the default, meaning it expires nothing.
|
|
196
|
+
|
|
197
|
+
The setting is a deployment's, not a declaration's: `@storageRef("productImages")`
|
|
198
|
+
says a store is needed, and says nothing about how long an unclaimed upload
|
|
199
|
+
should live there. Read from the `pendingUploadExpiryDays` config key (env var
|
|
200
|
+
`REVENTLESS_PENDING_UPLOAD_EXPIRY_DAYS`, or the `Pulumi.local.yaml` sidecar) as a
|
|
201
|
+
comma-separated list of `{plugin}.{store}=days` pairs:
|
|
202
|
+
|
|
203
|
+
pendingUploadExpiryDays: "Catalog.productImages=30, Ordering.receipts=14"
|
|
204
|
+
|
|
205
|
+
**Per store and off by default, on purpose.** Enabling this is the one step in
|
|
206
|
+
the mechanism that deletes data, and it is only sound once the claim component
|
|
207
|
+
has been live long enough for reconciliation to confirm that the objects still
|
|
208
|
+
tagged pending really are the unreferenced ones. A global switch — or a default
|
|
209
|
+
value — would turn it on for stores nobody had checked yet, which is the whole
|
|
210
|
+
failure this sequencing exists to prevent. A store that accumulates forever is a
|
|
211
|
+
legitimate choice, and stays the choice until a deployment says otherwise.
|
|
212
|
+
|
|
213
|
+
Only positive whole days are accepted. A malformed or non-positive entry is
|
|
214
|
+
ignored rather than rounded or defaulted: guessing at what a typo meant, in the
|
|
215
|
+
one setting that deletes objects, is not a service.
|
|
216
|
+
*/
|
|
217
|
+
let pendingExpiryFor = (~config: option<string>, ~store: string): option<int> =>
|
|
218
|
+
config
|
|
219
|
+
->Option.getOr("")
|
|
220
|
+
->String.split(",")
|
|
221
|
+
->Array.filterMap(entry =>
|
|
222
|
+
switch entry->String.split("=") {
|
|
223
|
+
| [name, days] if name->String.trim == store =>
|
|
224
|
+
days->String.trim->Int.fromString->Option.filter(d => d > 0)
|
|
225
|
+
| _ => None
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
->Array.get(0)
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
|
|
4
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
3
6
|
|
|
4
7
|
let defaultEphemeralPrefixes = ["pr-"];
|
|
5
8
|
|
|
@@ -64,6 +67,20 @@ function coverageFor(required, provisioned) {
|
|
|
64
67
|
}
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
function pendingExpiryFor(config, store) {
|
|
71
|
+
return Stdlib_Array.filterMap(Stdlib_Option.getOr(config, "").split(","), entry => {
|
|
72
|
+
let match = entry.split("=");
|
|
73
|
+
if (match.length !== 2) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let name = match[0];
|
|
77
|
+
let days = match[1];
|
|
78
|
+
if (name.trim() === store) {
|
|
79
|
+
return Stdlib_Option.filter(Stdlib_Int.fromString(days.trim(), undefined), d => d > 0);
|
|
80
|
+
}
|
|
81
|
+
})[0];
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
export {
|
|
68
85
|
defaultEphemeralPrefixes,
|
|
69
86
|
layoutFor,
|
|
@@ -72,5 +89,6 @@ export {
|
|
|
72
89
|
keyPrefixFor,
|
|
73
90
|
servingFor,
|
|
74
91
|
coverageFor,
|
|
92
|
+
pendingExpiryFor,
|
|
75
93
|
}
|
|
76
94
|
/* No side effect */
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The claim component's pure decisions — no S3, no DynamoDB stream.
|
|
2
|
+
//
|
|
3
|
+
// Everything between "a committed event appeared" and "an S3 untag happens" is
|
|
4
|
+
// decided by three functions in `Upload_Claim_S3_Ops`: which table a record came
|
|
5
|
+
// from, which refs a declared field holds, and whether a ref resolves to an
|
|
6
|
+
// object this claimer may touch. The untag itself has no branching left in it,
|
|
7
|
+
// so pinning these pins the behaviour that matters.
|
|
8
|
+
//
|
|
9
|
+
// The direction under test throughout is refusal. An untag this component
|
|
10
|
+
// wrongly *skips* costs a delayed cleanup; one it wrongly *performs* removes the
|
|
11
|
+
// only thing standing between an object and an expiry rule.
|
|
12
|
+
|
|
13
|
+
open JestGlobals
|
|
14
|
+
|
|
15
|
+
module Ops = Upload_Claim_S3_Ops
|
|
16
|
+
|
|
17
|
+
// `stores` and `refFieldsByTable` are read from the environment at module load,
|
|
18
|
+
// so these tests exercise the decision functions against explicitly-built
|
|
19
|
+
// inputs rather than a configured module — which is also the honest scope: the
|
|
20
|
+
// env parsing is `JSON.parse` plus field reads.
|
|
21
|
+
|
|
22
|
+
describe("Upload_Claim_S3_Ops.tableNameFromEventSourceArn", () => {
|
|
23
|
+
testSync("reads the table name out of a stream ARN", () =>
|
|
24
|
+
expect(
|
|
25
|
+
Ops.tableNameFromEventSourceArn(
|
|
26
|
+
"arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-a1b2c3/stream/2026-08-02T00:00:00.000",
|
|
27
|
+
),
|
|
28
|
+
)->toEqual(Some("CatalogDcbEventLog-a1b2c3"))
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
testSync("returns None for an ARN that is not a table stream", () =>
|
|
32
|
+
expect(Ops.tableNameFromEventSourceArn("arn:aws:sqs:eu-west-1:123456789012:SomeQueue"))->toEqual(
|
|
33
|
+
None,
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe("Upload_Claim_S3_Ops.keyOfRef", () => {
|
|
39
|
+
testSync("strips the leading slash the mint side writes", () =>
|
|
40
|
+
expect(Ops.keyOfRef("/Catalog/productImages/sub/uuid/p.png"))->toBe(
|
|
41
|
+
"Catalog/productImages/sub/uuid/p.png",
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
testSync("leaves an already-bare key alone", () =>
|
|
46
|
+
expect(Ops.keyOfRef("Catalog/productImages/sub/uuid/p.png"))->toBe(
|
|
47
|
+
"Catalog/productImages/sub/uuid/p.png",
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe("Upload_Claim_S3_Ops.refsOfField", () => {
|
|
53
|
+
let single: Ops.refField = {field: "imageUrl", many: false, store: "Catalog.productImages"}
|
|
54
|
+
let multi: Ops.refField = {field: "imageUrls", many: true, store: "Catalog.productImages"}
|
|
55
|
+
let ref1 = "/Catalog/productImages/sub/one/p.png"
|
|
56
|
+
let ref2 = "/Catalog/productImages/sub/two/q.png"
|
|
57
|
+
|
|
58
|
+
testSync("reads one ref from a string field", () =>
|
|
59
|
+
expect(
|
|
60
|
+
Ops.refsOfField(~data=Dict.fromArray([("imageUrl", JSON.Encode.string(ref1))]), single),
|
|
61
|
+
)->toEqual([ref1])
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
testSync("reads every ref from an array field", () =>
|
|
65
|
+
expect(
|
|
66
|
+
Ops.refsOfField(
|
|
67
|
+
~data=Dict.fromArray([
|
|
68
|
+
("imageUrls", JSON.Encode.array([JSON.Encode.string(ref1), JSON.Encode.string(ref2)])),
|
|
69
|
+
]),
|
|
70
|
+
multi,
|
|
71
|
+
),
|
|
72
|
+
)->toEqual([ref1, ref2])
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// `StorageRef` admits "" as the "no object" sentinel, so a present-but-empty
|
|
76
|
+
// field must cost no S3 call rather than resolving to the store's root.
|
|
77
|
+
testSync("ignores the empty-string sentinel", () =>
|
|
78
|
+
expect(
|
|
79
|
+
Ops.refsOfField(~data=Dict.fromArray([("imageUrl", JSON.Encode.string(""))]), single),
|
|
80
|
+
)->toEqual([])
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
testSync("ignores an absent field", () =>
|
|
84
|
+
expect(Ops.refsOfField(~data=Dict.make(), single))->toEqual([])
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
// Arity is declared, not sniffed: a field declared single that arrives as an
|
|
88
|
+
// array (or the reverse) is a schema and a deploy disagreeing, and reading it
|
|
89
|
+
// anyway would mean guessing which one is right.
|
|
90
|
+
testSync("a single-arity field ignores an array value", () =>
|
|
91
|
+
expect(
|
|
92
|
+
Ops.refsOfField(
|
|
93
|
+
~data=Dict.fromArray([("imageUrl", JSON.Encode.array([JSON.Encode.string(ref1)]))]),
|
|
94
|
+
single,
|
|
95
|
+
),
|
|
96
|
+
)->toEqual([])
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
testSync("a multi-arity field ignores a bare string value", () =>
|
|
100
|
+
expect(
|
|
101
|
+
Ops.refsOfField(~data=Dict.fromArray([("imageUrls", JSON.Encode.string(ref1))]), multi),
|
|
102
|
+
)->toEqual([])
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
testSync("ignores non-string elements of an array field", () =>
|
|
106
|
+
expect(
|
|
107
|
+
Ops.refsOfField(
|
|
108
|
+
~data=Dict.fromArray([
|
|
109
|
+
("imageUrls", JSON.Encode.array([JSON.Encode.string(ref1), JSON.Encode.int(7)])),
|
|
110
|
+
]),
|
|
111
|
+
multi,
|
|
112
|
+
),
|
|
113
|
+
)->toEqual([ref1])
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe("Upload_Claim_S3_Ops.claim", () => {
|
|
118
|
+
// The tag key is the contract between three separately-deployed things: the
|
|
119
|
+
// presigned PUT that writes it, this component that removes it, and the
|
|
120
|
+
// lifecycle rule that expires what still carries it. A drift in any one of
|
|
121
|
+
// them is silent — a rule filtered on a key nobody writes matches nothing, and
|
|
122
|
+
// a claimer stripping the wrong key leaves every object tagged.
|
|
123
|
+
testSync("removes the same tag the mint side writes", () =>
|
|
124
|
+
expect(Upload_PendingTag.key)->toBe("reventless:pending")
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
// The presigned URL carries `Tagging` as a URL-encoded query string, so the
|
|
128
|
+
// colon has to survive one decode by S3 to land as the key above.
|
|
129
|
+
testSync("the presign tagging parameter decodes to that key and value", () =>
|
|
130
|
+
expect(Upload_PendingTag.putObjectTagging)->toBe("reventless%3Apending=true")
|
|
131
|
+
)
|
|
132
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Upload_PendingTag$ReventlessAws from "../src/adapter/Upload/Upload_PendingTag.res.mjs";
|
|
4
|
+
import * as Upload_Claim_S3_Ops$ReventlessAws from "../src/adapter/Upload/Upload_Claim_S3_Ops.res.mjs";
|
|
5
|
+
|
|
6
|
+
globalThis.describe("Upload_Claim_S3_Ops.tableNameFromEventSourceArn", () => {
|
|
7
|
+
globalThis.test("reads the table name out of a stream ARN", () => {
|
|
8
|
+
globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.tableNameFromEventSourceArn("arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-a1b2c3/stream/2026-08-02T00:00:00.000")).toEqual("CatalogDcbEventLog-a1b2c3");
|
|
9
|
+
});
|
|
10
|
+
globalThis.test("returns None for an ARN that is not a table stream", () => {
|
|
11
|
+
globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.tableNameFromEventSourceArn("arn:aws:sqs:eu-west-1:123456789012:SomeQueue")).toEqual(undefined);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
globalThis.describe("Upload_Claim_S3_Ops.keyOfRef", () => {
|
|
16
|
+
globalThis.test("strips the leading slash the mint side writes", () => {
|
|
17
|
+
globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.keyOfRef("/Catalog/productImages/sub/uuid/p.png")).toBe("Catalog/productImages/sub/uuid/p.png");
|
|
18
|
+
});
|
|
19
|
+
globalThis.test("leaves an already-bare key alone", () => {
|
|
20
|
+
globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.keyOfRef("Catalog/productImages/sub/uuid/p.png")).toBe("Catalog/productImages/sub/uuid/p.png");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
globalThis.describe("Upload_Claim_S3_Ops.refsOfField", () => {
|
|
25
|
+
let single = {
|
|
26
|
+
field: "imageUrl",
|
|
27
|
+
many: false,
|
|
28
|
+
store: "Catalog.productImages"
|
|
29
|
+
};
|
|
30
|
+
let multi = {
|
|
31
|
+
field: "imageUrls",
|
|
32
|
+
many: true,
|
|
33
|
+
store: "Catalog.productImages"
|
|
34
|
+
};
|
|
35
|
+
let ref1 = "/Catalog/productImages/sub/one/p.png";
|
|
36
|
+
let ref2 = "/Catalog/productImages/sub/two/q.png";
|
|
37
|
+
globalThis.test("reads one ref from a string field", () => {
|
|
38
|
+
globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.refsOfField(Object.fromEntries([[
|
|
39
|
+
"imageUrl",
|
|
40
|
+
ref1
|
|
41
|
+
]]), single)).toEqual([ref1]);
|
|
42
|
+
});
|
|
43
|
+
globalThis.test("reads every ref from an array field", () => {
|
|
44
|
+
globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.refsOfField(Object.fromEntries([[
|
|
45
|
+
"imageUrls",
|
|
46
|
+
[
|
|
47
|
+
ref1,
|
|
48
|
+
ref2
|
|
49
|
+
]
|
|
50
|
+
]]), multi)).toEqual([
|
|
51
|
+
ref1,
|
|
52
|
+
ref2
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
globalThis.test("ignores the empty-string sentinel", () => {
|
|
56
|
+
globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.refsOfField(Object.fromEntries([[
|
|
57
|
+
"imageUrl",
|
|
58
|
+
""
|
|
59
|
+
]]), single)).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
globalThis.test("ignores an absent field", () => {
|
|
62
|
+
globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.refsOfField({}, single)).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
globalThis.test("a single-arity field ignores an array value", () => {
|
|
65
|
+
globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.refsOfField(Object.fromEntries([[
|
|
66
|
+
"imageUrl",
|
|
67
|
+
[ref1]
|
|
68
|
+
]]), single)).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
globalThis.test("a multi-arity field ignores a bare string value", () => {
|
|
71
|
+
globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.refsOfField(Object.fromEntries([[
|
|
72
|
+
"imageUrls",
|
|
73
|
+
ref1
|
|
74
|
+
]]), multi)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
globalThis.test("ignores non-string elements of an array field", () => {
|
|
77
|
+
globalThis.expect(Upload_Claim_S3_Ops$ReventlessAws.refsOfField(Object.fromEntries([[
|
|
78
|
+
"imageUrls",
|
|
79
|
+
[
|
|
80
|
+
ref1,
|
|
81
|
+
7
|
|
82
|
+
]
|
|
83
|
+
]]), multi)).toEqual([ref1]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
globalThis.describe("Upload_Claim_S3_Ops.claim", () => {
|
|
88
|
+
globalThis.test("removes the same tag the mint side writes", () => {
|
|
89
|
+
globalThis.expect(Upload_PendingTag$ReventlessAws.key).toBe("reventless:pending");
|
|
90
|
+
});
|
|
91
|
+
globalThis.test("the presign tagging parameter decodes to that key and value", () => {
|
|
92
|
+
globalThis.expect(Upload_PendingTag$ReventlessAws.putObjectTagging).toBe("reventless%3Apending=true");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
let Ops;
|
|
97
|
+
|
|
98
|
+
export {
|
|
99
|
+
Ops,
|
|
100
|
+
}
|
|
101
|
+
/* Not a pure module */
|