@reventlessdev/reventless-aws 3.0.0-alpha.238 → 3.0.0-alpha.240
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 +6 -6
- package/src/Platform.res +173 -15
- package/src/Platform.res.mjs +220 -32
- package/src/adapter/Upload/Upload_Presign_S3.res +19 -3
- package/src/adapter/Upload/Upload_Presign_S3.res.mjs +9 -9
- package/src/capability/Capability_ObjectStore_S3.res +69 -7
- package/src/capability/Capability_ObjectStore_S3.res.mjs +32 -6
- package/src/plugin/stack/Plugin_Stack.res +19 -6
- package/src/plugin/stack/Plugin_Stack.res.mjs +6 -6
- package/src/util/Util_HostUiDomain.res +17 -0
- package/src/util/Util_HostUiDomain.res.mjs +8 -1
- package/src/util/Util_StoreLayout.res +104 -0
- package/src/util/Util_StoreLayout.res.mjs +42 -0
- package/tests/Util_StoreLayoutTest.res +115 -0
- package/tests/Util_StoreLayoutTest.res.mjs +69 -0
|
@@ -42,9 +42,19 @@ let make = (
|
|
|
42
42
|
// bucket's CloudFront `{prefix}/*` behavior so the returned `/{key}` ref
|
|
43
43
|
// resolves.
|
|
44
44
|
~servedPrefix: string=defaultServedPrefix,
|
|
45
|
+
// Resource-name stem. Defaults to the single-service name this adapter had
|
|
46
|
+
// when a deployment could only have one store, so the existing service keeps
|
|
47
|
+
// its identity; declared stores each pass their own, because there is one
|
|
48
|
+
// service per store.
|
|
49
|
+
//
|
|
50
|
+
// One service per store rather than one service with wildcard write across
|
|
51
|
+
// stores: a single wide-scoped presigner reintroduces exactly the blast radius
|
|
52
|
+
// the per-store split exists to remove, and it does so invisibly — every
|
|
53
|
+
// upload still works.
|
|
54
|
+
~name: string="UploadPresignService",
|
|
45
55
|
~opts=?,
|
|
46
56
|
): serviceOutputs => {
|
|
47
|
-
let serviceName =
|
|
57
|
+
let serviceName = name
|
|
48
58
|
let opts =
|
|
49
59
|
opts->Option.map(ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions)
|
|
50
60
|
|
|
@@ -61,12 +71,18 @@ let make = (
|
|
|
61
71
|
)
|
|
62
72
|
|
|
63
73
|
// CloudWatch Logs (so failures are observable) plus least-privilege
|
|
64
|
-
// `s3:PutObject` on
|
|
74
|
+
// `s3:PutObject` on this store's own keys.
|
|
75
|
+
//
|
|
76
|
+
// Scoped to `{bucket}/{servedPrefix}/*`, not to the whole bucket. Because keys
|
|
77
|
+
// are rooted at the store's prefix in *both* layouts, this one expression is
|
|
78
|
+
// least-privilege in both: a dedicated bucket's service still cannot write
|
|
79
|
+
// outside its prefix, and a shared bucket's services cannot write into each
|
|
80
|
+
// other's stores. The layouts stay one model — even the policy does not fork.
|
|
65
81
|
let _policy =
|
|
66
82
|
bucketName
|
|
67
83
|
->Pulumi.Output.fromInput
|
|
68
84
|
->Pulumi.Output.apply(b => {
|
|
69
|
-
let arn = `arn:aws:s3:::${b}/*`
|
|
85
|
+
let arn = `arn:aws:s3:::${b}/${servedPrefix}/*`
|
|
70
86
|
let _ = IAM.RolePolicy.make(
|
|
71
87
|
~name=`${serviceName}Policy`,
|
|
72
88
|
~args={
|
|
@@ -14,16 +14,16 @@ import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/
|
|
|
14
14
|
|
|
15
15
|
let defaultServedPrefix = "uploads";
|
|
16
16
|
|
|
17
|
-
function make(bucketName, corsOriginsOpt, servedPrefixOpt, opts) {
|
|
17
|
+
function make(bucketName, corsOriginsOpt, servedPrefixOpt, nameOpt, opts) {
|
|
18
18
|
let corsOrigins = corsOriginsOpt !== undefined ? corsOriginsOpt : ["*"];
|
|
19
19
|
let servedPrefix = servedPrefixOpt !== undefined ? servedPrefixOpt : defaultServedPrefix;
|
|
20
|
-
let
|
|
20
|
+
let name = nameOpt !== undefined ? nameOpt : "UploadPresignService";
|
|
21
21
|
let opts$1 = Stdlib_Option.map(opts, Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions);
|
|
22
|
-
let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(
|
|
22
|
+
let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name, Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name, "Platform", "Identity", "Platform", undefined, undefined, undefined, undefined), opts$1);
|
|
23
23
|
bucketName.apply(b => {
|
|
24
|
-
let arn = `arn:aws:s3:::` + b + `/*`;
|
|
25
|
-
new (Aws.iam.RolePolicy)(
|
|
26
|
-
policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined,
|
|
24
|
+
let arn = `arn:aws:s3:::` + b + `/` + servedPrefix + `/*`;
|
|
25
|
+
new (Aws.iam.RolePolicy)(name + `Policy`, {
|
|
26
|
+
policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + `Policy`, [
|
|
27
27
|
{
|
|
28
28
|
Sid: "AllowLambdaLogging",
|
|
29
29
|
Effect: "Allow",
|
|
@@ -50,7 +50,7 @@ function make(bucketName, corsOriginsOpt, servedPrefixOpt, opts) {
|
|
|
50
50
|
]]);
|
|
51
51
|
let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs", packageDirs, undefined);
|
|
52
52
|
let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
|
|
53
|
-
let lambda = new (Aws.lambda.Function)(
|
|
53
|
+
let lambda = new (Aws.lambda.Function)(name, {
|
|
54
54
|
handler: "index.handler",
|
|
55
55
|
runtime: "nodejs22.x",
|
|
56
56
|
code: match.code,
|
|
@@ -58,7 +58,7 @@ function make(bucketName, corsOriginsOpt, servedPrefixOpt, opts) {
|
|
|
58
58
|
memorySize: 256,
|
|
59
59
|
timeout: 30,
|
|
60
60
|
layers: layers,
|
|
61
|
-
tags: AWS_Tags$ReventlessAws.make(
|
|
61
|
+
tags: AWS_Tags$ReventlessAws.make(name, "Platform", "Runtime", "Platform", undefined, undefined, undefined, undefined),
|
|
62
62
|
environment: {
|
|
63
63
|
variables: Object.fromEntries([
|
|
64
64
|
[
|
|
@@ -85,7 +85,7 @@ function make(bucketName, corsOriginsOpt, servedPrefixOpt, opts) {
|
|
|
85
85
|
},
|
|
86
86
|
sourceCodeHash: match.sourceCodeHash
|
|
87
87
|
}, opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
|
|
88
|
-
let functionUrl = new (Aws.lambda.FunctionUrl)(
|
|
88
|
+
let functionUrl = new (Aws.lambda.FunctionUrl)(name + `Url`, {
|
|
89
89
|
authorizationType: "NONE",
|
|
90
90
|
functionName: lambda.name,
|
|
91
91
|
cors: {
|
|
@@ -20,9 +20,20 @@
|
|
|
20
20
|
// both cost and delete semantics and belongs to a deployment's judgement rather
|
|
21
21
|
// than to a framework default.
|
|
22
22
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
23
|
+
// A store's lifecycle now follows a *declaration*, which changes the risk. A
|
|
24
|
+
// hand-written bucket could only be destroyed by editing a line of Pulumi; a
|
|
25
|
+
// declared one can be destroyed by renaming a field, because the last
|
|
26
|
+
// `@storageRef("productImages")` disappearing removes the requirement. Hence
|
|
27
|
+
// `~protect`, on by default and turned off only for stacks that are routinely
|
|
28
|
+
// torn down — see `Util_StoreLayout.protectionFor`, which decides that from
|
|
29
|
+
// disposability rather than from the store's layout.
|
|
30
|
+
//
|
|
31
|
+
// Not to be confused with the framework's other buckets: a **store** is
|
|
32
|
+
// declared by a field's type and holds values that events reference, so its
|
|
33
|
+
// objects outlive any single deploy. A **task bucket** (`TaskBucket_S3`) is
|
|
34
|
+
// wired by a slice and holds transient input, and the plugin-bundle and
|
|
35
|
+
// host-ui-bundle buckets are hosting substrate. Neither has a `@storageRef`
|
|
36
|
+
// field pointing at it, and neither is provisioned from a declaration.
|
|
26
37
|
|
|
27
38
|
open PulumiAws
|
|
28
39
|
|
|
@@ -39,24 +50,62 @@ let defaultCorsRules: S3.Bucket.corsRules = [
|
|
|
39
50
|
]
|
|
40
51
|
|
|
41
52
|
/** Create an object store bucket with framework attribution tags and an
|
|
42
|
-
all-true public-access block.
|
|
53
|
+
all-true public-access block.
|
|
54
|
+
|
|
55
|
+
`~keyPrefix` is the path this store's object keys are rooted at; it travels
|
|
56
|
+
in the returned record so the mint side and the serve side read one value
|
|
57
|
+
instead of restating it.
|
|
58
|
+
|
|
59
|
+
`~plugin` attributes the store to the plugin whose field declared it. The
|
|
60
|
+
bucket is created by the platform deploy — that is where the serving CDN and
|
|
61
|
+
the presign services live, and where a shared bucket has a single owner — so
|
|
62
|
+
ownership is expressed in the tags rather than in which stack ran `make`.
|
|
63
|
+
|
|
64
|
+
`~protect` (default on) blocks `pulumi destroy` and accidental replacement.
|
|
65
|
+
`~forceDestroy` is its counterpart for disposable stacks: a protected bucket
|
|
66
|
+
cannot be torn down, so a PR stack would leak exactly the buckets that
|
|
67
|
+
sharing exists to save. */
|
|
43
68
|
let make = (
|
|
44
69
|
~name: string,
|
|
70
|
+
~keyPrefix: string=Upload_Presign_S3.defaultServedPrefix,
|
|
71
|
+
~plugin: option<string>=?,
|
|
72
|
+
~protect: bool=true,
|
|
73
|
+
~forceDestroy: bool=false,
|
|
45
74
|
~corsRules: S3.Bucket.corsRules=defaultCorsRules,
|
|
46
75
|
~opts: option<Pulumi.CustomResourceOptions.t>=?,
|
|
47
76
|
): ReventlessInfra.Platform.objectStore => {
|
|
77
|
+
// A declared store is plugin substrate; the legacy hand-written store, which
|
|
78
|
+
// names no plugin, stays platform-scoped so its tags are unchanged.
|
|
79
|
+
let (kind, scope) = switch plugin {
|
|
80
|
+
| Some(_) => (
|
|
81
|
+
ReventlessCore.ComponentType.Plugin,
|
|
82
|
+
ReventlessCore.ResourceAttribution.Scope.Plugin,
|
|
83
|
+
)
|
|
84
|
+
| None => (
|
|
85
|
+
ReventlessCore.ComponentType.Platform,
|
|
86
|
+
ReventlessCore.ResourceAttribution.Scope.Platform,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let opts: Pulumi.CustomResourceOptions.t = {
|
|
91
|
+
...opts->Option.getOr({}),
|
|
92
|
+
protect,
|
|
93
|
+
}
|
|
94
|
+
|
|
48
95
|
let bucket = S3.Bucket.make(
|
|
49
96
|
~name,
|
|
50
97
|
~args={
|
|
51
98
|
corsRules: corsRules->Pulumi.Input.make,
|
|
99
|
+
forceDestroy: forceDestroy->Pulumi.Input.make,
|
|
52
100
|
tags: AWS.Tags.make(
|
|
53
101
|
~name,
|
|
54
|
-
~kind
|
|
102
|
+
~kind,
|
|
55
103
|
~role=Other("ObjectStore"),
|
|
56
|
-
~scope
|
|
104
|
+
~scope,
|
|
105
|
+
~plugin?,
|
|
57
106
|
),
|
|
58
107
|
},
|
|
59
|
-
~opts
|
|
108
|
+
~opts,
|
|
60
109
|
)
|
|
61
110
|
|
|
62
111
|
// A served store is read through the UI's CDN via an origin access control,
|
|
@@ -80,5 +129,18 @@ let make = (
|
|
|
80
129
|
bucketId: bucket.id->Pulumi.Output.asInput,
|
|
81
130
|
bucketArn: bucket.arn->Pulumi.Output.asInput,
|
|
82
131
|
bucketRegionalDomainName: bucket.bucketRegionalDomainName->Pulumi.Output.asInput,
|
|
132
|
+
keyPrefix,
|
|
83
133
|
}
|
|
84
134
|
}
|
|
135
|
+
|
|
136
|
+
/** The same bucket, addressed under a different key prefix.
|
|
137
|
+
|
|
138
|
+
A shared-layout stack puts every declared store in one bucket, so the bucket
|
|
139
|
+
is created once and each store is that bucket seen through its own prefix.
|
|
140
|
+
Splitting this out keeps the memoisation of "one bucket per physical name"
|
|
141
|
+
separate from "one store per declaration", which are different cardinalities
|
|
142
|
+
the moment a layout shares. */
|
|
143
|
+
let underPrefix = (
|
|
144
|
+
store: ReventlessInfra.Platform.objectStore,
|
|
145
|
+
~keyPrefix: string,
|
|
146
|
+
): ReventlessInfra.Platform.objectStore => {...store, keyPrefix}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as Aws from "@pulumi/aws";
|
|
4
|
-
import * as
|
|
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_Presign_S3$ReventlessAws from "../adapter/Upload/Upload_Presign_S3.res.mjs";
|
|
6
7
|
|
|
7
8
|
let defaultCorsRules = [{
|
|
8
9
|
allowedHeaders: ["*"],
|
|
@@ -17,15 +18,28 @@ let defaultCorsRules = [{
|
|
|
17
18
|
maxAgeSeconds: 3000
|
|
18
19
|
}];
|
|
19
20
|
|
|
20
|
-
function make(name, corsRulesOpt, opts) {
|
|
21
|
+
function make(name, keyPrefixOpt, plugin, protectOpt, forceDestroyOpt, corsRulesOpt, opts) {
|
|
22
|
+
let keyPrefix = keyPrefixOpt !== undefined ? keyPrefixOpt : Upload_Presign_S3$ReventlessAws.defaultServedPrefix;
|
|
23
|
+
let protect = protectOpt !== undefined ? protectOpt : true;
|
|
24
|
+
let forceDestroy = forceDestroyOpt !== undefined ? forceDestroyOpt : false;
|
|
21
25
|
let corsRules = corsRulesOpt !== undefined ? corsRulesOpt : defaultCorsRules;
|
|
26
|
+
let match = plugin !== undefined ? [
|
|
27
|
+
"Plugin",
|
|
28
|
+
"Plugin"
|
|
29
|
+
] : [
|
|
30
|
+
"Platform",
|
|
31
|
+
"Platform"
|
|
32
|
+
];
|
|
33
|
+
let newrecord = {...Stdlib_Option.getOr(opts, {})};
|
|
34
|
+
newrecord.protect = protect;
|
|
22
35
|
let bucket = new (Aws.s3.Bucket)(name, {
|
|
23
36
|
corsRules: corsRules,
|
|
24
|
-
|
|
37
|
+
forceDestroy: forceDestroy,
|
|
38
|
+
tags: AWS_Tags$ReventlessAws.make(name, match[0], {
|
|
25
39
|
TAG: "Other",
|
|
26
40
|
_0: "ObjectStore"
|
|
27
|
-
},
|
|
28
|
-
},
|
|
41
|
+
}, match[1], undefined, undefined, plugin, undefined)
|
|
42
|
+
}, newrecord);
|
|
29
43
|
new (Aws.s3.BucketPublicAccessBlock)(name + "-pab", {
|
|
30
44
|
bucket: bucket.id,
|
|
31
45
|
blockPublicAcls: true,
|
|
@@ -37,12 +51,24 @@ function make(name, corsRulesOpt, opts) {
|
|
|
37
51
|
bucketName: bucket.bucket,
|
|
38
52
|
bucketId: bucket.id,
|
|
39
53
|
bucketArn: bucket.arn,
|
|
40
|
-
bucketRegionalDomainName: bucket.bucketRegionalDomainName
|
|
54
|
+
bucketRegionalDomainName: bucket.bucketRegionalDomainName,
|
|
55
|
+
keyPrefix: keyPrefix
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function underPrefix(store, keyPrefix) {
|
|
60
|
+
return {
|
|
61
|
+
bucketName: store.bucketName,
|
|
62
|
+
bucketId: store.bucketId,
|
|
63
|
+
bucketArn: store.bucketArn,
|
|
64
|
+
bucketRegionalDomainName: store.bucketRegionalDomainName,
|
|
65
|
+
keyPrefix: keyPrefix
|
|
41
66
|
};
|
|
42
67
|
}
|
|
43
68
|
|
|
44
69
|
export {
|
|
45
70
|
defaultCorsRules,
|
|
46
71
|
make,
|
|
72
|
+
underPrefix,
|
|
47
73
|
}
|
|
48
74
|
/* @pulumi/aws Not a pure module */
|
|
@@ -225,17 +225,26 @@ let makeUiBundleDistribution = (
|
|
|
225
225
|
// CachingOptimized policy is safe. Prepended to `orderedCacheBehaviors` so a
|
|
226
226
|
// served path is matched before the SPA-serving default behavior and never
|
|
227
227
|
// routed to the bundle bucket.
|
|
228
|
-
let servedOriginId = (
|
|
229
|
-
let servedCacheBehavior = (
|
|
228
|
+
let servedOriginId = (id: string): string => "served-" ++ id
|
|
229
|
+
let servedCacheBehavior = (
|
|
230
|
+
~prefix: string,
|
|
231
|
+
~originId: string,
|
|
232
|
+
): PulumiAws.CloudFront.Distribution.orderedCacheBehavior => {
|
|
230
233
|
pathPattern: Pulumi.Input.make(prefix ++ "/*"),
|
|
231
|
-
targetOriginId: Pulumi.Input.make(
|
|
234
|
+
targetOriginId: Pulumi.Input.make(originId),
|
|
232
235
|
viewerProtocolPolicy: Pulumi.Input.make("redirect-to-https"),
|
|
233
236
|
allowedMethods: Pulumi.Input.make(["GET", "HEAD"]),
|
|
234
237
|
cachedMethods: Pulumi.Input.make(["GET", "HEAD"]),
|
|
235
238
|
cachePolicyId: Pulumi.Input.make(cachingOptimizedPolicyId),
|
|
236
239
|
}
|
|
237
240
|
let orderedCacheBehaviors = Array.concat(
|
|
238
|
-
|
|
241
|
+
// One behavior per served prefix; several prefixes may share one origin when
|
|
242
|
+
// they live in the same bucket.
|
|
243
|
+
servedBuckets->Array.flatMap(sb =>
|
|
244
|
+
sb.prefixes->Array.map(prefix =>
|
|
245
|
+
servedCacheBehavior(~prefix, ~originId=servedOriginId(sb.id))
|
|
246
|
+
)
|
|
247
|
+
),
|
|
239
248
|
Array.concat(
|
|
240
249
|
[noCacheBehavior("/remoteEntry.js")],
|
|
241
250
|
spaFallback ? [noCacheBehavior("/" ++ indexDocument), noCacheBehavior("/config.json")] : [],
|
|
@@ -336,7 +345,7 @@ let makeUiBundleDistribution = (
|
|
|
336
345
|
// per-bucket read grant is the served bucket's own BucketPolicy below).
|
|
337
346
|
servedBuckets->Array.map(sb => {
|
|
338
347
|
PulumiAws.CloudFront.Distribution.domainName: sb.bucketRegionalDomainName,
|
|
339
|
-
originId: Pulumi.Input.make(servedOriginId(sb.
|
|
348
|
+
originId: Pulumi.Input.make(servedOriginId(sb.id)),
|
|
340
349
|
originAccessControlId: Pulumi.Input.make(oacId),
|
|
341
350
|
}),
|
|
342
351
|
)
|
|
@@ -432,9 +441,13 @@ let makeUiBundleDistribution = (
|
|
|
432
441
|
// object is fetchable at `https://<ui-domain>/{prefix}/<key>` while the bucket
|
|
433
442
|
// stays private (direct S3 GET is 403). Mirrors the bundle bucket policy above;
|
|
434
443
|
// the served bucket keeps its own all-true BucketPublicAccessBlock (app-owned).
|
|
444
|
+
//
|
|
445
|
+
// Exactly one policy per bucket — S3 permits no more. Grouping prefixes into
|
|
446
|
+
// one `servedBucket` is what makes that structural rather than a rule to
|
|
447
|
+
// remember.
|
|
435
448
|
servedBuckets->Array.forEach(sb => {
|
|
436
449
|
let _ = PulumiAws.S3.BucketPolicy.make(
|
|
437
|
-
~name=name ++ "-served-" ++ sb.
|
|
450
|
+
~name=name ++ "-served-" ++ sb.id ++ "-policy",
|
|
438
451
|
~args={
|
|
439
452
|
bucket: sb.bucketId,
|
|
440
453
|
policy: (sb.bucketArn->Pulumi.Output.fromInput, distribution.arn)
|
|
@@ -119,11 +119,11 @@ function makeUiBundleDistribution(pluginId, bundleVersion, assetsDir, spaFallbac
|
|
|
119
119
|
],
|
|
120
120
|
cachePolicyId: cachingDisabledPolicyId
|
|
121
121
|
});
|
|
122
|
-
let orderedCacheBehaviors = servedBuckets.
|
|
123
|
-
let
|
|
122
|
+
let orderedCacheBehaviors = servedBuckets.flatMap(sb => sb.prefixes.map(prefix => {
|
|
123
|
+
let originId = "served-" + sb.id;
|
|
124
124
|
return {
|
|
125
125
|
pathPattern: prefix + "/*",
|
|
126
|
-
targetOriginId:
|
|
126
|
+
targetOriginId: originId,
|
|
127
127
|
viewerProtocolPolicy: "redirect-to-https",
|
|
128
128
|
allowedMethods: [
|
|
129
129
|
"GET",
|
|
@@ -135,7 +135,7 @@ function makeUiBundleDistribution(pluginId, bundleVersion, assetsDir, spaFallbac
|
|
|
135
135
|
],
|
|
136
136
|
cachePolicyId: cachingOptimizedPolicyId
|
|
137
137
|
};
|
|
138
|
-
}).concat([noCacheBehavior("/remoteEntry.js")].concat(spaFallback ? [
|
|
138
|
+
})).concat([noCacheBehavior("/remoteEntry.js")].concat(spaFallback ? [
|
|
139
139
|
noCacheBehavior("/" + indexDocument),
|
|
140
140
|
noCacheBehavior("/config.json")
|
|
141
141
|
] : []));
|
|
@@ -188,7 +188,7 @@ function makeUiBundleDistribution(pluginId, bundleVersion, assetsDir, spaFallbac
|
|
|
188
188
|
originAccessControlId: oacId
|
|
189
189
|
}].concat(servedBuckets.map(sb => ({
|
|
190
190
|
domainName: sb.bucketRegionalDomainName,
|
|
191
|
-
originId: "served-" + sb.
|
|
191
|
+
originId: "served-" + sb.id,
|
|
192
192
|
originAccessControlId: oacId
|
|
193
193
|
})));
|
|
194
194
|
}),
|
|
@@ -253,7 +253,7 @@ function makeUiBundleDistribution(pluginId, bundleVersion, assetsDir, spaFallbac
|
|
|
253
253
|
}))
|
|
254
254
|
});
|
|
255
255
|
servedBuckets.forEach(sb => {
|
|
256
|
-
new (Aws.s3.BucketPolicy)(name + "-served-" + sb.
|
|
256
|
+
new (Aws.s3.BucketPolicy)(name + "-served-" + sb.id + "-policy", {
|
|
257
257
|
bucket: sb.bucketId,
|
|
258
258
|
policy: Pulumi.all([
|
|
259
259
|
sb.bucketArn,
|
|
@@ -26,3 +26,20 @@ let parseProdStacks = (csv: string): array<string> =>
|
|
|
26
26
|
->String.split(",")
|
|
27
27
|
->Array.map(String.trim)
|
|
28
28
|
->Array.filter(s => s !== "")
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
The stacks this deployment considers production, resolved from config.
|
|
32
|
+
|
|
33
|
+
The one notion of "is this stack production", deliberately shared rather than
|
|
34
|
+
answered twice. Domain naming asked it first; object-store layout asks it too,
|
|
35
|
+
and a second key would be two flags that can disagree — the state where a stack
|
|
36
|
+
gets a production domain and non-production storage, or the reverse, with
|
|
37
|
+
nothing to flag the mismatch.
|
|
38
|
+
|
|
39
|
+
Impure by necessity (it reads config); the derivations that consume it stay
|
|
40
|
+
pure and unit-tested.
|
|
41
|
+
*/
|
|
42
|
+
let resolveProdStacks = (): array<string> =>
|
|
43
|
+
Util_LocalConfig.get("hostUiProdStacks")
|
|
44
|
+
->Option.map(parseProdStacks)
|
|
45
|
+
->Option.getOr(defaultProdStacks)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
|
+
import * as Util_LocalConfig$ReventlessAws from "./Util_LocalConfig.res.mjs";
|
|
3
5
|
|
|
4
6
|
let defaultProdStacks = [
|
|
5
7
|
"prod",
|
|
@@ -18,9 +20,14 @@ function parseProdStacks(csv) {
|
|
|
18
20
|
return csv.split(",").map(prim => prim.trim()).filter(s => s !== "");
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
function resolveProdStacks() {
|
|
24
|
+
return Stdlib_Option.getOr(Stdlib_Option.map(Util_LocalConfig$ReventlessAws.get("hostUiProdStacks"), parseProdStacks), defaultProdStacks);
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
export {
|
|
22
28
|
defaultProdStacks,
|
|
23
29
|
deriveFqdn,
|
|
24
30
|
parseProdStacks,
|
|
31
|
+
resolveProdStacks,
|
|
25
32
|
}
|
|
26
|
-
/*
|
|
33
|
+
/* Util_LocalConfig-ReventlessAws Not a pure module */
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// How a stack lays out the object stores its plugins declare, and how hard it
|
|
2
|
+
// protects them. Pure functions so both decisions are decidable and unit-
|
|
3
|
+
// testable without touching Pulumi — the same shape `Util_HostUiDomain` uses
|
|
4
|
+
// for the host-shell FQDN.
|
|
5
|
+
//
|
|
6
|
+
// Two decisions, deliberately independent:
|
|
7
|
+
//
|
|
8
|
+
// layout — one bucket per store, or one bucket per stack with `{store}/…`
|
|
9
|
+
// prefixes inside it
|
|
10
|
+
// protection — whether an accidental removal may destroy the store
|
|
11
|
+
//
|
|
12
|
+
// It is tempting to make one switch decide both, and that is wrong. `alpha`
|
|
13
|
+
// shares a bucket *and* holds hand-entered data worth protecting; a `pr-*`
|
|
14
|
+
// stack shares a bucket and must be destroyable or teardown leaks exactly the
|
|
15
|
+
// buckets sharing exists to save. Layout answers "how many buckets"; protection
|
|
16
|
+
// answers "is this data disposable". Those are different questions about the
|
|
17
|
+
// same stack.
|
|
18
|
+
//
|
|
19
|
+
// Why production alone gets per-store buckets: the growth term is
|
|
20
|
+
// `plugins × stores × stacks`, and stacks is the factor that actually grows —
|
|
21
|
+
// the stack allowlist admits `pr-*`, so per-PR environments multiply every
|
|
22
|
+
// plugin's every store against a 100-bucket account default. Production does
|
|
23
|
+
// not multiply. It is also the only environment where a bucket boundary (rather
|
|
24
|
+
// than a prefix boundary) is worth its cost, since the honest price of sharing
|
|
25
|
+
// is that per-store least-privilege degrades from a bucket ARN to a prefix ARN.
|
|
26
|
+
|
|
27
|
+
/** How many buckets a stack's declared stores get. */
|
|
28
|
+
type t =
|
|
29
|
+
| PerStore
|
|
30
|
+
| SharedBucket
|
|
31
|
+
|
|
32
|
+
/** Whether a store may be destroyed by an accidental removal. */
|
|
33
|
+
type protection =
|
|
34
|
+
| Protected
|
|
35
|
+
| Unprotected
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
Stack-name prefixes whose stacks are disposable.
|
|
39
|
+
|
|
40
|
+
`pr-*` matches the stack allowlist's per-PR environments. A prefix rather than
|
|
41
|
+
an exact list because the whole point of these stacks is that nobody enumerates
|
|
42
|
+
them in advance.
|
|
43
|
+
*/
|
|
44
|
+
let defaultEphemeralPrefixes = ["pr-"]
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
Production gets a bucket per store; every other stack shares one.
|
|
48
|
+
|
|
49
|
+
`prodStacks` comes from `Util_HostUiDomain.resolveProdStacks` — the same notion
|
|
50
|
+
of "production" that names the host-shell domain, on purpose.
|
|
51
|
+
|
|
52
|
+
Note this polarity **fails open**: a new production stack whose name is not on
|
|
53
|
+
the list (`production`, `live`, `prod-eu`) silently gets the weaker layout and
|
|
54
|
+
nothing errors. That is the accepted cost of keying off a name allowlist, paid
|
|
55
|
+
down by the list being config-overridable and by the deploy logging the layout
|
|
56
|
+
it chose — a silent fail-open is only dangerous while it is silent.
|
|
57
|
+
*/
|
|
58
|
+
let layoutFor = (~stack: string, ~prodStacks: array<string>): t =>
|
|
59
|
+
prodStacks->Array.includes(stack) ? PerStore : SharedBucket
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
Destroy semantics follow disposability, **not** layout.
|
|
63
|
+
|
|
64
|
+
`alpha` shares a bucket and is still protected: it declares
|
|
65
|
+
`reventless:wipeable`, but that authorises the reset tool to empty stores
|
|
66
|
+
*deliberately*, after a scope prompt and a typed confirmation. It does not say a
|
|
67
|
+
field rename may destroy a bucket by accident. Only stacks that are routinely
|
|
68
|
+
torn down are unprotected.
|
|
69
|
+
*/
|
|
70
|
+
let protectionFor = (~stack: string, ~ephemeralPrefixes: array<string>=defaultEphemeralPrefixes): protection =>
|
|
71
|
+
ephemeralPrefixes->Array.some(p => stack->String.startsWith(p)) ? Unprotected : Protected
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
The bucket a store's objects live in.
|
|
75
|
+
|
|
76
|
+
Per-store: `{plugin}-{store}`, so the physical name traces back to the
|
|
77
|
+
declaration that required it — a store rename becomes a visible replace in
|
|
78
|
+
review rather than a silent one. Shared: one `{stack}-stores` bucket for every
|
|
79
|
+
declared store on the stack.
|
|
80
|
+
|
|
81
|
+
Pulumi appends its own suffix for global uniqueness, so neither form has to
|
|
82
|
+
carry an account or region discriminator.
|
|
83
|
+
*/
|
|
84
|
+
let bucketNameFor = (~layout: t, ~stack: string, ~plugin: string, ~store: string): string =>
|
|
85
|
+
switch layout {
|
|
86
|
+
| PerStore => `${plugin}-${store}`
|
|
87
|
+
| SharedBucket => `${stack}-stores`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
The prefix a store's object keys are rooted at — the store name, in **both**
|
|
92
|
+
layouts.
|
|
93
|
+
|
|
94
|
+
This is the property that keeps the two layouts one model rather than a fork.
|
|
95
|
+
The presign service mints `{store}/{identity}{uuid}/{file}` either way, so the
|
|
96
|
+
stored ref is `/{store}/…` regardless of which bucket sits behind it, and only
|
|
97
|
+
the CDN origin differs. Refs live in an append-only event log: one that encoded
|
|
98
|
+
its bucket layout would be environment-specific and unrewritable, so a prod dump
|
|
99
|
+
restored into a PR stack would carry refs that cannot resolve.
|
|
100
|
+
|
|
101
|
+
The cost is a slightly redundant prefix inside a dedicated bucket
|
|
102
|
+
(`catalog-productImages/productImages/…`). Take the redundancy.
|
|
103
|
+
*/
|
|
104
|
+
let keyPrefixFor = (~store: string): string => store
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
let defaultEphemeralPrefixes = ["pr-"];
|
|
5
|
+
|
|
6
|
+
function layoutFor(stack, prodStacks) {
|
|
7
|
+
if (prodStacks.includes(stack)) {
|
|
8
|
+
return "PerStore";
|
|
9
|
+
} else {
|
|
10
|
+
return "SharedBucket";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function protectionFor(stack, ephemeralPrefixesOpt) {
|
|
15
|
+
let ephemeralPrefixes = ephemeralPrefixesOpt !== undefined ? ephemeralPrefixesOpt : defaultEphemeralPrefixes;
|
|
16
|
+
if (ephemeralPrefixes.some(p => stack.startsWith(p))) {
|
|
17
|
+
return "Unprotected";
|
|
18
|
+
} else {
|
|
19
|
+
return "Protected";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function bucketNameFor(layout, stack, plugin, store) {
|
|
24
|
+
if (layout === "PerStore") {
|
|
25
|
+
return plugin + `-` + store;
|
|
26
|
+
} else {
|
|
27
|
+
return stack + `-stores`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function keyPrefixFor(store) {
|
|
32
|
+
return store;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
defaultEphemeralPrefixes,
|
|
37
|
+
layoutFor,
|
|
38
|
+
protectionFor,
|
|
39
|
+
bucketNameFor,
|
|
40
|
+
keyPrefixFor,
|
|
41
|
+
}
|
|
42
|
+
/* No side effect */
|