@reventlessdev/reventless-aws 3.0.0-alpha.252 → 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 +28 -0
- package/package.json +8 -8
- package/src/Platform.res +159 -21
- package/src/Platform.res.mjs +120 -8
- 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/Task/TaskBucket_S3.res +11 -0
- package/src/adapter/Task/TaskBucket_S3.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 +17 -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 +62 -9
- package/src/util/Util_StoreLayout.res.mjs +20 -2
- 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 +79 -3
- package/tests/Util_StoreLayoutTest.res.mjs +47 -2
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S3$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/S3.res.mjs";
|
|
4
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
6
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
7
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
8
|
+
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
9
|
+
import * as ClientS3 from "@aws-sdk/client-s3";
|
|
10
|
+
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
11
|
+
import * as DynamoDb_Util_Helpers$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/DynamoDb_Util_Helpers.res.mjs";
|
|
12
|
+
import * as Upload_PendingTag$ReventlessAws from "./Upload_PendingTag.res.mjs";
|
|
13
|
+
|
|
14
|
+
function getEnv(k) {
|
|
15
|
+
let v = process.env[k];
|
|
16
|
+
if (v !== undefined && v !== "") {
|
|
17
|
+
return v;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseEnvObject(k) {
|
|
22
|
+
let match = Stdlib_Option.map(getEnv(k), s => JSON.parse(s));
|
|
23
|
+
if (match !== undefined) {
|
|
24
|
+
if (typeof match === "object" && match !== null && !Array.isArray(match)) {
|
|
25
|
+
return match;
|
|
26
|
+
} else {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function decodeStore(json) {
|
|
35
|
+
if (typeof json !== "object" || json === null || Array.isArray(json)) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
let match = Stdlib_Option.flatMap(json["bucket"], Stdlib_JSON.Decode.string);
|
|
39
|
+
let match$1 = Stdlib_Option.flatMap(json["prefix"], Stdlib_JSON.Decode.string);
|
|
40
|
+
if (match !== undefined && match$1 !== undefined) {
|
|
41
|
+
return {
|
|
42
|
+
bucket: match,
|
|
43
|
+
prefix: match$1
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let stores = Object.fromEntries(Stdlib_Array.filterMap(Object.entries(parseEnvObject("UPLOAD_STORES")), param => {
|
|
49
|
+
let k = param[0];
|
|
50
|
+
return Stdlib_Option.map(decodeStore(param[1]), c => [
|
|
51
|
+
k,
|
|
52
|
+
c
|
|
53
|
+
]);
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
function decodeRefField(json) {
|
|
57
|
+
if (typeof json !== "object" || json === null || Array.isArray(json)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
let match = Stdlib_Option.flatMap(json["field"], Stdlib_JSON.Decode.string);
|
|
61
|
+
let match$1 = Stdlib_Option.flatMap(json["store"], Stdlib_JSON.Decode.string);
|
|
62
|
+
if (match !== undefined && match$1 !== undefined) {
|
|
63
|
+
return {
|
|
64
|
+
field: match,
|
|
65
|
+
many: Primitive_object.equal(Stdlib_Option.flatMap(json["arity"], Stdlib_JSON.Decode.string), "many"),
|
|
66
|
+
store: match$1
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function decodeEventFields(json) {
|
|
72
|
+
if (typeof json === "object" && json !== null && !Array.isArray(json)) {
|
|
73
|
+
return Object.fromEntries(Object.entries(json).map(param => {
|
|
74
|
+
let v = param[1];
|
|
75
|
+
let tmp;
|
|
76
|
+
tmp = Array.isArray(v) ? Stdlib_Array.filterMap(v, decodeRefField) : [];
|
|
77
|
+
return [
|
|
78
|
+
param[0],
|
|
79
|
+
tmp
|
|
80
|
+
];
|
|
81
|
+
}));
|
|
82
|
+
} else {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let refFieldsByTable = Object.fromEntries(Object.entries(parseEnvObject("CLAIM_REF_FIELDS")).map(param => [
|
|
88
|
+
param[0],
|
|
89
|
+
decodeEventFields(param[1])
|
|
90
|
+
]));
|
|
91
|
+
|
|
92
|
+
function tableNameFromEventSourceArn(arn) {
|
|
93
|
+
let parts = arn.split("/");
|
|
94
|
+
let match = parts[0];
|
|
95
|
+
let match$1 = parts[1];
|
|
96
|
+
let match$2 = parts[2];
|
|
97
|
+
if (match !== undefined && match$1 !== undefined && match$2 !== undefined && match$2 === "stream" && match.endsWith(":table")) {
|
|
98
|
+
return match$1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function refsOfField(data, field) {
|
|
103
|
+
let match = data[field.field];
|
|
104
|
+
if (match === undefined) {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
if (Array.isArray(match)) {
|
|
108
|
+
if (field.many) {
|
|
109
|
+
return Stdlib_Array.filterMap(match, v => {
|
|
110
|
+
if (typeof v === "string" && v !== "") {
|
|
111
|
+
return v;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
} else {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
switch (typeof match) {
|
|
119
|
+
case "string" :
|
|
120
|
+
if (!field.many && match !== "") {
|
|
121
|
+
return [match];
|
|
122
|
+
} else {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
default:
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function keyOfRef(storageRef) {
|
|
131
|
+
if (storageRef.startsWith("/")) {
|
|
132
|
+
return storageRef.slice(1, storageRef.length);
|
|
133
|
+
} else {
|
|
134
|
+
return storageRef;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resolveTarget(store, storageRef) {
|
|
139
|
+
let match = stores[store];
|
|
140
|
+
if (match === undefined) {
|
|
141
|
+
return {
|
|
142
|
+
TAG: "Error",
|
|
143
|
+
_0: `unknown_store ` + store
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
let key = keyOfRef(storageRef);
|
|
147
|
+
if (key.startsWith(match.prefix + `/`)) {
|
|
148
|
+
return {
|
|
149
|
+
TAG: "Ok",
|
|
150
|
+
_0: {
|
|
151
|
+
bucket: match.bucket,
|
|
152
|
+
key: key
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
} else {
|
|
156
|
+
return {
|
|
157
|
+
TAG: "Error",
|
|
158
|
+
_0: `not_in_store ` + store + ` ` + storageRef
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function claim(bucket, key) {
|
|
164
|
+
try {
|
|
165
|
+
let current = await S3$AwsSdk.GetObjectTaggingCommand.send(new ClientS3.GetObjectTaggingCommand({
|
|
166
|
+
Bucket: bucket,
|
|
167
|
+
Key: key
|
|
168
|
+
}));
|
|
169
|
+
let tagSet = Stdlib_Option.getOr(current.TagSet, []);
|
|
170
|
+
let remaining = tagSet.filter(t => t.Key !== Upload_PendingTag$ReventlessAws.key);
|
|
171
|
+
if (remaining.length !== tagSet.length) {
|
|
172
|
+
await S3$AwsSdk.PutObjectTaggingCommand.send(new ClientS3.PutObjectTaggingCommand({
|
|
173
|
+
Bucket: bucket,
|
|
174
|
+
Key: key,
|
|
175
|
+
Tagging: {
|
|
176
|
+
TagSet: remaining
|
|
177
|
+
}
|
|
178
|
+
}));
|
|
179
|
+
return;
|
|
180
|
+
} else {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
} catch (raw_exn) {
|
|
184
|
+
let exn = Primitive_exceptions.internalToException(raw_exn);
|
|
185
|
+
let match = Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.name);
|
|
186
|
+
if (match !== undefined) {
|
|
187
|
+
switch (match) {
|
|
188
|
+
case "NoSuchKey" :
|
|
189
|
+
case "NotFound" :
|
|
190
|
+
return;
|
|
191
|
+
default:
|
|
192
|
+
throw exn;
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
throw exn;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function targetsOfRecord(record) {
|
|
201
|
+
let match = Stdlib_Option.flatMap(tableNameFromEventSourceArn(record.eventSourceARN), t => refFieldsByTable[t]);
|
|
202
|
+
let match$1 = Stdlib_Option.flatMap(record.dynamodb, d => d.NewImage);
|
|
203
|
+
if (match === undefined) {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
if (match$1 === undefined) {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
let row = DynamoDb_Util_Helpers$AwsSdk.unmarshallDict(undefined, match$1);
|
|
210
|
+
let match$2 = Stdlib_Option.flatMap(row["event"], Stdlib_JSON.Decode.string);
|
|
211
|
+
let match$3 = row["data"];
|
|
212
|
+
if (match$2 !== undefined) {
|
|
213
|
+
if (match$3 !== undefined) {
|
|
214
|
+
if (typeof match$3 === "object" && match$3 !== null && !Array.isArray(match$3)) {
|
|
215
|
+
return Stdlib_Option.getOr(match[match$2], []).flatMap(field => Stdlib_Array.filterMap(refsOfField(match$3, field), storageRef => {
|
|
216
|
+
let target = resolveTarget(field.store, storageRef);
|
|
217
|
+
if (target.TAG === "Ok") {
|
|
218
|
+
return target._0;
|
|
219
|
+
}
|
|
220
|
+
console.error("Upload_Claim: refusing ref outside a declared store —", target._0);
|
|
221
|
+
}));
|
|
222
|
+
} else {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function handler(event) {
|
|
234
|
+
let targets = event.Records.filter(r => r.eventName === "INSERT").flatMap(targetsOfRecord);
|
|
235
|
+
return await Stdlib_Array.reduce(targets, Promise.resolve(), (acc, param) => {
|
|
236
|
+
let key = param.key;
|
|
237
|
+
let bucket = param.bucket;
|
|
238
|
+
return acc.then(() => claim(bucket, key));
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let S3;
|
|
243
|
+
|
|
244
|
+
export {
|
|
245
|
+
S3,
|
|
246
|
+
getEnv,
|
|
247
|
+
parseEnvObject,
|
|
248
|
+
decodeStore,
|
|
249
|
+
stores,
|
|
250
|
+
decodeRefField,
|
|
251
|
+
decodeEventFields,
|
|
252
|
+
refFieldsByTable,
|
|
253
|
+
tableNameFromEventSourceArn,
|
|
254
|
+
refsOfField,
|
|
255
|
+
keyOfRef,
|
|
256
|
+
resolveTarget,
|
|
257
|
+
claim,
|
|
258
|
+
targetsOfRecord,
|
|
259
|
+
handler,
|
|
260
|
+
}
|
|
261
|
+
/* stores Not a pure module */
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The tag that makes "nobody has committed a reference to this object yet"
|
|
2
|
+
// visible to S3 itself.
|
|
3
|
+
//
|
|
4
|
+
// One definition, three readers, and they must agree exactly or the mechanism
|
|
5
|
+
// deletes live data:
|
|
6
|
+
// - the mint side writes it on the presigned PUT (`Upload_Presign_S3_Ops`),
|
|
7
|
+
// - the claim side removes it when a committed event carries the ref
|
|
8
|
+
// (`Upload_Claim_S3_Ops`),
|
|
9
|
+
// - the bucket's lifecycle rule expires what still carries it
|
|
10
|
+
// (`Capability_ObjectStore_S3`).
|
|
11
|
+
// A rule filtered on a key the mint side never writes matches *nothing*, which
|
|
12
|
+
// fails safe; a claim side that strips a different key leaves every object
|
|
13
|
+
// tagged, which does not. Hence one module rather than three string literals.
|
|
14
|
+
//
|
|
15
|
+
// Runtime-pure and Pulumi-free on purpose: two of the three readers are Lambda
|
|
16
|
+
// handlers shipped as EntryPoint modules, so a deploy-time import here would
|
|
17
|
+
// leak `@pulumi/pulumi` into their cold-start graph.
|
|
18
|
+
//
|
|
19
|
+
// The tag's meaning is deliberately "not yet claimed", never "safe to delete".
|
|
20
|
+
// Nothing reads it as permission; the lifecycle rule adds an age condition of
|
|
21
|
+
// its own, and it is opt-in per store.
|
|
22
|
+
|
|
23
|
+
/** Tag key. `:` is a legal S3 tag-key character, and the `reventless:` prefix
|
|
24
|
+
keeps the framework's tags from colliding with a deployment's own. */
|
|
25
|
+
let key = "reventless:pending"
|
|
26
|
+
|
|
27
|
+
/** Tag value. A single-valued tag: the key's presence is the fact, and the
|
|
28
|
+
value exists only because S3 tags are pairs. */
|
|
29
|
+
let value = "true"
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
The `Tagging` parameter of a `PutObject`, in the URL-encoded query-string form
|
|
33
|
+
S3 specifies for that header.
|
|
34
|
+
|
|
35
|
+
The colon is percent-encoded here because the value is parsed as a query string
|
|
36
|
+
by S3, so its key/value separators are the only characters that may appear raw.
|
|
37
|
+
The SDK hoists this into the presigned URL's query string (verified against a
|
|
38
|
+
live bucket: a PUT that sends nothing but `Content-Type` still lands the tag),
|
|
39
|
+
so the caller needs no cooperation and cannot opt out.
|
|
40
|
+
*/
|
|
41
|
+
let putObjectTagging = `${key->encodeURIComponent}=${value->encodeURIComponent}`
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
let key = "reventless:pending";
|
|
5
|
+
|
|
6
|
+
let value = "true";
|
|
7
|
+
|
|
8
|
+
let putObjectTagging = encodeURIComponent(key) + `=` + encodeURIComponent(value);
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
key,
|
|
12
|
+
value,
|
|
13
|
+
putObjectTagging,
|
|
14
|
+
}
|
|
15
|
+
/* putObjectTagging Not a pure module */
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// There is no Function URL and no anonymous surface: authentication is the platform
|
|
12
12
|
// API's Cognito authorizer, and the verified caller identity reaches the handler in
|
|
13
13
|
// the resolver payload (see the JS resolver code below). This is route B of
|
|
14
|
-
// [docs/plans/upload-release-path.md]; the anonymous Function URL and the unverified
|
|
14
|
+
// [docs/plans/done/upload-release-path.md]; the anonymous Function URL and the unverified
|
|
15
15
|
// `decodeJwtSub` the mint side used to carry are gone.
|
|
16
16
|
//
|
|
17
17
|
// Why an EntryPoint and not a Pulumi `CallbackFunction`: the handler needs the AWS
|
|
@@ -106,6 +106,11 @@ let make = (
|
|
|
106
106
|
// `GetObject` backs the `HeadObject` the age check reads. One Lambda covers every
|
|
107
107
|
// declared store (route B1) — but its reach is still the union of those prefixes,
|
|
108
108
|
// not a wildcard, so it cannot touch a key outside a declared store.
|
|
109
|
+
//
|
|
110
|
+
// `PutObjectTagging` is what lets the presigned PUT carry the pending tag: a
|
|
111
|
+
// presigned request runs with the *signer's* permissions, so tag-on-put fails
|
|
112
|
+
// with AccessDenied without it — and an untagged object is one the sweep can
|
|
113
|
+
// never reach.
|
|
109
114
|
let _policy =
|
|
110
115
|
resolvedStores->Pulumi.Output.apply(list => {
|
|
111
116
|
let arns = list->Array.map(((_, bucket, prefix)) => `arn:aws:s3:::${bucket}/${prefix}/*`)
|
|
@@ -124,7 +129,12 @@ let make = (
|
|
|
124
129
|
{
|
|
125
130
|
sid: "AllowUploadObjectAccess",
|
|
126
131
|
effect: Allow,
|
|
127
|
-
actions: Actions([
|
|
132
|
+
actions: Actions([
|
|
133
|
+
"s3:PutObject",
|
|
134
|
+
"s3:PutObjectTagging",
|
|
135
|
+
"s3:DeleteObject",
|
|
136
|
+
"s3:GetObject",
|
|
137
|
+
]),
|
|
128
138
|
resources: Resources(arns),
|
|
129
139
|
},
|
|
130
140
|
],
|
|
@@ -25,6 +25,7 @@ type putObjectInput = {
|
|
|
25
25
|
@as("Bucket") bucket: string,
|
|
26
26
|
@as("Key") key: string,
|
|
27
27
|
@as("ContentType") contentType?: string,
|
|
28
|
+
@as("Tagging") tagging?: string,
|
|
28
29
|
}
|
|
29
30
|
type putObjectCommand
|
|
30
31
|
|
|
@@ -123,6 +124,10 @@ type releaseOutcome = Released | Refused(string)
|
|
|
123
124
|
// Key must sit under this store's served prefix (`not_in_store`) and under the
|
|
124
125
|
// caller's own identity segment within it (`not_yours`); an empty `sub` is
|
|
125
126
|
// `unauthenticated` (the authorizer should have refused first — this is a guard).
|
|
127
|
+
//
|
|
128
|
+
// One prefix, not a set: changing a store's prefix strands the refs already
|
|
129
|
+
// minted under the old one, so a prefix change is a breaking change resolved by
|
|
130
|
+
// emptying the store and re-seeding, never by carrying both prefixes forever.
|
|
126
131
|
let scopeCheck = (~key: string, ~sub: string, ~servedPrefix: string): result<unit, string> =>
|
|
127
132
|
if sub == "" {
|
|
128
133
|
Error("unauthenticated")
|
|
@@ -221,7 +226,18 @@ let handlePresign = async (
|
|
|
221
226
|
// served prefix so the CloudFront `{prefix}/*` behavior fronts it, and namespaced
|
|
222
227
|
// by the verified `sub` so the release rule can tell one caller's objects apart.
|
|
223
228
|
let key = `${servedPrefix}/${sub}/${NodeCrypto.randomUUID()}/${fileName}`
|
|
224
|
-
|
|
229
|
+
// The object is provisional from the moment the bytes land: it carries the
|
|
230
|
+
// pending tag until a committed event referencing it strips it (see
|
|
231
|
+
// `Upload_Claim_S3_Ops`). Written into the signature here, not asked of the
|
|
232
|
+
// caller — the SDK hoists `x-amz-tagging` into the presigned URL's query
|
|
233
|
+
// string, so a client that PUTs exactly as it always has still gets tagged
|
|
234
|
+
// bytes, and cannot opt out.
|
|
235
|
+
let command = makePutObjectCommand({
|
|
236
|
+
bucket,
|
|
237
|
+
key,
|
|
238
|
+
contentType: ?args.contentType,
|
|
239
|
+
tagging: Upload_PendingTag.putObjectTagging,
|
|
240
|
+
})
|
|
225
241
|
let uploadUrl = await getSignedUrl(client, command, {expiresIn: 300})
|
|
226
242
|
// Same-origin relative ref `/{key}`: the served bucket is fronted read-only by the
|
|
227
243
|
// UI's own CloudFront distribution under `{prefix}/*`, so a command stores this
|
|
@@ -10,6 +10,7 @@ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
|
10
10
|
import * as ClientS3 from "@aws-sdk/client-s3";
|
|
11
11
|
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
12
12
|
import * as S3RequestPresigner from "@aws-sdk/s3-request-presigner";
|
|
13
|
+
import * as Upload_PendingTag$ReventlessAws from "./Upload_PendingTag.res.mjs";
|
|
13
14
|
|
|
14
15
|
function getEnv(k) {
|
|
15
16
|
let v = process.env[k];
|
|
@@ -175,7 +176,8 @@ async function handlePresign(client, bucket, servedPrefix, sub, args) {
|
|
|
175
176
|
let command = new ClientS3.PutObjectCommand({
|
|
176
177
|
Bucket: bucket,
|
|
177
178
|
Key: key,
|
|
178
|
-
ContentType: args.contentType
|
|
179
|
+
ContentType: args.contentType,
|
|
180
|
+
Tagging: Upload_PendingTag$ReventlessAws.putObjectTagging
|
|
179
181
|
});
|
|
180
182
|
let uploadUrl = await S3RequestPresigner.getSignedUrl(client, command, {
|
|
181
183
|
expiresIn: 300
|
|
@@ -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)
|