@reventlessdev/reventless-aws 3.0.0-alpha.240 → 3.0.0-alpha.242
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 +17 -0
- package/package.json +7 -7
- package/src/Platform.res +196 -6
- package/src/Platform.res.mjs +169 -20
- package/src/plugin/stack/Plugin_Stack.res +190 -0
- package/src/plugin/stack/Plugin_Stack.res.mjs +116 -0
- package/src/util/Util_StoreLayout.res +71 -0
- package/src/util/Util_StoreLayout.res.mjs +34 -0
- package/tests/Util_StoreLayoutTest.res +114 -0
- package/tests/Util_StoreLayoutTest.res.mjs +57 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.242 (2026-07-29)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **platform:** export the bucket's physical name, not the layout's ([43b8ba4](https://github.com/ReventlessDev/reventless-core/commit/43b8ba4f04499c918c7c16921962ad640ea20198))
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **platform:** fail a plugin deploy that needs a store the platform lacks ([8f2b6e6](https://github.com/ReventlessDev/reventless-core/commit/8f2b6e6ca8ea472c70bee40e57c959a64f2a7b4e))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# 3.0.0-alpha.241 (2026-07-29)
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **platform:** serve and publish declared object stores without a host UI ([9cc4700](https://github.com/ReventlessDev/reventless-core/commit/9cc4700dc96f05e3d35fbc912a4c7be7a5531337))
|
|
21
|
+
|
|
22
|
+
|
|
6
23
|
# 3.0.0-alpha.240 (2026-07-28)
|
|
7
24
|
|
|
8
25
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-aws",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.242",
|
|
4
4
|
"description": "AWS adapters for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"dependencies": {
|
|
@@ -12,16 +12,16 @@
|
|
|
12
12
|
"sury": "11.0.0-alpha.4",
|
|
13
13
|
"uuid": "^13.0.0",
|
|
14
14
|
"@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.25",
|
|
15
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.9",
|
|
16
15
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.31",
|
|
16
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.9",
|
|
17
17
|
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.61",
|
|
18
18
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
|
|
19
19
|
"@reventlessdev/rescript-uuid": "1.1.0-alpha.17",
|
|
20
|
-
"@reventlessdev/reventless-
|
|
21
|
-
"@reventlessdev/reventless-
|
|
22
|
-
"@reventlessdev/reventless-
|
|
23
|
-
"@reventlessdev/reventless-
|
|
24
|
-
"@reventlessdev/reventless-
|
|
20
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.29",
|
|
21
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.84",
|
|
22
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.109",
|
|
23
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.55",
|
|
24
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.191"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"rescript": "12.3.0",
|
package/src/Platform.res
CHANGED
|
@@ -44,6 +44,45 @@ let splitApiOutputsRef: ref<option<splitApiOutputs>> = ref(None)
|
|
|
44
44
|
Call after `makePlatform` — returns `None` in unified mode. */
|
|
45
45
|
let getSplitApiOutputs = () => splitApiOutputsRef.contents
|
|
46
46
|
|
|
47
|
+
// Object stores provisioned from field declarations — populated by
|
|
48
|
+
// deployPlatform. Access via getObjectStoreEndpoints() after it has been called.
|
|
49
|
+
//
|
|
50
|
+
// A platform whose UI ships from its own stack needs each store's presign
|
|
51
|
+
// endpoint, and `deployPlatform` is the only place that knows it. Without this
|
|
52
|
+
// the store is provisioned and unnameable: the stores are built unconditionally,
|
|
53
|
+
// but every consumer of them used to sit inside `switch hostUiBundle`. Same
|
|
54
|
+
// reason `getSplitApiOutputs` exists — a root cannot re-export what it cannot
|
|
55
|
+
// reach.
|
|
56
|
+
type objectStoreEndpoint = {
|
|
57
|
+
// The store's qualified `{plugin}.{store}` name. The same string
|
|
58
|
+
// `pluginStructure.requiredStores` carries, so a UI's binding key and the
|
|
59
|
+
// declaration's identity are one value rather than two that can drift.
|
|
60
|
+
store: string,
|
|
61
|
+
// The path this store's keys are rooted at, and therefore the path it must be
|
|
62
|
+
// served under for a minted `/{key}` ref to resolve.
|
|
63
|
+
keyPrefix: string,
|
|
64
|
+
// The bucket's **physical** name, which is not the name the layout computes:
|
|
65
|
+
// Pulumi auto-names the resource, so a `SharedBucket` stack asking for
|
|
66
|
+
// `alpha-stores` gets `alpha-stores-507202d`. An Output because that suffix is
|
|
67
|
+
// only known after the bucket resolves — and a plain string here is exactly
|
|
68
|
+
// how this shipped exporting a bucket name that no ARN matches.
|
|
69
|
+
bucketName: Pulumi.Output.t<string>,
|
|
70
|
+
uploadUrl: Pulumi.Output.t<string>,
|
|
71
|
+
// Public base URL the store is served from, when the platform serves it
|
|
72
|
+
// itself. `None` when a host-UI bundle is deployed — there the shell's own
|
|
73
|
+
// origin serves the store same-origin and a relative `/{prefix}/…` resolves
|
|
74
|
+
// without a base.
|
|
75
|
+
baseUrl: option<Pulumi.Output.t<string>>,
|
|
76
|
+
}
|
|
77
|
+
let objectStoreEndpointsRef: ref<array<objectStoreEndpoint>> = ref([])
|
|
78
|
+
|
|
79
|
+
/** Returns the object stores this platform provisioned from field declarations.
|
|
80
|
+
|
|
81
|
+
Empty when nothing is declared — a legitimate answer rather than a
|
|
82
|
+
call-order mistake, so this returns `[]` instead of throwing the way
|
|
83
|
+
`getApiConfig` does. */
|
|
84
|
+
let getObjectStoreEndpoints = () => objectStoreEndpointsRef.contents
|
|
85
|
+
|
|
47
86
|
module MakeWithConfig = (
|
|
48
87
|
Config: {
|
|
49
88
|
let splitApi: bool
|
|
@@ -1552,7 +1591,11 @@ module MakeWithConfig = (
|
|
|
1552
1591
|
~bucketName=storeHandle.bucketName,
|
|
1553
1592
|
~servedPrefix=storeHandle.keyPrefix,
|
|
1554
1593
|
)
|
|
1555
|
-
|
|
1594
|
+
// `bucketName` is the layout's *logical* name and groups the served view
|
|
1595
|
+
// below; `storeHandle.bucketName` is the physical one Pulumi resolves, and
|
|
1596
|
+
// is what a consumer needs to build an ARN. Carrying both is the point —
|
|
1597
|
+
// they are different strings and only one of them exists in S3.
|
|
1598
|
+
(`${plugin}.${store}`, keyPrefix, bucketName, storeHandle.bucketName, presign.url)
|
|
1556
1599
|
})
|
|
1557
1600
|
|
|
1558
1601
|
// Group the served view by bucket: one origin and one bucket policy per
|
|
@@ -1563,13 +1606,105 @@ module MakeWithConfig = (
|
|
|
1563
1606
|
->Option.map(b => {
|
|
1564
1607
|
ReventlessInfra.Platform.id: bucketName,
|
|
1565
1608
|
prefixes: declaredStoreServices
|
|
1566
|
-
->Array.filterMap(((_, prefix, bn, _)) => bn == bucketName ? Some(prefix) : None),
|
|
1609
|
+
->Array.filterMap(((_, prefix, bn, _, _)) => bn == bucketName ? Some(prefix) : None),
|
|
1567
1610
|
bucketId: b.bucketId,
|
|
1568
1611
|
bucketArn: b.bucketArn,
|
|
1569
1612
|
bucketRegionalDomainName: b.bucketRegionalDomainName,
|
|
1570
1613
|
})
|
|
1571
1614
|
)
|
|
1572
1615
|
|
|
1616
|
+
// ── Serving the declared stores ───────────────────────────────────────
|
|
1617
|
+
//
|
|
1618
|
+
// A store's bucket blocks public policy and takes its read grant only from
|
|
1619
|
+
// a distribution's BucketPolicy, so "provisioned" and "readable" are two
|
|
1620
|
+
// different things. When a host UI bundle is deployed, its distribution
|
|
1621
|
+
// fronts the stores same-origin (below) and a relative `/{prefix}/…`
|
|
1622
|
+
// resolves with no base URL. When it is not, the platform fronts them
|
|
1623
|
+
// itself — otherwise every declared store is write-only.
|
|
1624
|
+
//
|
|
1625
|
+
// Never both: two distributions fronting one bucket would each write the
|
|
1626
|
+
// bucket's single allowed policy and silently unpick the other's grant.
|
|
1627
|
+
// `Util_StoreLayout.servingFor` is where that exclusion is decided, so it is
|
|
1628
|
+
// one testable choice rather than a condition spelled out here.
|
|
1629
|
+
let storeServingBaseUrl: option<Pulumi.Output.t<string>> = switch Util_StoreLayout.servingFor(
|
|
1630
|
+
~hasHostUiBundle=hostUiBundle->Option.isSome,
|
|
1631
|
+
~declaredBucketCount=declaredServedBuckets->Array.length,
|
|
1632
|
+
) {
|
|
1633
|
+
| NoStores | HostShell => None
|
|
1634
|
+
| PlatformOwned =>
|
|
1635
|
+
Some(
|
|
1636
|
+
Plugin_Stack.makeServedBucketDistribution(
|
|
1637
|
+
~name="object-stores",
|
|
1638
|
+
~servedBuckets=declaredServedBuckets,
|
|
1639
|
+
),
|
|
1640
|
+
)
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
let declaredStoreEndpoints = declaredStoreServices->Array.map(((
|
|
1644
|
+
store,
|
|
1645
|
+
keyPrefix,
|
|
1646
|
+
_logicalBucketName,
|
|
1647
|
+
physicalBucketName,
|
|
1648
|
+
uploadUrl,
|
|
1649
|
+
)) => {
|
|
1650
|
+
store,
|
|
1651
|
+
keyPrefix,
|
|
1652
|
+
bucketName: physicalBucketName->Pulumi.Output.fromInput,
|
|
1653
|
+
uploadUrl,
|
|
1654
|
+
baseUrl: storeServingBaseUrl,
|
|
1655
|
+
})
|
|
1656
|
+
objectStoreEndpointsRef := declaredStoreEndpoints
|
|
1657
|
+
|
|
1658
|
+
// Exported unconditionally — the provisioning above already is. Their only
|
|
1659
|
+
// consumers used to sit inside `switch hostUiBundle`, which left a platform
|
|
1660
|
+
// whose UI ships from another stack holding stores it could not name.
|
|
1661
|
+
//
|
|
1662
|
+
// Omitted entirely when nothing is declared, so a deployment with no
|
|
1663
|
+
// declared store keeps a byte-identical output set.
|
|
1664
|
+
if declaredStoreEndpoints->Array.length > 0 {
|
|
1665
|
+
Pulumi.Pulumi.export(
|
|
1666
|
+
"uploadEndpoints",
|
|
1667
|
+
declaredStoreEndpoints
|
|
1668
|
+
->Array.map(e => e.uploadUrl->Pulumi.Output.apply(u => (e.store, JSON.Encode.string(u))))
|
|
1669
|
+
->Pulumi.Output.all
|
|
1670
|
+
->Pulumi.Output.apply(pairs => pairs->Dict.fromArray->JSON.Encode.object),
|
|
1671
|
+
)
|
|
1672
|
+
Pulumi.Pulumi.export(
|
|
1673
|
+
"objectStores",
|
|
1674
|
+
declaredStoreEndpoints
|
|
1675
|
+
->Array.map(e =>
|
|
1676
|
+
Pulumi.Output.all2((e.bucketName, e.baseUrl->Pulumi.Output.allOpt))->Pulumi.Output.apply(((
|
|
1677
|
+
bucketName,
|
|
1678
|
+
baseUrl,
|
|
1679
|
+
)) => (e, bucketName, baseUrl))
|
|
1680
|
+
)
|
|
1681
|
+
->Pulumi.Output.all
|
|
1682
|
+
->Pulumi.Output.apply(resolved =>
|
|
1683
|
+
resolved
|
|
1684
|
+
->Array.map(((e, bucketName, baseUrl)) => (
|
|
1685
|
+
e.store,
|
|
1686
|
+
[
|
|
1687
|
+
("bucketName", JSON.Encode.string(bucketName)),
|
|
1688
|
+
("keyPrefix", JSON.Encode.string(e.keyPrefix)),
|
|
1689
|
+
]
|
|
1690
|
+
// Absent when the host shell serves the store: there the object is
|
|
1691
|
+
// addressable same-origin and a base URL would be a second, and
|
|
1692
|
+
// wrong, way to reach it.
|
|
1693
|
+
->Array.concat(
|
|
1694
|
+
switch baseUrl {
|
|
1695
|
+
| Some(b) => [("baseUrl", JSON.Encode.string(b))]
|
|
1696
|
+
| None => []
|
|
1697
|
+
},
|
|
1698
|
+
)
|
|
1699
|
+
->Dict.fromArray
|
|
1700
|
+
->JSON.Encode.object,
|
|
1701
|
+
))
|
|
1702
|
+
->Dict.fromArray
|
|
1703
|
+
->JSON.Encode.object
|
|
1704
|
+
),
|
|
1705
|
+
)
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1573
1708
|
// Host UI shell deployment — opt-in via ~hostUiBundle. The shell SPA is
|
|
1574
1709
|
// hosted on its own CloudFront distribution; `config.json` is generated
|
|
1575
1710
|
// at deploy time with the resolved API endpoints, region, and Cognito
|
|
@@ -1706,7 +1841,7 @@ module MakeWithConfig = (
|
|
|
1706
1841
|
// built before per-store binding reads it and is unaffected.
|
|
1707
1842
|
let storeUploadEndpointsOutput: Pulumi.Output.t<array<(string, string)>> =
|
|
1708
1843
|
declaredStoreServices
|
|
1709
|
-
->Array.map(((qualified, _, _, url)) => url->Pulumi.Output.apply(u => (qualified, u)))
|
|
1844
|
+
->Array.map(((qualified, _, _, _, url)) => url->Pulumi.Output.apply(u => (qualified, u)))
|
|
1710
1845
|
->Pulumi.Output.all
|
|
1711
1846
|
|
|
1712
1847
|
let configJsonContent =
|
|
@@ -1934,11 +2069,66 @@ module MakeWithConfig = (
|
|
|
1934
2069
|
~mergedApiIdentifier=checkedMergedApiArn,
|
|
1935
2070
|
~association,
|
|
1936
2071
|
)
|
|
2072
|
+
|
|
2073
|
+
// ── Capability coverage ────────────────────────────────────────────────
|
|
2074
|
+
//
|
|
2075
|
+
// `requiredStores` is what this plugin's fields *declare*; the platform's
|
|
2076
|
+
// `objectStores` output is what it actually provisioned. A store in the
|
|
2077
|
+
// first and not the second is the split-stack ordering hazard: the
|
|
2078
|
+
// platform deploys before the plugin and cannot see the plugin's schemas,
|
|
2079
|
+
// so the capability list is hand-written and can simply be wrong.
|
|
2080
|
+
//
|
|
2081
|
+
// It is worth failing on because every symptom is silent. The upload
|
|
2082
|
+
// input finds no per-store endpoint, falls back to the legacy single
|
|
2083
|
+
// service, and writes to whatever bucket that serves — a 2xx, a plausible
|
|
2084
|
+
// ref, and the wrong destination. A case slip in the hand-written name
|
|
2085
|
+
// produces exactly that, and so does forgetting the entry entirely.
|
|
2086
|
+
//
|
|
2087
|
+
// Two outcomes rather than one, because "you got it wrong" and "you have
|
|
2088
|
+
// not started" deserve different answers. A platform exporting no stores
|
|
2089
|
+
// at all has not adopted capability provisioning; failing it would break
|
|
2090
|
+
// deployments that are working. A platform exporting *some* stores but
|
|
2091
|
+
// not this one has adopted it and is missing or misspelling an entry.
|
|
2092
|
+
//
|
|
2093
|
+
// Folded into the association export for the same reason the merge gate
|
|
2094
|
+
// is: a dangling `apply` is not guaranteed to be evaluated, and a check
|
|
2095
|
+
// that might not run is not a check.
|
|
2096
|
+
let capabilityGate: Pulumi.Output.t<unit> =
|
|
2097
|
+
(pluginOutputs.pluginStructure, stackRef->Pulumi.StackReference.getOutput("objectStores"))
|
|
2098
|
+
->Pulumi.Output.all2
|
|
2099
|
+
->Pulumi.Output.apply((((structure, objectStores): (_, option<JSON.t>))) => {
|
|
2100
|
+
let required =
|
|
2101
|
+
structure->Option.flatMap(s => s.requiredStores)->Option.getOr([])
|
|
2102
|
+
let provisioned =
|
|
2103
|
+
objectStores
|
|
2104
|
+
->Option.flatMap(JSON.Decode.object)
|
|
2105
|
+
->Option.map(Dict.keysToArray)
|
|
2106
|
+
->Option.getOr([])
|
|
2107
|
+
switch Util_StoreLayout.coverageFor(~required, ~provisioned) {
|
|
2108
|
+
| Covered => ()
|
|
2109
|
+
| NotAdopted(missing) =>
|
|
2110
|
+
log.warn(
|
|
2111
|
+
~comp="Platform:deployPlugin",
|
|
2112
|
+
`declares ${missing->Array.join(", ")} but the platform stack provisions no object stores — ` ++
|
|
2113
|
+
`add them to the platform's ~capabilities and redeploy the platform first, ` ++
|
|
2114
|
+
`or uploads will fall back to the legacy service and write to the wrong bucket`,
|
|
2115
|
+
)
|
|
2116
|
+
| Missing({missing, provisioned}) =>
|
|
2117
|
+
JsError.throwWithMessage(
|
|
2118
|
+
`Plugin requires object store(s) the platform does not provision: ${missing->Array.join(", ")}.\n` ++
|
|
2119
|
+
` The platform stack provisions: ${provisioned->Array.join(", ")}.\n` ++
|
|
2120
|
+
` A store's key is {plugin}.{store}, where {plugin} is the name the plugin registers — ` ++
|
|
2121
|
+
`check the capability's spelling and case against it.\n` ++
|
|
2122
|
+
` Add the missing entr(ies) to the platform's ~capabilities and redeploy the platform stack first.`,
|
|
2123
|
+
)
|
|
2124
|
+
}
|
|
2125
|
+
})
|
|
2126
|
+
|
|
1937
2127
|
Pulumi.Pulumi.export(
|
|
1938
2128
|
"sourceApiAssociationId",
|
|
1939
|
-
(association.associationId, mergeGate)
|
|
1940
|
-
->Pulumi.Output.
|
|
1941
|
-
->Pulumi.Output.apply(((id, _)) => id),
|
|
2129
|
+
(association.associationId, mergeGate, capabilityGate)
|
|
2130
|
+
->Pulumi.Output.all3
|
|
2131
|
+
->Pulumi.Output.apply(((id, _, _)) => id),
|
|
1942
2132
|
)
|
|
1943
2133
|
Pulumi.Pulumi.export(
|
|
1944
2134
|
"pluginSourceApiId",
|
package/src/Platform.res.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output
|
|
|
11
11
|
import * as Pulumi$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Pulumi.res.mjs";
|
|
12
12
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
13
13
|
import * as Pulumi from "@pulumi/pulumi";
|
|
14
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
14
15
|
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
15
16
|
import * as Plugin$ReventlessAws from "./components/Plugin.res.mjs";
|
|
16
17
|
import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
|
|
@@ -116,6 +117,14 @@ function getSplitApiOutputs() {
|
|
|
116
117
|
return splitApiOutputsRef.contents;
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
let objectStoreEndpointsRef = {
|
|
121
|
+
contents: []
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
function getObjectStoreEndpoints() {
|
|
125
|
+
return objectStoreEndpointsRef.contents;
|
|
126
|
+
}
|
|
127
|
+
|
|
119
128
|
function MakeWithConfig(Config) {
|
|
120
129
|
Stdlib_Option.forEach(Config.commandHandlerConfig.aggregates, param => {
|
|
121
130
|
Stdlib_Option.forEach(param.sync, AggregateRuntime_Builder_Single$ReventlessAws.setConfig);
|
|
@@ -927,6 +936,7 @@ function MakeWithConfig(Config) {
|
|
|
927
936
|
plugin + `.` + store,
|
|
928
937
|
keyPrefix,
|
|
929
938
|
bucketName,
|
|
939
|
+
storeHandle.bucketName,
|
|
930
940
|
presign.url
|
|
931
941
|
];
|
|
932
942
|
});
|
|
@@ -941,18 +951,70 @@ function MakeWithConfig(Config) {
|
|
|
941
951
|
bucketArn: b.bucketArn,
|
|
942
952
|
bucketRegionalDomainName: b.bucketRegionalDomainName
|
|
943
953
|
})));
|
|
954
|
+
let match$1 = Util_StoreLayout$ReventlessAws.servingFor(Stdlib_Option.isSome(hostUiBundle), declaredServedBuckets.length);
|
|
955
|
+
let storeServingBaseUrl;
|
|
956
|
+
switch (match$1) {
|
|
957
|
+
case "NoStores" :
|
|
958
|
+
case "HostShell" :
|
|
959
|
+
storeServingBaseUrl = undefined;
|
|
960
|
+
break;
|
|
961
|
+
case "PlatformOwned" :
|
|
962
|
+
storeServingBaseUrl = Plugin_Stack$ReventlessAws.makeServedBucketDistribution("object-stores", declaredServedBuckets, undefined);
|
|
963
|
+
break;
|
|
964
|
+
}
|
|
965
|
+
let declaredStoreEndpoints = declaredStoreServices.map(param => ({
|
|
966
|
+
store: param[0],
|
|
967
|
+
keyPrefix: param[1],
|
|
968
|
+
bucketName: param[3],
|
|
969
|
+
uploadUrl: param[4],
|
|
970
|
+
baseUrl: storeServingBaseUrl
|
|
971
|
+
}));
|
|
972
|
+
objectStoreEndpointsRef.contents = declaredStoreEndpoints;
|
|
973
|
+
if (declaredStoreEndpoints.length !== 0) {
|
|
974
|
+
Pulumi$Pulumi.$$export("uploadEndpoints", Pulumi.all(declaredStoreEndpoints.map(e => e.uploadUrl.apply(u => [
|
|
975
|
+
e.store,
|
|
976
|
+
u
|
|
977
|
+
]))).apply(pairs => Object.fromEntries(pairs)));
|
|
978
|
+
Pulumi$Pulumi.$$export("objectStores", Pulumi.all(declaredStoreEndpoints.map(e => Pulumi.all([
|
|
979
|
+
e.bucketName,
|
|
980
|
+
Output$Pulumi.allOpt(e.baseUrl)
|
|
981
|
+
]).apply(param => [
|
|
982
|
+
e,
|
|
983
|
+
param[0],
|
|
984
|
+
param[1]
|
|
985
|
+
]))).apply(resolved => Object.fromEntries(resolved.map(param => {
|
|
986
|
+
let baseUrl = param[2];
|
|
987
|
+
let e = param[0];
|
|
988
|
+
return [
|
|
989
|
+
e.store,
|
|
990
|
+
Object.fromEntries([
|
|
991
|
+
[
|
|
992
|
+
"bucketName",
|
|
993
|
+
param[1]
|
|
994
|
+
],
|
|
995
|
+
[
|
|
996
|
+
"keyPrefix",
|
|
997
|
+
e.keyPrefix
|
|
998
|
+
]
|
|
999
|
+
].concat(baseUrl !== undefined ? [[
|
|
1000
|
+
"baseUrl",
|
|
1001
|
+
baseUrl
|
|
1002
|
+
]] : []))
|
|
1003
|
+
];
|
|
1004
|
+
}))));
|
|
1005
|
+
}
|
|
944
1006
|
if (hostUiBundle !== undefined) {
|
|
945
|
-
let match$
|
|
946
|
-
let match$
|
|
1007
|
+
let match$2 = Util_LocalConfig$ReventlessAws.get("hostUiBaseDomain");
|
|
1008
|
+
let match$3 = Util_LocalConfig$ReventlessAws.get("hostUiHostedZoneId");
|
|
947
1009
|
let customDomain;
|
|
948
|
-
if (match$
|
|
1010
|
+
if (match$2 !== undefined && match$3 !== undefined) {
|
|
949
1011
|
let stack = Pulumi.getStack();
|
|
950
1012
|
let baseName = Stdlib_Option.getOr(Util_LocalConfig$ReventlessAws.get("hostUiBaseName"), Pulumi.getProject());
|
|
951
1013
|
let prodStacks = Stdlib_Option.getOr(Stdlib_Option.map(Util_LocalConfig$ReventlessAws.get("hostUiProdStacks"), Util_HostUiDomain$ReventlessAws.parseProdStacks), Util_HostUiDomain$ReventlessAws.defaultProdStacks);
|
|
952
|
-
let fqdn = Util_HostUiDomain$ReventlessAws.deriveFqdn(baseName, stack, match$
|
|
1014
|
+
let fqdn = Util_HostUiDomain$ReventlessAws.deriveFqdn(baseName, stack, match$2, prodStacks);
|
|
953
1015
|
customDomain = {
|
|
954
1016
|
fqdn: fqdn,
|
|
955
|
-
hostedZoneId: match$
|
|
1017
|
+
hostedZoneId: match$3
|
|
956
1018
|
};
|
|
957
1019
|
} else {
|
|
958
1020
|
customDomain = undefined;
|
|
@@ -967,11 +1029,11 @@ function MakeWithConfig(Config) {
|
|
|
967
1029
|
bucketRegionalDomainName: store.bucketRegionalDomainName
|
|
968
1030
|
}] : []
|
|
969
1031
|
).concat(declaredServedBuckets);
|
|
970
|
-
let match$
|
|
1032
|
+
let match$4 = Plugin_Stack$ReventlessAws.makeUiBundleDistribution("host-ui", Stdlib_Option.getOr(hostUiBundle.bundleVersion, version), Stdlib_Option.getOr(hostUiBundle.assetsDir, Util_Bundle$ReventlessAws.resolvePackageRoot("@reventlessdev/reventless-host-shell") + "/dist"), true, undefined, true, [
|
|
971
1033
|
"config.json",
|
|
972
1034
|
"ui-hints.json"
|
|
973
1035
|
], customDomain, servedBuckets);
|
|
974
|
-
let bucketName = match$
|
|
1036
|
+
let bucketName = match$4.bucketName;
|
|
975
1037
|
let regionStr = Stdlib_Option.getOr(new Pulumi.Config("aws").get("region"), "unknown");
|
|
976
1038
|
let cognitoPool = Platform_Stack$ReventlessAws.resolveCognitoUserPool();
|
|
977
1039
|
let domainEventsEndpointOutput = domainEventsApiOpt !== undefined ? AppSync_EventsApi$ReventlessAws.httpEndpoint(domainEventsApiOpt).apply(ep => ep + "/event") : Pulumi.output(undefined);
|
|
@@ -981,7 +1043,7 @@ function MakeWithConfig(Config) {
|
|
|
981
1043
|
let uploadEndpointOutput = store$1 !== undefined ? Upload_Presign_S3$ReventlessAws.make(store$1.bucketName, undefined, undefined, undefined, undefined).url.apply(u => u) : Pulumi.output(undefined);
|
|
982
1044
|
let storeUploadEndpointsOutput = Pulumi.all(declaredStoreServices.map(param => {
|
|
983
1045
|
let qualified = param[0];
|
|
984
|
-
return param[
|
|
1046
|
+
return param[4].apply(u => [
|
|
985
1047
|
qualified,
|
|
986
1048
|
u
|
|
987
1049
|
]);
|
|
@@ -1083,7 +1145,7 @@ function MakeWithConfig(Config) {
|
|
|
1083
1145
|
contentType: "application/json"
|
|
1084
1146
|
});
|
|
1085
1147
|
}
|
|
1086
|
-
Pulumi$Pulumi.$$export("hostShellUrl", match$
|
|
1148
|
+
Pulumi$Pulumi.$$export("hostShellUrl", match$4.distributionUrl);
|
|
1087
1149
|
}
|
|
1088
1150
|
return Pulumi$Pulumi.getOutputs();
|
|
1089
1151
|
};
|
|
@@ -1142,9 +1204,25 @@ function MakeWithConfig(Config) {
|
|
|
1142
1204
|
]).apply(param => param[0]);
|
|
1143
1205
|
let association = AppSync_MergedApi$ReventlessAws.associateSourceWithMergedArn("PluginSourceAssociation", arnAfterSchemaPush, domainApi, {});
|
|
1144
1206
|
let mergeGate = AppSync_MergedApi$ReventlessAws.mergeStatusGateWith(checkedMergedApiArn, association);
|
|
1207
|
+
let capabilityGate = Pulumi.all([
|
|
1208
|
+
pluginOutputs.pluginStructure,
|
|
1209
|
+
stackRef.getOutput("objectStores")
|
|
1210
|
+
]).apply(param => {
|
|
1211
|
+
let required = Stdlib_Option.getOr(Stdlib_Option.flatMap(param[0], s => s.requiredStores), []);
|
|
1212
|
+
let provisioned = Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(param[1], Stdlib_JSON.Decode.object), prim => Object.keys(prim)), []);
|
|
1213
|
+
let missing = Util_StoreLayout$ReventlessAws.coverageFor(required, provisioned);
|
|
1214
|
+
if (typeof missing !== "object") {
|
|
1215
|
+
return;
|
|
1216
|
+
} else if (missing.TAG === "NotAdopted") {
|
|
1217
|
+
return log.warn("Platform:deployPlugin", undefined, `declares ` + missing._0.join(", ") + ` but the platform stack provisions no object stores — add them to the platform's ~capabilities and redeploy the platform first, or uploads will fall back to the legacy service and write to the wrong bucket`);
|
|
1218
|
+
} else {
|
|
1219
|
+
return Stdlib_JsError.throwWithMessage(`Plugin requires object store(s) the platform does not provision: ` + missing.missing.join(", ") + `.\n` + (` The platform stack provisions: ` + missing.provisioned.join(", ") + `.\n`) + ` A store's key is {plugin}.{store}, where {plugin} is the name the plugin registers — check the capability's spelling and case against it.\n Add the missing entr(ies) to the platform's ~capabilities and redeploy the platform stack first.`);
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1145
1222
|
Pulumi$Pulumi.$$export("sourceApiAssociationId", Pulumi.all([
|
|
1146
1223
|
association.associationId,
|
|
1147
|
-
mergeGate
|
|
1224
|
+
mergeGate,
|
|
1225
|
+
capabilityGate
|
|
1148
1226
|
]).apply(param => param[0]));
|
|
1149
1227
|
Pulumi$Pulumi.$$export("pluginSourceApiId", Output$Pulumi.flatMap(domainApi, api => api.id));
|
|
1150
1228
|
Pulumi$Pulumi.$$export("pluginSourceApiEndpoint", Output$Pulumi.flatMap(domainApi, api => api.uris.apply(uris => uris.GRAPHQL)));
|
|
@@ -2013,6 +2091,7 @@ function Make($star) {
|
|
|
2013
2091
|
plugin + `.` + store,
|
|
2014
2092
|
keyPrefix,
|
|
2015
2093
|
bucketName,
|
|
2094
|
+
storeHandle.bucketName,
|
|
2016
2095
|
presign.url
|
|
2017
2096
|
];
|
|
2018
2097
|
});
|
|
@@ -2027,18 +2106,70 @@ function Make($star) {
|
|
|
2027
2106
|
bucketArn: b.bucketArn,
|
|
2028
2107
|
bucketRegionalDomainName: b.bucketRegionalDomainName
|
|
2029
2108
|
})));
|
|
2109
|
+
let match$1 = Util_StoreLayout$ReventlessAws.servingFor(Stdlib_Option.isSome(hostUiBundle), declaredServedBuckets.length);
|
|
2110
|
+
let storeServingBaseUrl;
|
|
2111
|
+
switch (match$1) {
|
|
2112
|
+
case "NoStores" :
|
|
2113
|
+
case "HostShell" :
|
|
2114
|
+
storeServingBaseUrl = undefined;
|
|
2115
|
+
break;
|
|
2116
|
+
case "PlatformOwned" :
|
|
2117
|
+
storeServingBaseUrl = Plugin_Stack$ReventlessAws.makeServedBucketDistribution("object-stores", declaredServedBuckets, undefined);
|
|
2118
|
+
break;
|
|
2119
|
+
}
|
|
2120
|
+
let declaredStoreEndpoints = declaredStoreServices.map(param => ({
|
|
2121
|
+
store: param[0],
|
|
2122
|
+
keyPrefix: param[1],
|
|
2123
|
+
bucketName: param[3],
|
|
2124
|
+
uploadUrl: param[4],
|
|
2125
|
+
baseUrl: storeServingBaseUrl
|
|
2126
|
+
}));
|
|
2127
|
+
objectStoreEndpointsRef.contents = declaredStoreEndpoints;
|
|
2128
|
+
if (declaredStoreEndpoints.length !== 0) {
|
|
2129
|
+
Pulumi$Pulumi.$$export("uploadEndpoints", Pulumi.all(declaredStoreEndpoints.map(e => e.uploadUrl.apply(u => [
|
|
2130
|
+
e.store,
|
|
2131
|
+
u
|
|
2132
|
+
]))).apply(pairs => Object.fromEntries(pairs)));
|
|
2133
|
+
Pulumi$Pulumi.$$export("objectStores", Pulumi.all(declaredStoreEndpoints.map(e => Pulumi.all([
|
|
2134
|
+
e.bucketName,
|
|
2135
|
+
Output$Pulumi.allOpt(e.baseUrl)
|
|
2136
|
+
]).apply(param => [
|
|
2137
|
+
e,
|
|
2138
|
+
param[0],
|
|
2139
|
+
param[1]
|
|
2140
|
+
]))).apply(resolved => Object.fromEntries(resolved.map(param => {
|
|
2141
|
+
let baseUrl = param[2];
|
|
2142
|
+
let e = param[0];
|
|
2143
|
+
return [
|
|
2144
|
+
e.store,
|
|
2145
|
+
Object.fromEntries([
|
|
2146
|
+
[
|
|
2147
|
+
"bucketName",
|
|
2148
|
+
param[1]
|
|
2149
|
+
],
|
|
2150
|
+
[
|
|
2151
|
+
"keyPrefix",
|
|
2152
|
+
e.keyPrefix
|
|
2153
|
+
]
|
|
2154
|
+
].concat(baseUrl !== undefined ? [[
|
|
2155
|
+
"baseUrl",
|
|
2156
|
+
baseUrl
|
|
2157
|
+
]] : []))
|
|
2158
|
+
];
|
|
2159
|
+
}))));
|
|
2160
|
+
}
|
|
2030
2161
|
if (hostUiBundle !== undefined) {
|
|
2031
|
-
let match$
|
|
2032
|
-
let match$
|
|
2162
|
+
let match$2 = Util_LocalConfig$ReventlessAws.get("hostUiBaseDomain");
|
|
2163
|
+
let match$3 = Util_LocalConfig$ReventlessAws.get("hostUiHostedZoneId");
|
|
2033
2164
|
let customDomain;
|
|
2034
|
-
if (match$
|
|
2165
|
+
if (match$2 !== undefined && match$3 !== undefined) {
|
|
2035
2166
|
let stack = Pulumi.getStack();
|
|
2036
2167
|
let baseName = Stdlib_Option.getOr(Util_LocalConfig$ReventlessAws.get("hostUiBaseName"), Pulumi.getProject());
|
|
2037
2168
|
let prodStacks = Stdlib_Option.getOr(Stdlib_Option.map(Util_LocalConfig$ReventlessAws.get("hostUiProdStacks"), Util_HostUiDomain$ReventlessAws.parseProdStacks), Util_HostUiDomain$ReventlessAws.defaultProdStacks);
|
|
2038
|
-
let fqdn = Util_HostUiDomain$ReventlessAws.deriveFqdn(baseName, stack, match$
|
|
2169
|
+
let fqdn = Util_HostUiDomain$ReventlessAws.deriveFqdn(baseName, stack, match$2, prodStacks);
|
|
2039
2170
|
customDomain = {
|
|
2040
2171
|
fqdn: fqdn,
|
|
2041
|
-
hostedZoneId: match$
|
|
2172
|
+
hostedZoneId: match$3
|
|
2042
2173
|
};
|
|
2043
2174
|
} else {
|
|
2044
2175
|
customDomain = undefined;
|
|
@@ -2053,11 +2184,11 @@ function Make($star) {
|
|
|
2053
2184
|
bucketRegionalDomainName: store.bucketRegionalDomainName
|
|
2054
2185
|
}] : []
|
|
2055
2186
|
).concat(declaredServedBuckets);
|
|
2056
|
-
let match$
|
|
2187
|
+
let match$4 = Plugin_Stack$ReventlessAws.makeUiBundleDistribution("host-ui", Stdlib_Option.getOr(hostUiBundle.bundleVersion, version), Stdlib_Option.getOr(hostUiBundle.assetsDir, Util_Bundle$ReventlessAws.resolvePackageRoot("@reventlessdev/reventless-host-shell") + "/dist"), true, undefined, true, [
|
|
2057
2188
|
"config.json",
|
|
2058
2189
|
"ui-hints.json"
|
|
2059
2190
|
], customDomain, servedBuckets);
|
|
2060
|
-
let bucketName = match$
|
|
2191
|
+
let bucketName = match$4.bucketName;
|
|
2061
2192
|
let regionStr = Stdlib_Option.getOr(new Pulumi.Config("aws").get("region"), "unknown");
|
|
2062
2193
|
let cognitoPool = Platform_Stack$ReventlessAws.resolveCognitoUserPool();
|
|
2063
2194
|
let domainEventsEndpointOutput = domainEventsApiOpt !== undefined ? AppSync_EventsApi$ReventlessAws.httpEndpoint(domainEventsApiOpt).apply(ep => ep + "/event") : Pulumi.output(undefined);
|
|
@@ -2067,7 +2198,7 @@ function Make($star) {
|
|
|
2067
2198
|
let uploadEndpointOutput = store$1 !== undefined ? Upload_Presign_S3$ReventlessAws.make(store$1.bucketName, undefined, undefined, undefined, undefined).url.apply(u => u) : Pulumi.output(undefined);
|
|
2068
2199
|
let storeUploadEndpointsOutput = Pulumi.all(declaredStoreServices.map(param => {
|
|
2069
2200
|
let qualified = param[0];
|
|
2070
|
-
return param[
|
|
2201
|
+
return param[4].apply(u => [
|
|
2071
2202
|
qualified,
|
|
2072
2203
|
u
|
|
2073
2204
|
]);
|
|
@@ -2169,7 +2300,7 @@ function Make($star) {
|
|
|
2169
2300
|
contentType: "application/json"
|
|
2170
2301
|
});
|
|
2171
2302
|
}
|
|
2172
|
-
Pulumi$Pulumi.$$export("hostShellUrl", match$
|
|
2303
|
+
Pulumi$Pulumi.$$export("hostShellUrl", match$4.distributionUrl);
|
|
2173
2304
|
}
|
|
2174
2305
|
return Pulumi$Pulumi.getOutputs();
|
|
2175
2306
|
};
|
|
@@ -2228,9 +2359,25 @@ function Make($star) {
|
|
|
2228
2359
|
]).apply(param => param[0]);
|
|
2229
2360
|
let association = AppSync_MergedApi$ReventlessAws.associateSourceWithMergedArn("PluginSourceAssociation", arnAfterSchemaPush, domainApi, {});
|
|
2230
2361
|
let mergeGate = AppSync_MergedApi$ReventlessAws.mergeStatusGateWith(checkedMergedApiArn, association);
|
|
2362
|
+
let capabilityGate = Pulumi.all([
|
|
2363
|
+
pluginOutputs.pluginStructure,
|
|
2364
|
+
stackRef.getOutput("objectStores")
|
|
2365
|
+
]).apply(param => {
|
|
2366
|
+
let required = Stdlib_Option.getOr(Stdlib_Option.flatMap(param[0], s => s.requiredStores), []);
|
|
2367
|
+
let provisioned = Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(param[1], Stdlib_JSON.Decode.object), prim => Object.keys(prim)), []);
|
|
2368
|
+
let missing = Util_StoreLayout$ReventlessAws.coverageFor(required, provisioned);
|
|
2369
|
+
if (typeof missing !== "object") {
|
|
2370
|
+
return;
|
|
2371
|
+
} else if (missing.TAG === "NotAdopted") {
|
|
2372
|
+
return log.warn("Platform:deployPlugin", undefined, `declares ` + missing._0.join(", ") + ` but the platform stack provisions no object stores — add them to the platform's ~capabilities and redeploy the platform first, or uploads will fall back to the legacy service and write to the wrong bucket`);
|
|
2373
|
+
} else {
|
|
2374
|
+
return Stdlib_JsError.throwWithMessage(`Plugin requires object store(s) the platform does not provision: ` + missing.missing.join(", ") + `.\n` + (` The platform stack provisions: ` + missing.provisioned.join(", ") + `.\n`) + ` A store's key is {plugin}.{store}, where {plugin} is the name the plugin registers — check the capability's spelling and case against it.\n Add the missing entr(ies) to the platform's ~capabilities and redeploy the platform stack first.`);
|
|
2375
|
+
}
|
|
2376
|
+
});
|
|
2231
2377
|
Pulumi$Pulumi.$$export("sourceApiAssociationId", Pulumi.all([
|
|
2232
2378
|
association.associationId,
|
|
2233
|
-
mergeGate
|
|
2379
|
+
mergeGate,
|
|
2380
|
+
capabilityGate
|
|
2234
2381
|
]).apply(param => param[0]));
|
|
2235
2382
|
Pulumi$Pulumi.$$export("pluginSourceApiId", Output$Pulumi.flatMap(domainApi, api => api.id));
|
|
2236
2383
|
Pulumi$Pulumi.$$export("pluginSourceApiEndpoint", Output$Pulumi.flatMap(domainApi, api => api.uris.apply(uris => uris.GRAPHQL)));
|
|
@@ -2321,6 +2468,8 @@ export {
|
|
|
2321
2468
|
getApiConfig,
|
|
2322
2469
|
splitApiOutputsRef,
|
|
2323
2470
|
getSplitApiOutputs,
|
|
2471
|
+
objectStoreEndpointsRef,
|
|
2472
|
+
getObjectStoreEndpoints,
|
|
2324
2473
|
MakeWithConfig,
|
|
2325
2474
|
Make,
|
|
2326
2475
|
}
|
|
@@ -538,3 +538,193 @@ let makeUiBundleDistribution = (
|
|
|
538
538
|
bucketName: bucket.bucket,
|
|
539
539
|
}
|
|
540
540
|
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
Front a set of served buckets with a CloudFront distribution of their own, with
|
|
544
|
+
no SPA bundle behind it.
|
|
545
|
+
|
|
546
|
+
`makeUiBundleDistribution` can already serve buckets, but only as a side car to
|
|
547
|
+
a UI bundle. A platform that deploys no shell — because its UI ships from its
|
|
548
|
+
own stack — still provisions stores and then has nothing to serve them from:
|
|
549
|
+
the bucket is created with an all-true public-access block and takes its read
|
|
550
|
+
grant solely from a distribution's `BucketPolicy`, so with no distribution the
|
|
551
|
+
objects are unreachable by anything.
|
|
552
|
+
|
|
553
|
+
Serving lives with the stack that owns the buckets, and that is not a style
|
|
554
|
+
preference. **S3 permits exactly one bucket policy per bucket.** If the read
|
|
555
|
+
grant were written by whichever stack happened to build a distribution, two
|
|
556
|
+
distributions fronting one store would silently overwrite each other's grant —
|
|
557
|
+
deploying green and 404ing the loser's objects. Keeping the policy next to the
|
|
558
|
+
bucket makes a second writer impossible rather than merely discouraged.
|
|
559
|
+
|
|
560
|
+
Returns the public base URL. A consumer needs that and the store's prefix to
|
|
561
|
+
address an object; it needs no bucket identity at all, which is both a smaller
|
|
562
|
+
contract and one that cannot collide.
|
|
563
|
+
*/
|
|
564
|
+
let makeServedBucketDistribution = (
|
|
565
|
+
~name: string,
|
|
566
|
+
~servedBuckets: array<ReventlessInfra.Platform.servedBucket>,
|
|
567
|
+
~customDomain: option<customDomain>=?,
|
|
568
|
+
): Pulumi.Output.t<string> => {
|
|
569
|
+
let oac = PulumiAws.CloudFront.OriginAccessControl.make(
|
|
570
|
+
~name=name ++ "-oac",
|
|
571
|
+
~args={
|
|
572
|
+
originAccessControlOriginType: Pulumi.Input.make("s3"),
|
|
573
|
+
signingBehavior: Pulumi.Input.make("always"),
|
|
574
|
+
signingProtocol: Pulumi.Input.make("sigv4"),
|
|
575
|
+
},
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
let originIdFor = (id: string): string => "served-" ++ id
|
|
579
|
+
|
|
580
|
+
// CloudFront requires a default behavior, and with no bundle origin there is
|
|
581
|
+
// nothing neutral to point it at. The first served bucket takes it; every
|
|
582
|
+
// real path is matched by its own `{prefix}/*` behavior first, so the default
|
|
583
|
+
// is only reached by a request for a path no store claims — which 404s at S3,
|
|
584
|
+
// the correct answer.
|
|
585
|
+
let defaultOriginId = switch servedBuckets->Array.get(0) {
|
|
586
|
+
| Some(sb) => originIdFor(sb.id)
|
|
587
|
+
| None =>
|
|
588
|
+
JsError.throwWithMessage(
|
|
589
|
+
"Plugin_Stack.makeServedBucketDistribution: no served buckets — call only when at least one store is declared",
|
|
590
|
+
)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
let viewerCertificate: PulumiAws.CloudFront.Distribution.viewerCertificate = switch customDomain {
|
|
594
|
+
| None => {
|
|
595
|
+
cloudfrontDefaultCertificate: Pulumi.Input.make(true),
|
|
596
|
+
}
|
|
597
|
+
| Some({fqdn, hostedZoneId: _}) =>
|
|
598
|
+
let usEast1 = _getUsEast1Provider()
|
|
599
|
+
let cert = PulumiAws.Acm.Certificate.make(
|
|
600
|
+
~name=name ++ "-cert",
|
|
601
|
+
~args={
|
|
602
|
+
domainName: Pulumi.Input.make(fqdn),
|
|
603
|
+
validationMethod: Pulumi.Input.make("DNS"),
|
|
604
|
+
},
|
|
605
|
+
~opts={provider: usEast1},
|
|
606
|
+
)
|
|
607
|
+
{
|
|
608
|
+
acmCertificateArn: cert.arn->Pulumi.Output.asInput,
|
|
609
|
+
sslSupportMethod: Pulumi.Input.make("sni-only"),
|
|
610
|
+
minimumProtocolVersion: Pulumi.Input.make("TLSv1.2_2021"),
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
let distribution = PulumiAws.CloudFront.Distribution.make(
|
|
615
|
+
~name=name ++ "-cdn",
|
|
616
|
+
~args={
|
|
617
|
+
enabled: Pulumi.Input.make(true),
|
|
618
|
+
aliases: ?switch customDomain {
|
|
619
|
+
| None => None
|
|
620
|
+
| Some({fqdn}) => Some(Pulumi.Input.make([fqdn]))
|
|
621
|
+
},
|
|
622
|
+
origins: oac.id->Pulumi.Output.apply(oacId =>
|
|
623
|
+
servedBuckets->Array.map(sb => {
|
|
624
|
+
PulumiAws.CloudFront.Distribution.domainName: sb.bucketRegionalDomainName,
|
|
625
|
+
originId: Pulumi.Input.make(originIdFor(sb.id)),
|
|
626
|
+
originAccessControlId: Pulumi.Input.make(oacId),
|
|
627
|
+
})
|
|
628
|
+
)
|
|
629
|
+
->Pulumi.Output.asInput,
|
|
630
|
+
defaultCacheBehavior: Pulumi.Input.make(
|
|
631
|
+
(
|
|
632
|
+
{
|
|
633
|
+
targetOriginId: Pulumi.Input.make(defaultOriginId),
|
|
634
|
+
viewerProtocolPolicy: Pulumi.Input.make("redirect-to-https"),
|
|
635
|
+
allowedMethods: Pulumi.Input.make(["GET", "HEAD"]),
|
|
636
|
+
cachedMethods: Pulumi.Input.make(["GET", "HEAD"]),
|
|
637
|
+
cachePolicyId: Pulumi.Input.make(cachingOptimizedPolicyId),
|
|
638
|
+
}: PulumiAws.CloudFront.Distribution.defaultCacheBehavior
|
|
639
|
+
),
|
|
640
|
+
),
|
|
641
|
+
// One behavior per served prefix. Store objects have immutable uuid keys,
|
|
642
|
+
// so the long-TTL CachingOptimized policy is safe.
|
|
643
|
+
orderedCacheBehaviors: Pulumi.Input.make(
|
|
644
|
+
servedBuckets->Array.flatMap(sb =>
|
|
645
|
+
sb.prefixes->Array.map(prefix => {
|
|
646
|
+
PulumiAws.CloudFront.Distribution.pathPattern: Pulumi.Input.make(prefix ++ "/*"),
|
|
647
|
+
targetOriginId: Pulumi.Input.make(originIdFor(sb.id)),
|
|
648
|
+
viewerProtocolPolicy: Pulumi.Input.make("redirect-to-https"),
|
|
649
|
+
allowedMethods: Pulumi.Input.make(["GET", "HEAD"]),
|
|
650
|
+
cachedMethods: Pulumi.Input.make(["GET", "HEAD"]),
|
|
651
|
+
cachePolicyId: Pulumi.Input.make(cachingOptimizedPolicyId),
|
|
652
|
+
})
|
|
653
|
+
),
|
|
654
|
+
),
|
|
655
|
+
restrictions: Pulumi.Input.make({
|
|
656
|
+
PulumiAws.CloudFront.Distribution.geoRestriction: Pulumi.Input.make({
|
|
657
|
+
PulumiAws.CloudFront.Distribution.restrictionType: Pulumi.Input.make("none"),
|
|
658
|
+
}),
|
|
659
|
+
}),
|
|
660
|
+
viewerCertificate: Pulumi.Input.make(viewerCertificate),
|
|
661
|
+
comment: Pulumi.Input.make(name ++ " object store CDN"),
|
|
662
|
+
tags: AWS.Tags.make(
|
|
663
|
+
~name=name ++ "-cdn",
|
|
664
|
+
~kind=ReventlessCore.ComponentType.Platform,
|
|
665
|
+
~role=Hosting,
|
|
666
|
+
~scope=Platform,
|
|
667
|
+
),
|
|
668
|
+
},
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
switch customDomain {
|
|
672
|
+
| None => ()
|
|
673
|
+
| Some({fqdn, hostedZoneId}) =>
|
|
674
|
+
let _ = PulumiAws.Route53.Record.make(
|
|
675
|
+
~name=name ++ "-domain-alias",
|
|
676
|
+
~args={
|
|
677
|
+
zoneId: Pulumi.Input.make(hostedZoneId),
|
|
678
|
+
name: Pulumi.Input.make(fqdn),
|
|
679
|
+
type_: Pulumi.Input.make("A"),
|
|
680
|
+
aliases: [
|
|
681
|
+
(
|
|
682
|
+
{
|
|
683
|
+
name: distribution.domainName->Pulumi.Output.asInput,
|
|
684
|
+
zoneId: Pulumi.Input.make(cloudFrontAliasZoneId),
|
|
685
|
+
evaluateTargetHealth: Pulumi.Input.make(false),
|
|
686
|
+
}: PulumiAws.Route53.Record.alias
|
|
687
|
+
),
|
|
688
|
+
]->Pulumi.Input.make,
|
|
689
|
+
},
|
|
690
|
+
)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// One policy per bucket — see the module doc above for why this is here and
|
|
694
|
+
// not in the consuming stack.
|
|
695
|
+
servedBuckets->Array.forEach(sb => {
|
|
696
|
+
let _ = PulumiAws.S3.BucketPolicy.make(
|
|
697
|
+
~name=name ++ "-served-" ++ sb.id ++ "-policy",
|
|
698
|
+
~args={
|
|
699
|
+
bucket: sb.bucketId,
|
|
700
|
+
policy: (sb.bucketArn->Pulumi.Output.fromInput, distribution.arn)
|
|
701
|
+
->Pulumi.Output.all2
|
|
702
|
+
->Pulumi.Output.apply(((bucketArn, distributionArn)) =>
|
|
703
|
+
{
|
|
704
|
+
"Version": "2012-10-17",
|
|
705
|
+
"Statement": [
|
|
706
|
+
{
|
|
707
|
+
"Sid": "AllowCloudFrontServicePrincipal",
|
|
708
|
+
"Effect": "Allow",
|
|
709
|
+
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
|
710
|
+
"Action": "s3:GetObject",
|
|
711
|
+
"Resource": bucketArn ++ "/*",
|
|
712
|
+
"Condition": {
|
|
713
|
+
"StringEquals": {"AWS:SourceArn": distributionArn},
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
],
|
|
717
|
+
}
|
|
718
|
+
->JSON.stringifyAny
|
|
719
|
+
->Option.getUnsafe
|
|
720
|
+
)
|
|
721
|
+
->Pulumi.Output.asInput,
|
|
722
|
+
},
|
|
723
|
+
)
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
switch customDomain {
|
|
727
|
+
| Some({fqdn}) => Pulumi.Output.make("https://" ++ fqdn)
|
|
728
|
+
| None => distribution.domainName->Pulumi.Output.apply(d => "https://" ++ d)
|
|
729
|
+
}
|
|
730
|
+
}
|
|
@@ -303,6 +303,121 @@ function makeUiBundleDistribution(pluginId, bundleVersion, assetsDir, spaFallbac
|
|
|
303
303
|
};
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
function makeServedBucketDistribution(name, servedBuckets, customDomain) {
|
|
307
|
+
let oac = new (Aws.cloudfront.OriginAccessControl)(name + "-oac", {
|
|
308
|
+
originAccessControlOriginType: "s3",
|
|
309
|
+
signingBehavior: "always",
|
|
310
|
+
signingProtocol: "sigv4"
|
|
311
|
+
});
|
|
312
|
+
let sb = servedBuckets[0];
|
|
313
|
+
let defaultOriginId = sb !== undefined ? "served-" + sb.id : Stdlib_JsError.throwWithMessage("Plugin_Stack.makeServedBucketDistribution: no served buckets — call only when at least one store is declared");
|
|
314
|
+
let viewerCertificate;
|
|
315
|
+
if (customDomain !== undefined) {
|
|
316
|
+
let usEast1 = _getUsEast1Provider();
|
|
317
|
+
let cert = new (Aws.acm.Certificate)(name + "-cert", {
|
|
318
|
+
domainName: customDomain.fqdn,
|
|
319
|
+
validationMethod: "DNS"
|
|
320
|
+
}, {
|
|
321
|
+
provider: Primitive_option.some(usEast1)
|
|
322
|
+
});
|
|
323
|
+
viewerCertificate = {
|
|
324
|
+
acmCertificateArn: cert.arn,
|
|
325
|
+
sslSupportMethod: "sni-only",
|
|
326
|
+
minimumProtocolVersion: "TLSv1.2_2021"
|
|
327
|
+
};
|
|
328
|
+
} else {
|
|
329
|
+
viewerCertificate = {
|
|
330
|
+
cloudfrontDefaultCertificate: true
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
let distribution = new (Aws.cloudfront.Distribution)(name + "-cdn", {
|
|
334
|
+
enabled: true,
|
|
335
|
+
aliases: customDomain !== undefined ? [customDomain.fqdn] : undefined,
|
|
336
|
+
origins: oac.id.apply(oacId => servedBuckets.map(sb => ({
|
|
337
|
+
domainName: sb.bucketRegionalDomainName,
|
|
338
|
+
originId: "served-" + sb.id,
|
|
339
|
+
originAccessControlId: oacId
|
|
340
|
+
}))),
|
|
341
|
+
defaultCacheBehavior: {
|
|
342
|
+
targetOriginId: defaultOriginId,
|
|
343
|
+
viewerProtocolPolicy: "redirect-to-https",
|
|
344
|
+
allowedMethods: [
|
|
345
|
+
"GET",
|
|
346
|
+
"HEAD"
|
|
347
|
+
],
|
|
348
|
+
cachedMethods: [
|
|
349
|
+
"GET",
|
|
350
|
+
"HEAD"
|
|
351
|
+
],
|
|
352
|
+
cachePolicyId: cachingOptimizedPolicyId
|
|
353
|
+
},
|
|
354
|
+
orderedCacheBehaviors: servedBuckets.flatMap(sb => sb.prefixes.map(prefix => ({
|
|
355
|
+
pathPattern: prefix + "/*",
|
|
356
|
+
targetOriginId: "served-" + sb.id,
|
|
357
|
+
viewerProtocolPolicy: "redirect-to-https",
|
|
358
|
+
allowedMethods: [
|
|
359
|
+
"GET",
|
|
360
|
+
"HEAD"
|
|
361
|
+
],
|
|
362
|
+
cachedMethods: [
|
|
363
|
+
"GET",
|
|
364
|
+
"HEAD"
|
|
365
|
+
],
|
|
366
|
+
cachePolicyId: cachingOptimizedPolicyId
|
|
367
|
+
}))),
|
|
368
|
+
restrictions: {
|
|
369
|
+
geoRestriction: {
|
|
370
|
+
restrictionType: "none"
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
viewerCertificate: viewerCertificate,
|
|
374
|
+
comment: name + " object store CDN",
|
|
375
|
+
tags: AWS_Tags$ReventlessAws.make(name + "-cdn", "Platform", "Hosting", "Platform", undefined, undefined, undefined, undefined)
|
|
376
|
+
});
|
|
377
|
+
if (customDomain !== undefined) {
|
|
378
|
+
new (Aws.route53.Record)(name + "-domain-alias", {
|
|
379
|
+
zoneId: customDomain.hostedZoneId,
|
|
380
|
+
name: customDomain.fqdn,
|
|
381
|
+
type: "A",
|
|
382
|
+
aliases: [{
|
|
383
|
+
name: distribution.domainName,
|
|
384
|
+
zoneId: cloudFrontAliasZoneId,
|
|
385
|
+
evaluateTargetHealth: false
|
|
386
|
+
}]
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
servedBuckets.forEach(sb => {
|
|
390
|
+
new (Aws.s3.BucketPolicy)(name + "-served-" + sb.id + "-policy", {
|
|
391
|
+
bucket: sb.bucketId,
|
|
392
|
+
policy: Pulumi.all([
|
|
393
|
+
sb.bucketArn,
|
|
394
|
+
distribution.arn
|
|
395
|
+
]).apply(param => JSON.stringify({
|
|
396
|
+
Version: "2012-10-17",
|
|
397
|
+
Statement: [{
|
|
398
|
+
Sid: "AllowCloudFrontServicePrincipal",
|
|
399
|
+
Effect: "Allow",
|
|
400
|
+
Principal: {
|
|
401
|
+
Service: "cloudfront.amazonaws.com"
|
|
402
|
+
},
|
|
403
|
+
Action: "s3:GetObject",
|
|
404
|
+
Resource: param[0] + "/*",
|
|
405
|
+
Condition: {
|
|
406
|
+
StringEquals: {
|
|
407
|
+
"AWS:SourceArn": param[1]
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}]
|
|
411
|
+
}))
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
if (customDomain !== undefined) {
|
|
415
|
+
return Pulumi.output("https://" + customDomain.fqdn);
|
|
416
|
+
} else {
|
|
417
|
+
return distribution.domainName.apply(d => "https://" + d);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
306
421
|
export {
|
|
307
422
|
log,
|
|
308
423
|
cachingOptimizedPolicyId,
|
|
@@ -313,5 +428,6 @@ export {
|
|
|
313
428
|
cacheControlFor,
|
|
314
429
|
invalidateDistribution,
|
|
315
430
|
makeUiBundleDistribution,
|
|
431
|
+
makeServedBucketDistribution,
|
|
316
432
|
}
|
|
317
433
|
/* log Not a pure module */
|
|
@@ -34,6 +34,27 @@ type protection =
|
|
|
34
34
|
| Protected
|
|
35
35
|
| Unprotected
|
|
36
36
|
|
|
37
|
+
/** Whether the platform provisions everything a plugin's fields declare. */
|
|
38
|
+
type coverage =
|
|
39
|
+
/** Every declared store is provisioned. */
|
|
40
|
+
| Covered
|
|
41
|
+
/** The platform provisions no stores at all — it has not adopted capability
|
|
42
|
+
provisioning, which is a different situation from getting it wrong. */
|
|
43
|
+
| NotAdopted(array<string>)
|
|
44
|
+
/** The platform provisions stores, but not these. Carries what it does
|
|
45
|
+
provision, because the usual cause is a near-miss worth showing. */
|
|
46
|
+
| Missing({missing: array<string>, provisioned: array<string>})
|
|
47
|
+
|
|
48
|
+
/** Which distribution fronts the declared stores. */
|
|
49
|
+
type serving =
|
|
50
|
+
/** No store is declared, so nothing is served. */
|
|
51
|
+
| NoStores
|
|
52
|
+
/** The host shell's own distribution serves them same-origin, so a minted
|
|
53
|
+
`/{prefix}/…` ref resolves relative and there is no base URL. */
|
|
54
|
+
| HostShell
|
|
55
|
+
/** The platform fronts them itself, because no host shell is deployed. */
|
|
56
|
+
| PlatformOwned
|
|
57
|
+
|
|
37
58
|
/**
|
|
38
59
|
Stack-name prefixes whose stacks are disposable.
|
|
39
60
|
|
|
@@ -102,3 +123,53 @@ The cost is a slightly redundant prefix inside a dedicated bucket
|
|
|
102
123
|
(`catalog-productImages/productImages/…`). Take the redundancy.
|
|
103
124
|
*/
|
|
104
125
|
let keyPrefixFor = (~store: string): string => store
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
Who serves the declared stores — and the answer is never "both".
|
|
129
|
+
|
|
130
|
+
A store's bucket blocks public policy and takes its read grant solely from a
|
|
131
|
+
distribution's `BucketPolicy`. **S3 permits exactly one bucket policy per
|
|
132
|
+
bucket**, so two distributions fronting one store would each write that single
|
|
133
|
+
policy and silently unpick the other's grant: green deploy, 404s afterwards.
|
|
134
|
+
Making this one function's return a three-way choice is what keeps "both" from
|
|
135
|
+
being expressible.
|
|
136
|
+
|
|
137
|
+
The polarity is the useful part. Provisioning a store and serving it are
|
|
138
|
+
separate; before this, serving happened only as a side car to a host-UI bundle,
|
|
139
|
+
so a platform whose UI shipped from its own stack provisioned stores that
|
|
140
|
+
nothing could read.
|
|
141
|
+
*/
|
|
142
|
+
let servingFor = (~hasHostUiBundle: bool, ~declaredBucketCount: int): serving =>
|
|
143
|
+
switch (hasHostUiBundle, declaredBucketCount) {
|
|
144
|
+
| (_, 0) => NoStores
|
|
145
|
+
| (true, _) => HostShell
|
|
146
|
+
| (false, _) => PlatformOwned
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
Does the platform provision what the plugin's fields declare?
|
|
151
|
+
|
|
152
|
+
The split-stack ordering hazard, stated as a set difference: the platform
|
|
153
|
+
deploys before the plugin and cannot read its schemas, so its capability list is
|
|
154
|
+
written by hand and can simply be wrong. `required` comes from
|
|
155
|
+
`pluginStructure.requiredStores`; `provisioned` from the platform's exported
|
|
156
|
+
`objectStores` keys. Both are qualified `{plugin}.{store}`.
|
|
157
|
+
|
|
158
|
+
**Three outcomes, not two.** A platform provisioning nothing has not adopted
|
|
159
|
+
capability provisioning; failing it would break deployments that work today. A
|
|
160
|
+
platform provisioning *some* stores but not this one has adopted it and has a
|
|
161
|
+
missing or misspelled entry. Collapsing those two into one verdict forces a
|
|
162
|
+
choice between breaking the first group and not helping the second.
|
|
163
|
+
|
|
164
|
+
Worth being strict about because every symptom is silent: the upload input finds
|
|
165
|
+
no per-store endpoint, falls back to the legacy single service, and writes to
|
|
166
|
+
whatever bucket that serves — a 2xx, a plausible ref, and the wrong destination.
|
|
167
|
+
*/
|
|
168
|
+
let coverageFor = (~required: array<string>, ~provisioned: array<string>): coverage => {
|
|
169
|
+
let missing = required->Array.filter(r => !(provisioned->Array.includes(r)))
|
|
170
|
+
switch (missing, provisioned) {
|
|
171
|
+
| ([], _) => Covered
|
|
172
|
+
| (missing, []) => NotAdopted(missing)
|
|
173
|
+
| (missing, provisioned) => Missing({missing, provisioned})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -32,11 +32,45 @@ function keyPrefixFor(store) {
|
|
|
32
32
|
return store;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function servingFor(hasHostUiBundle, declaredBucketCount) {
|
|
36
|
+
if (declaredBucketCount !== 0) {
|
|
37
|
+
if (hasHostUiBundle) {
|
|
38
|
+
return "HostShell";
|
|
39
|
+
} else {
|
|
40
|
+
return "PlatformOwned";
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
return "NoStores";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function coverageFor(required, provisioned) {
|
|
48
|
+
let missing = required.filter(r => !provisioned.includes(r));
|
|
49
|
+
if (missing.length !== 0) {
|
|
50
|
+
if (provisioned.length !== 0) {
|
|
51
|
+
return {
|
|
52
|
+
TAG: "Missing",
|
|
53
|
+
missing: missing,
|
|
54
|
+
provisioned: provisioned
|
|
55
|
+
};
|
|
56
|
+
} else {
|
|
57
|
+
return {
|
|
58
|
+
TAG: "NotAdopted",
|
|
59
|
+
_0: missing
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
return "Covered";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
35
67
|
export {
|
|
36
68
|
defaultEphemeralPrefixes,
|
|
37
69
|
layoutFor,
|
|
38
70
|
protectionFor,
|
|
39
71
|
bucketNameFor,
|
|
40
72
|
keyPrefixFor,
|
|
73
|
+
servingFor,
|
|
74
|
+
coverageFor,
|
|
41
75
|
}
|
|
42
76
|
/* No side effect */
|
|
@@ -40,6 +40,120 @@ describe("Util_StoreLayout.layoutFor", () => {
|
|
|
40
40
|
)
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
+
describe("Util_StoreLayout.servingFor", () => {
|
|
44
|
+
testSync("no declared store means nothing to serve", () =>
|
|
45
|
+
expect(Util_StoreLayout.servingFor(~hasHostUiBundle=false, ~declaredBucketCount=0))->toEqual(
|
|
46
|
+
Util_StoreLayout.NoStores,
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
testSync("a host shell serves the stores from its own origin", () =>
|
|
51
|
+
expect(Util_StoreLayout.servingFor(~hasHostUiBundle=true, ~declaredBucketCount=1))->toEqual(
|
|
52
|
+
Util_StoreLayout.HostShell,
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// The case that was previously unrepresentable and produced a write-only
|
|
57
|
+
// store: stores are provisioned unconditionally, but serving used to happen
|
|
58
|
+
// only as a side car to a host-UI bundle.
|
|
59
|
+
testSync("no host shell means the platform serves them itself", () =>
|
|
60
|
+
expect(Util_StoreLayout.servingFor(~hasHostUiBundle=false, ~declaredBucketCount=1))->toEqual(
|
|
61
|
+
Util_StoreLayout.PlatformOwned,
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
// A bucket carries exactly one policy, so two distributions fronting one
|
|
66
|
+
// store would unpick each other's read grant. The three-way return is what
|
|
67
|
+
// makes "both" unrepresentable — asserted so it stays that way.
|
|
68
|
+
testSync("a host shell wins even with several buckets — never both", () =>
|
|
69
|
+
expect(Util_StoreLayout.servingFor(~hasHostUiBundle=true, ~declaredBucketCount=3))->toEqual(
|
|
70
|
+
Util_StoreLayout.HostShell,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
// Declaring nothing outranks having a shell: with no store there is no
|
|
75
|
+
// bucket, no policy and no origin, whoever is deployed.
|
|
76
|
+
testSync("no store outranks a host shell", () =>
|
|
77
|
+
expect(Util_StoreLayout.servingFor(~hasHostUiBundle=true, ~declaredBucketCount=0))->toEqual(
|
|
78
|
+
Util_StoreLayout.NoStores,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe("Util_StoreLayout.coverageFor", () => {
|
|
84
|
+
testSync("everything declared is provisioned", () =>
|
|
85
|
+
expect(
|
|
86
|
+
Util_StoreLayout.coverageFor(
|
|
87
|
+
~required=["Catalog.productImages"],
|
|
88
|
+
~provisioned=["Catalog.productImages"],
|
|
89
|
+
),
|
|
90
|
+
)->toEqual(Util_StoreLayout.Covered)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
testSync("declaring nothing is covered, whatever the platform provisions", () =>
|
|
94
|
+
expect(Util_StoreLayout.coverageFor(~required=[], ~provisioned=["Catalog.productImages"]))->toEqual(
|
|
95
|
+
Util_StoreLayout.Covered,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
// A platform provisioning nothing has not adopted capability provisioning.
|
|
100
|
+
// Failing it would break deployments that work today, so this is the arm that
|
|
101
|
+
// must NOT be a hard error.
|
|
102
|
+
testSync("a platform provisioning nothing has not adopted, rather than got it wrong", () =>
|
|
103
|
+
expect(Util_StoreLayout.coverageFor(~required=["Catalog.productImages"], ~provisioned=[]))->toEqual(
|
|
104
|
+
Util_StoreLayout.NotAdopted(["Catalog.productImages"]),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
// The case that shipped: the platform declared the store under a lowercased
|
|
109
|
+
// plugin name, so both sides had a productImages store and neither matched.
|
|
110
|
+
// It carries what IS provisioned because the cause is usually a near-miss.
|
|
111
|
+
testSync("a near-miss reports both sides — this is the case-slip shape", () =>
|
|
112
|
+
expect(
|
|
113
|
+
Util_StoreLayout.coverageFor(
|
|
114
|
+
~required=["Catalog.productImages"],
|
|
115
|
+
~provisioned=["catalog.productImages"],
|
|
116
|
+
),
|
|
117
|
+
)->toEqual(
|
|
118
|
+
Util_StoreLayout.Missing({
|
|
119
|
+
missing: ["Catalog.productImages"],
|
|
120
|
+
provisioned: ["catalog.productImages"],
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
testSync("only the uncovered stores are reported missing", () =>
|
|
126
|
+
expect(
|
|
127
|
+
Util_StoreLayout.coverageFor(
|
|
128
|
+
~required=["Catalog.productImages", "Catalog.manuals"],
|
|
129
|
+
~provisioned=["Catalog.productImages"],
|
|
130
|
+
),
|
|
131
|
+
)->toEqual(
|
|
132
|
+
Util_StoreLayout.Missing({
|
|
133
|
+
missing: ["Catalog.manuals"],
|
|
134
|
+
provisioned: ["Catalog.productImages"],
|
|
135
|
+
}),
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
// Matching is exact. Suffix or case-insensitive matching would "fix" the
|
|
140
|
+
// case slip above by silently binding to the wrong store, and two plugins may
|
|
141
|
+
// legitimately name a store the same.
|
|
142
|
+
testSync("matching is exact — a suffix match is not a match", () =>
|
|
143
|
+
expect(
|
|
144
|
+
Util_StoreLayout.coverageFor(
|
|
145
|
+
~required=["Catalog.productImages"],
|
|
146
|
+
~provisioned=["Ordering.productImages"],
|
|
147
|
+
),
|
|
148
|
+
)->toEqual(
|
|
149
|
+
Util_StoreLayout.Missing({
|
|
150
|
+
missing: ["Catalog.productImages"],
|
|
151
|
+
provisioned: ["Ordering.productImages"],
|
|
152
|
+
}),
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
43
157
|
describe("Util_StoreLayout.protectionFor", () => {
|
|
44
158
|
// The pairing a single layout-driven switch would have got wrong: alpha
|
|
45
159
|
// shares a bucket and is still protected.
|
|
@@ -27,6 +27,63 @@ globalThis.describe("Util_StoreLayout.layoutFor", () => {
|
|
|
27
27
|
});
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
globalThis.describe("Util_StoreLayout.servingFor", () => {
|
|
31
|
+
globalThis.test("no declared store means nothing to serve", () => {
|
|
32
|
+
globalThis.expect(Util_StoreLayout$ReventlessAws.servingFor(false, 0)).toEqual("NoStores");
|
|
33
|
+
});
|
|
34
|
+
globalThis.test("a host shell serves the stores from its own origin", () => {
|
|
35
|
+
globalThis.expect(Util_StoreLayout$ReventlessAws.servingFor(true, 1)).toEqual("HostShell");
|
|
36
|
+
});
|
|
37
|
+
globalThis.test("no host shell means the platform serves them itself", () => {
|
|
38
|
+
globalThis.expect(Util_StoreLayout$ReventlessAws.servingFor(false, 1)).toEqual("PlatformOwned");
|
|
39
|
+
});
|
|
40
|
+
globalThis.test("a host shell wins even with several buckets — never both", () => {
|
|
41
|
+
globalThis.expect(Util_StoreLayout$ReventlessAws.servingFor(true, 3)).toEqual("HostShell");
|
|
42
|
+
});
|
|
43
|
+
globalThis.test("no store outranks a host shell", () => {
|
|
44
|
+
globalThis.expect(Util_StoreLayout$ReventlessAws.servingFor(true, 0)).toEqual("NoStores");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
globalThis.describe("Util_StoreLayout.coverageFor", () => {
|
|
49
|
+
globalThis.test("everything declared is provisioned", () => {
|
|
50
|
+
globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor(["Catalog.productImages"], ["Catalog.productImages"])).toEqual("Covered");
|
|
51
|
+
});
|
|
52
|
+
globalThis.test("declaring nothing is covered, whatever the platform provisions", () => {
|
|
53
|
+
globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor([], ["Catalog.productImages"])).toEqual("Covered");
|
|
54
|
+
});
|
|
55
|
+
globalThis.test("a platform provisioning nothing has not adopted, rather than got it wrong", () => {
|
|
56
|
+
globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor(["Catalog.productImages"], [])).toEqual({
|
|
57
|
+
TAG: "NotAdopted",
|
|
58
|
+
_0: ["Catalog.productImages"]
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
globalThis.test("a near-miss reports both sides — this is the case-slip shape", () => {
|
|
62
|
+
globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor(["Catalog.productImages"], ["catalog.productImages"])).toEqual({
|
|
63
|
+
TAG: "Missing",
|
|
64
|
+
missing: ["Catalog.productImages"],
|
|
65
|
+
provisioned: ["catalog.productImages"]
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
globalThis.test("only the uncovered stores are reported missing", () => {
|
|
69
|
+
globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor([
|
|
70
|
+
"Catalog.productImages",
|
|
71
|
+
"Catalog.manuals"
|
|
72
|
+
], ["Catalog.productImages"])).toEqual({
|
|
73
|
+
TAG: "Missing",
|
|
74
|
+
missing: ["Catalog.manuals"],
|
|
75
|
+
provisioned: ["Catalog.productImages"]
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
globalThis.test("matching is exact — a suffix match is not a match", () => {
|
|
79
|
+
globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor(["Catalog.productImages"], ["Ordering.productImages"])).toEqual({
|
|
80
|
+
TAG: "Missing",
|
|
81
|
+
missing: ["Catalog.productImages"],
|
|
82
|
+
provisioned: ["Ordering.productImages"]
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
30
87
|
globalThis.describe("Util_StoreLayout.protectionFor", () => {
|
|
31
88
|
globalThis.test("alpha shares a bucket and is still protected", () => {
|
|
32
89
|
globalThis.expect(Util_StoreLayout$ReventlessAws.layoutFor("alpha", Util_HostUiDomain$ReventlessAws.defaultProdStacks)).toEqual("SharedBucket");
|