@reventlessdev/reventless-aws 3.0.0-alpha.272 → 3.0.0-alpha.274
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 +19 -0
- package/package.json +8 -7
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +15 -13
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +3 -2
- package/src/adapter/Api/Platform_UIFragments_Lambda.res +15 -13
- package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +3 -2
- package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res +8 -1
- package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res.mjs +3 -2
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res +15 -13
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res.mjs +3 -2
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +6 -12
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +4 -8
- package/src/adapter/QueryDb/QueryInterceptor_Provisioning.res +155 -0
- package/src/adapter/QueryDb/QueryInterceptor_Provisioning.res.mjs +92 -0
- package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res +4 -9
- package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res.mjs +8 -2
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +32 -62
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +7 -20
- package/src/adapter/StateTopic/StateTopic_AppSync.res +15 -13
- package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +3 -2
- package/src/adapter/Upload/Upload_Claim_S3.res +15 -13
- package/src/adapter/Upload/Upload_Claim_S3.res.mjs +3 -2
- package/src/adapter/Upload/Upload_Presign_S3.res +15 -13
- package/src/adapter/Upload/Upload_Presign_S3.res.mjs +3 -2
- package/src/components/Api/AppSync_Adapter.res +13 -9
- package/src/components/Api/AppSync_Adapter.res.mjs +4 -3
- package/src/util/Util_LambdaLogging.res +83 -21
- package/src/util/Util_LambdaLogging.res.mjs +21 -8
- package/src/util/Util_LogGroup_Adopting.res +480 -0
- package/src/util/Util_LogGroup_Adopting.res.mjs +365 -0
- package/src/util/Util_LogGroup_Adopting_Js.mjs +20 -0
- package/src/util/Util_LogRetention.res +11 -12
- package/tests/Util_LambdaLoggingTest.res +48 -0
- package/tests/Util_LambdaLoggingTest.res.mjs +30 -0
- package/tests/Util_LogGroup_AdoptingTest.res +169 -0
- package/tests/Util_LogGroup_AdoptingTest.res.mjs +208 -0
|
@@ -44,40 +44,102 @@ let applyLogLevelDefault = (variables: dict<Pulumi.Input.t<string>>) =>
|
|
|
44
44
|
variables->Dict.set(key, value)
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
/** The managed log group name for a Lambda — chosen by the program, never
|
|
48
|
+
derived from the function's physical name output. Two properties follow from
|
|
49
|
+
choosing it:
|
|
50
|
+
|
|
51
|
+
- **stable**: it carries no `-<7hex>` Pulumi suffix, so replacing a function
|
|
52
|
+
keeps its group instead of stranding the old one as an orphan nothing
|
|
53
|
+
tears down.
|
|
54
|
+
- **deployment-scoped**: deployments sharing an account cannot collide, which
|
|
55
|
+
was the reason the physical name was used before.
|
|
56
|
+
|
|
57
|
+
The scope is `project`/`stack`, and **both halves are load-bearing**. A stack
|
|
58
|
+
name alone is not a discriminator: every plugin of one platform is deployed as
|
|
59
|
+
its own Pulumi project under the *same* stack name (`alpha`, `beta`, …), and
|
|
60
|
+
the components inside them are named by role rather than by owner — so several
|
|
61
|
+
projects each host an `AllStateViewSlices`, an `AllReadModels`, an
|
|
62
|
+
`AllAggregatesCmdHandler`. Scoping on the stack alone gives all of them one
|
|
63
|
+
name, and every deployment after the first fails with
|
|
64
|
+
`ResourceAlreadyExistsException`.
|
|
65
|
+
|
|
66
|
+
Pure, so the naming is decidable without Pulumi. */
|
|
67
|
+
let logGroupNameFor = (~project: string, ~stack: string, ~name: string): string =>
|
|
68
|
+
`/aws/lambda/${project}-${stack}-${name}`
|
|
69
|
+
|
|
70
|
+
// Create the managed CloudWatch log group for a Lambda **before** the function
|
|
71
|
+
// that writes to it, so the function can be pointed at it via `loggingConfig`
|
|
72
|
+
// (see `loggingConfigFor`) and Lambda never auto-creates a group of its own.
|
|
73
|
+
// That ordering is the whole point: a group created *after* its function races
|
|
74
|
+
// the auto-create the function's first invocation triggers, and a lost race is
|
|
75
|
+
// permanent — the group persists, so every retry fails the same way.
|
|
76
|
+
//
|
|
77
|
+
// Created when the stack manages its groups (`Util_LogRetention.managesLogGroup`)
|
|
78
|
+
// or the caller pins `~retentionDaysOverride` (the app-developer opt-in, which
|
|
79
|
+
// wins over the tier default and applies even on an unmanaged stack). `None`
|
|
80
|
+
// means the group is left to Lambda, with no retention.
|
|
81
|
+
//
|
|
82
|
+
// The caller supplies `~tags` (Logs-role, its own attribution convention) and
|
|
83
|
+
// `~opts` (so the group parents to the same component and is torn down with it).
|
|
84
|
+
// The trailing `()` terminates the optional arguments.
|
|
55
85
|
let makeManagedLogGroup = (
|
|
56
86
|
~name: string,
|
|
57
|
-
~
|
|
87
|
+
~retentionDaysOverride: option<int>=?,
|
|
58
88
|
~tags,
|
|
59
89
|
~opts=?,
|
|
60
90
|
(),
|
|
61
|
-
) => {
|
|
91
|
+
): option<Cloudwatch.LogGroup.t> => {
|
|
62
92
|
let (stack, prodStacks, unmanagedStacks) = stackContext()
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
93
|
+
let retentionDays = switch (
|
|
94
|
+
retentionDaysOverride,
|
|
95
|
+
Util_LogRetention.managesLogGroup(~stack, ~unmanagedStacks),
|
|
96
|
+
) {
|
|
97
|
+
| (Some(days), _) => Some(days)
|
|
98
|
+
| (None, true) =>
|
|
99
|
+
Some(
|
|
100
|
+
Util_LogRetention.retentionDaysFor(
|
|
101
|
+
~stack,
|
|
102
|
+
~prodStacks,
|
|
103
|
+
~configOverride=?Util_LocalConfig.get("logRetentionDays")->Option.flatMap(s =>
|
|
104
|
+
Int.fromString(s)
|
|
105
|
+
),
|
|
69
106
|
),
|
|
70
107
|
)
|
|
71
|
-
|
|
108
|
+
| (None, false) => None
|
|
109
|
+
}
|
|
110
|
+
retentionDays->Option.map(days =>
|
|
111
|
+
Cloudwatch.LogGroup.make(
|
|
72
112
|
~name=`${name}LogGroup`,
|
|
73
113
|
~args={
|
|
74
|
-
name:
|
|
75
|
-
|
|
76
|
-
|
|
114
|
+
name: logGroupNameFor(
|
|
115
|
+
~project=Pulumi.Pulumi.getProjectName(),
|
|
116
|
+
~stack,
|
|
117
|
+
~name,
|
|
118
|
+
)->Pulumi.Input.make,
|
|
77
119
|
retentionInDays: days->Pulumi.Input.make,
|
|
78
120
|
tags,
|
|
79
121
|
},
|
|
80
122
|
~opts?,
|
|
81
123
|
)
|
|
82
|
-
|
|
124
|
+
)
|
|
83
125
|
}
|
|
126
|
+
|
|
127
|
+
// The `loggingConfig` that points a function at the group `makeManagedLogGroup`
|
|
128
|
+
// just created. Reading `logGroup.name` is also what orders the two: Pulumi sees
|
|
129
|
+
// the function depend on the group, so the group exists first and the function
|
|
130
|
+
// has somewhere to write from its very first invocation.
|
|
131
|
+
//
|
|
132
|
+
// `None` when the group is unmanaged — the function then carries no
|
|
133
|
+
// `loggingConfig` at all and keeps Lambda's auto-created group, exactly as
|
|
134
|
+
// before. `Text` is AWS's own default format, so no log line changes shape.
|
|
135
|
+
let loggingConfigFor = (logGroup: option<Cloudwatch.LogGroup.t>): option<
|
|
136
|
+
Pulumi.Input.t<Lambda.Function.loggingConfig>,
|
|
137
|
+
> =>
|
|
138
|
+
logGroup->Option.map(group =>
|
|
139
|
+
(
|
|
140
|
+
{
|
|
141
|
+
logFormat: "Text"->Pulumi.Input.make,
|
|
142
|
+
logGroup: group.name->Pulumi.Output.asInput,
|
|
143
|
+
}: Lambda.Function.loggingConfig
|
|
144
|
+
)->Pulumi.Input.make
|
|
145
|
+
)
|
|
@@ -36,24 +36,37 @@ function applyLogLevelDefault(variables) {
|
|
|
36
36
|
variables[match[0]] = match[1];
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function
|
|
39
|
+
function logGroupNameFor(project, stack, name) {
|
|
40
|
+
return `/aws/lambda/` + project + `-` + stack + `-` + name;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeManagedLogGroup(name, retentionDaysOverride, tags, opts, param) {
|
|
40
44
|
let match = stackContext();
|
|
41
45
|
let stack = match[0];
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
new (Aws.cloudwatch.LogGroup)(name + `LogGroup`, {
|
|
47
|
-
name:
|
|
46
|
+
let match$1 = Util_LogRetention$ReventlessAws.managesLogGroup(stack, match[2]);
|
|
47
|
+
let retentionDays = retentionDaysOverride !== undefined ? retentionDaysOverride : (
|
|
48
|
+
match$1 ? Util_LogRetention$ReventlessAws.retentionDaysFor(stack, match[1], Stdlib_Option.flatMap(Util_LocalConfig$ReventlessAws.get("logRetentionDays"), s => Stdlib_Int.fromString(s, undefined))) : undefined
|
|
49
|
+
);
|
|
50
|
+
return Stdlib_Option.map(retentionDays, days => new (Aws.cloudwatch.LogGroup)(name + `LogGroup`, {
|
|
51
|
+
name: logGroupNameFor(Pulumi.getProject(), stack, name),
|
|
48
52
|
retentionInDays: days,
|
|
49
53
|
tags: tags
|
|
50
|
-
}, opts !== undefined ? Primitive_option.valFromOption(opts) : undefined);
|
|
54
|
+
}, opts !== undefined ? Primitive_option.valFromOption(opts) : undefined));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loggingConfigFor(logGroup) {
|
|
58
|
+
return Stdlib_Option.map(logGroup, group => ({
|
|
59
|
+
logFormat: "Text",
|
|
60
|
+
logGroup: group.name
|
|
61
|
+
}));
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
export {
|
|
54
65
|
stackContext,
|
|
55
66
|
logLevelEntry,
|
|
56
67
|
applyLogLevelDefault,
|
|
68
|
+
logGroupNameFor,
|
|
57
69
|
makeManagedLogGroup,
|
|
70
|
+
loggingConfigFor,
|
|
58
71
|
}
|
|
59
72
|
/* @pulumi/aws Not a pure module */
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/** A CloudWatch log group that **adopts an existing group instead of failing**.
|
|
2
|
+
|
|
3
|
+
`Cloudwatch.LogGroup` cannot: its create is `CreateLogGroup`, which fails
|
|
4
|
+
`ResourceAlreadyExistsException` when the group is already there, and the
|
|
5
|
+
failure does not heal on retry because the group persists. That is fatal
|
|
6
|
+
wherever the deploy cannot get in first — an AppSync API's group name is
|
|
7
|
+
`/aws/appsync/apis/<server-assigned id>`, so the group can only ever be
|
|
8
|
+
created after the API, and any request the API serves in between makes the
|
|
9
|
+
group before the deploy reaches it. From then on every deploy of that stack
|
|
10
|
+
fails on the group rather than on whatever it was actually changing.
|
|
11
|
+
|
|
12
|
+
The obvious mechanism, Pulumi's `import` resource option, does not reach this
|
|
13
|
+
case. `import` takes a plan-time `string`, and neither half is one: deciding
|
|
14
|
+
whether the group exists means awaiting `getLogGroup` (a `Promise`, or an
|
|
15
|
+
`Output`) inside a synchronous resource declaration, and the AppSync group's
|
|
16
|
+
name is itself an `Output` of the api id. Passing `import` unconditionally is
|
|
17
|
+
not a fallback — importing something absent is its own error.
|
|
18
|
+
|
|
19
|
+
So this provider removes the decision rather than making it. Retention and
|
|
20
|
+
tags are applied with calls that do not care whether the group already
|
|
21
|
+
exists (`CreateLogGroup` tolerated when it reports `ResourceAlreadyExists`,
|
|
22
|
+
then `PutRetentionPolicy` / `TagResource`), which makes create idempotent by
|
|
23
|
+
construction: nothing has to be known synchronously, and the group name can
|
|
24
|
+
be an `Output` because it arrives as a resource *input* rather than a
|
|
25
|
+
resource *option*.
|
|
26
|
+
|
|
27
|
+
Delete tears the group down, so teardown behaves as the declarative resource
|
|
28
|
+
did and `unmanagedLogGroupStacks` keeps its meaning. Adoption is reported at
|
|
29
|
+
info level, so a deploy that adopts says so rather than passing silently.
|
|
30
|
+
|
|
31
|
+
**Lambda does not need this.** There the deploy names the group itself and
|
|
32
|
+
creates it before the function that writes to it, under a name AWS never
|
|
33
|
+
mints — see `Util_LambdaLogging`. Ordering is the better fix where ordering
|
|
34
|
+
is available; this is for where it is not.
|
|
35
|
+
|
|
36
|
+
Caveat inherited from every Pulumi dynamic provider: the whole captured
|
|
37
|
+
closure is serialised into stack state, and `create`/`update`/`delete`/`read`
|
|
38
|
+
run the **serialised** version, not current source. A fix here reaches a
|
|
39
|
+
given resource only once that resource is next created or updated. Keep each
|
|
40
|
+
step able to handle state written by earlier versions. */
|
|
41
|
+
|
|
42
|
+
let log = ReventlessCore.Logger.fromEnv()
|
|
43
|
+
|
|
44
|
+
// ── AWS SDK bindings (lazily imported — see AppSync_SourceApiAssociation_Retrying) ──
|
|
45
|
+
|
|
46
|
+
type logsClient
|
|
47
|
+
|
|
48
|
+
type nameInput = {logGroupName: string}
|
|
49
|
+
type createInput = {logGroupName: string, tags?: dict<string>}
|
|
50
|
+
type retentionInput = {logGroupName: string, retentionInDays: int}
|
|
51
|
+
type tagInput = {resourceArn: string, tags: dict<string>}
|
|
52
|
+
type describeInput = {logGroupNamePrefix: string}
|
|
53
|
+
|
|
54
|
+
type logGroupSummary = {
|
|
55
|
+
logGroupName?: string,
|
|
56
|
+
arn?: string,
|
|
57
|
+
retentionInDays?: int,
|
|
58
|
+
}
|
|
59
|
+
type describeResult = {logGroups?: array<logGroupSummary>}
|
|
60
|
+
|
|
61
|
+
type createCmd
|
|
62
|
+
type retentionCmd
|
|
63
|
+
type deleteRetentionCmd
|
|
64
|
+
type tagCmd
|
|
65
|
+
type describeCmd
|
|
66
|
+
type deleteCmd
|
|
67
|
+
|
|
68
|
+
// Shape of the dynamically-imported `@aws-sdk/client-cloudwatch-logs` module.
|
|
69
|
+
type sdkModule = {
|
|
70
|
+
@as("CloudWatchLogsClient") clientCtor: unit => logsClient,
|
|
71
|
+
@as("CreateLogGroupCommand") createCtor: createInput => createCmd,
|
|
72
|
+
@as("PutRetentionPolicyCommand") retentionCtor: retentionInput => retentionCmd,
|
|
73
|
+
@as("DeleteRetentionPolicyCommand") deleteRetentionCtor: nameInput => deleteRetentionCmd,
|
|
74
|
+
@as("TagResourceCommand") tagCtor: tagInput => tagCmd,
|
|
75
|
+
@as("DescribeLogGroupsCommand") describeCtor: describeInput => describeCmd,
|
|
76
|
+
@as("DeleteLogGroupCommand") deleteCtor: nameInput => deleteCmd,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@module("./Util_LogGroup_Adopting_Js.mjs") external newCommand: ('ctor, 'arg) => 'cmd = "newCommand"
|
|
80
|
+
@module("./Util_LogGroup_Adopting_Js.mjs") external newClient: 'ctor => logsClient = "newClient"
|
|
81
|
+
@module("./Util_LogGroup_Adopting_Js.mjs") external rethrow: JsExn.t => 'a = "rethrow"
|
|
82
|
+
|
|
83
|
+
@send external sendCreate: (logsClient, createCmd) => promise<unit> = "send"
|
|
84
|
+
@send external sendRetention: (logsClient, retentionCmd) => promise<unit> = "send"
|
|
85
|
+
@send external sendDeleteRetention: (logsClient, deleteRetentionCmd) => promise<unit> = "send"
|
|
86
|
+
@send external sendTag: (logsClient, tagCmd) => promise<unit> = "send"
|
|
87
|
+
@send external sendDescribe: (logsClient, describeCmd) => promise<describeResult> = "send"
|
|
88
|
+
@send external sendDelete: (logsClient, deleteCmd) => promise<unit> = "send"
|
|
89
|
+
|
|
90
|
+
// Dynamic ESM import — emitted as a literal `import("...")` so the Pulumi
|
|
91
|
+
// serialiser does not statically capture the SDK in the provider closure.
|
|
92
|
+
@val external dynImport: string => promise<sdkModule> = "import"
|
|
93
|
+
|
|
94
|
+
let _sdk: ref<option<sdkModule>> = ref(None)
|
|
95
|
+
let _client: ref<option<logsClient>> = ref(None)
|
|
96
|
+
|
|
97
|
+
let getSdk = async (): sdkModule =>
|
|
98
|
+
switch _sdk.contents {
|
|
99
|
+
| Some(m) => m
|
|
100
|
+
| None =>
|
|
101
|
+
let m = await dynImport("@aws-sdk/client-cloudwatch-logs")
|
|
102
|
+
_sdk.contents = Some(m)
|
|
103
|
+
m
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let getClient = async (): logsClient =>
|
|
107
|
+
switch _client.contents {
|
|
108
|
+
| Some(c) => c
|
|
109
|
+
| None =>
|
|
110
|
+
let sdk = await getSdk()
|
|
111
|
+
let c = newClient(sdk.clientCtor)
|
|
112
|
+
_client.contents = Some(c)
|
|
113
|
+
c
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Error classification ──────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
@get @return(nullable) external exnName: JsExn.t => option<string> = "name"
|
|
119
|
+
|
|
120
|
+
let matchesAws = (jsErr: JsExn.t, code: string): bool =>
|
|
121
|
+
switch (jsErr->exnName, JsExn.message(jsErr)) {
|
|
122
|
+
| (Some(name), _) if name == code => true
|
|
123
|
+
| (_, Some(msg)) => msg->String.includes(code)
|
|
124
|
+
| _ => false
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The group is already there — the whole point of this provider. */
|
|
128
|
+
let isAlreadyExistsError = (jsErr: JsExn.t): bool =>
|
|
129
|
+
jsErr->matchesAws("ResourceAlreadyExistsException")
|
|
130
|
+
|
|
131
|
+
/** The group is gone, so a delete or a retention/tag write is a no-op. */
|
|
132
|
+
let isAlreadyGoneError = (jsErr: JsExn.t): bool => jsErr->matchesAws("ResourceNotFoundException")
|
|
133
|
+
|
|
134
|
+
// CloudWatch Logs rejects concurrent writes against one group with
|
|
135
|
+
// `OperationAbortedException`, which plugin stacks deploying in parallel do hit.
|
|
136
|
+
let transientNames = [
|
|
137
|
+
"OperationAbortedException",
|
|
138
|
+
"ThrottlingException",
|
|
139
|
+
"TooManyRequestsException",
|
|
140
|
+
"ServiceUnavailableException",
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
let isRetryableError = (jsErr: JsExn.t): bool =>
|
|
144
|
+
transientNames->Array.some(code => jsErr->matchesAws(code))
|
|
145
|
+
|
|
146
|
+
// ── Retry helper ─────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
@val external setTimeout: (unit => unit, int) => int = "setTimeout"
|
|
149
|
+
|
|
150
|
+
/** Hand-rolled capped exponential backoff (`Effect` cannot be captured in a
|
|
151
|
+
serialised provider closure). 1 s doubling to 8 s, 5 attempts — enough to
|
|
152
|
+
outlast a concurrent write to the same group, and short enough that a real
|
|
153
|
+
permission or quota failure is still reported promptly. */
|
|
154
|
+
let rec runWithRetry = async (
|
|
155
|
+
~attempt: int=0,
|
|
156
|
+
~maxAttempts: int=5,
|
|
157
|
+
~delayMs: int=1000,
|
|
158
|
+
~maxDelayMs: int=8000,
|
|
159
|
+
makeCall: unit => promise<'a>,
|
|
160
|
+
): 'a =>
|
|
161
|
+
try {
|
|
162
|
+
await makeCall()
|
|
163
|
+
} catch {
|
|
164
|
+
| exn =>
|
|
165
|
+
let jsExn = exn->JsExn.fromException
|
|
166
|
+
let isRetryable = attempt < maxAttempts && jsExn->Option.mapOr(false, isRetryableError)
|
|
167
|
+
if isRetryable {
|
|
168
|
+
let _ = await Promise.make((resolve, _) => setTimeout(resolve, delayMs)->ignore)
|
|
169
|
+
let nextDelay = delayMs * 2
|
|
170
|
+
await runWithRetry(
|
|
171
|
+
~attempt=attempt + 1,
|
|
172
|
+
~maxAttempts,
|
|
173
|
+
~delayMs=nextDelay > maxDelayMs ? maxDelayMs : nextDelay,
|
|
174
|
+
~maxDelayMs,
|
|
175
|
+
makeCall,
|
|
176
|
+
)
|
|
177
|
+
} else {
|
|
178
|
+
switch jsExn {
|
|
179
|
+
| Some(e) => rethrow(e)
|
|
180
|
+
| None => throw(exn)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Provider input / output shapes ────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
type providerInputs = {
|
|
188
|
+
logGroupName: string,
|
|
189
|
+
retentionInDays: int,
|
|
190
|
+
tags: dict<string>,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// `managedBy` is this provider's own marker. State written by the classic
|
|
194
|
+
// `aws:cloudwatch/logGroup:LogGroup` resource that the alias adopts (see `make`)
|
|
195
|
+
// does not carry it, which is what tells `diff_` that the adopted state still
|
|
196
|
+
// has to be normalised — including Pulumi's own `__provider`, which only lands
|
|
197
|
+
// on a create or an update.
|
|
198
|
+
//
|
|
199
|
+
// Every field `diff_` reads must live here, and every one is nullable: adopted
|
|
200
|
+
// classic state carries a different shape, and a missing field must never read
|
|
201
|
+
// as a change.
|
|
202
|
+
type outs = {
|
|
203
|
+
logGroupName: Nullable.t<string>,
|
|
204
|
+
arn: Nullable.t<string>,
|
|
205
|
+
retentionInDays: Nullable.t<int>,
|
|
206
|
+
tags: Nullable.t<dict<string>>,
|
|
207
|
+
managedBy: Nullable.t<string>,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
type createResultOut = {id: string, outs: outs}
|
|
211
|
+
type updateResultOut = {outs: outs}
|
|
212
|
+
type diffResult = {changes: bool, replaces: array<string>, deleteBeforeReplace: bool}
|
|
213
|
+
type readResult = {id?: string, props?: outs}
|
|
214
|
+
|
|
215
|
+
let marker = "reventless:adopting-log-group"
|
|
216
|
+
|
|
217
|
+
// ── Steps ─────────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
/** DescribeLogGroups filtered by exact name. Returns `None` when the group does
|
|
220
|
+
not exist. The prefix filter can match siblings, so the exact name is
|
|
221
|
+
re-checked here rather than trusting the first row. */
|
|
222
|
+
let describe = async (~logGroupName: string): option<logGroupSummary> => {
|
|
223
|
+
let sdk = await getSdk()
|
|
224
|
+
let client = await getClient()
|
|
225
|
+
let result = await runWithRetry(() =>
|
|
226
|
+
client->sendDescribe(newCommand(sdk.describeCtor, {logGroupNamePrefix: logGroupName}))
|
|
227
|
+
)
|
|
228
|
+
result.logGroups
|
|
229
|
+
->Option.getOr([])
|
|
230
|
+
->Array.find(g => g.logGroupName == Some(logGroupName))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** CloudWatch reports a group's ARN with a trailing `:*` (denoting its streams).
|
|
234
|
+
`TagResource` rejects that spelling, so strip it. */
|
|
235
|
+
let taggableArn = (arn: string): string =>
|
|
236
|
+
arn->String.endsWith(":*") ? arn->String.slice(~start=0, ~end=arn->String.length - 2) : arn
|
|
237
|
+
|
|
238
|
+
/** `0` means "never expire" and is a valid explicit opt-in, so it clears the
|
|
239
|
+
policy rather than setting a retention of zero days. */
|
|
240
|
+
let applyRetention = async (~logGroupName: string, ~retentionInDays: int): unit => {
|
|
241
|
+
let sdk = await getSdk()
|
|
242
|
+
let client = await getClient()
|
|
243
|
+
await runWithRetry(() =>
|
|
244
|
+
retentionInDays > 0
|
|
245
|
+
? client->sendRetention(newCommand(sdk.retentionCtor, {logGroupName, retentionInDays}))
|
|
246
|
+
: client->sendDeleteRetention(newCommand(sdk.deleteRetentionCtor, {logGroupName}))
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let applyTags = async (~arn: string, ~tags: dict<string>): unit => {
|
|
251
|
+
let sdk = await getSdk()
|
|
252
|
+
let client = await getClient()
|
|
253
|
+
await runWithRetry(() =>
|
|
254
|
+
client->sendTag(newCommand(sdk.tagCtor, {resourceArn: arn->taggableArn, tags}))
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Provider methods ──────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
/** Create the group, or adopt the one that is already there. Both paths end
|
|
261
|
+
with the declared retention and tags applied, which is what makes the
|
|
262
|
+
outcome independent of who got there first. */
|
|
263
|
+
let create = async (inputs: providerInputs): createResultOut => {
|
|
264
|
+
let sdk = await getSdk()
|
|
265
|
+
let client = await getClient()
|
|
266
|
+
let {logGroupName, retentionInDays, tags} = inputs
|
|
267
|
+
|
|
268
|
+
let adopted = try {
|
|
269
|
+
await runWithRetry(() =>
|
|
270
|
+
client->sendCreate(newCommand(sdk.createCtor, {logGroupName, tags}))
|
|
271
|
+
)
|
|
272
|
+
false
|
|
273
|
+
} catch {
|
|
274
|
+
| exn if exn->JsExn.fromException->Option.mapOr(false, isAlreadyExistsError) => true
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let arn = switch await describe(~logGroupName) {
|
|
278
|
+
| Some({arn: ?Some(arn)}) => arn
|
|
279
|
+
| _ =>
|
|
280
|
+
JsError.throwWithMessage(
|
|
281
|
+
`log group "${logGroupName}" is missing immediately after create/adopt`,
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if adopted {
|
|
286
|
+
// CreateLogGroup carried the tags on the create path; the adopt path has to
|
|
287
|
+
// put them on a group that already existed without them.
|
|
288
|
+
await applyTags(~arn, ~tags)
|
|
289
|
+
log.info(
|
|
290
|
+
~comp="Util_LogGroup_Adopting",
|
|
291
|
+
`adopted existing log group "${logGroupName}"; applied retention ${retentionInDays->Int.toString}d and tags`,
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
await applyRetention(~logGroupName, ~retentionInDays)
|
|
295
|
+
|
|
296
|
+
{
|
|
297
|
+
id: logGroupName,
|
|
298
|
+
outs: {
|
|
299
|
+
logGroupName: Nullable.make(logGroupName),
|
|
300
|
+
arn: Nullable.make(arn),
|
|
301
|
+
retentionInDays: Nullable.make(retentionInDays),
|
|
302
|
+
tags: Nullable.make(tags),
|
|
303
|
+
managedBy: Nullable.make(marker),
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Re-apply retention and tags. Reached for a retention/tag change and for the
|
|
309
|
+
first deploy after the alias migration, whose whole job is to normalise the
|
|
310
|
+
adopted classic state. A name change replaces instead (see `diff_`), so the
|
|
311
|
+
group being updated is always the one named in `news`. */
|
|
312
|
+
let update = async (_id: string, _olds: outs, news: providerInputs): updateResultOut => {
|
|
313
|
+
let {logGroupName, retentionInDays, tags} = news
|
|
314
|
+
let arn = switch await describe(~logGroupName) {
|
|
315
|
+
| Some({arn: ?Some(arn)}) => Some(arn)
|
|
316
|
+
| _ => None
|
|
317
|
+
}
|
|
318
|
+
switch arn {
|
|
319
|
+
| Some(arn) =>
|
|
320
|
+
await applyTags(~arn, ~tags)
|
|
321
|
+
await applyRetention(~logGroupName, ~retentionInDays)
|
|
322
|
+
| None =>
|
|
323
|
+
// Drifted away underneath us — recreate rather than fail, since this
|
|
324
|
+
// resource's contract is that the group ends up existing and configured.
|
|
325
|
+
log.warn(
|
|
326
|
+
~comp="Util_LogGroup_Adopting",
|
|
327
|
+
`log group "${logGroupName}" was gone at update; recreating it`,
|
|
328
|
+
)
|
|
329
|
+
let _ = await create(news)
|
|
330
|
+
}
|
|
331
|
+
{
|
|
332
|
+
outs: {
|
|
333
|
+
logGroupName: Nullable.make(logGroupName),
|
|
334
|
+
arn: arn->Nullable.fromOption,
|
|
335
|
+
retentionInDays: Nullable.make(retentionInDays),
|
|
336
|
+
tags: Nullable.make(tags),
|
|
337
|
+
managedBy: Nullable.make(marker),
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
let delete_ = async (id: string, _props: outs): unit => {
|
|
343
|
+
let sdk = await getSdk()
|
|
344
|
+
let client = await getClient()
|
|
345
|
+
try {
|
|
346
|
+
await runWithRetry(() => client->sendDelete(newCommand(sdk.deleteCtor, {logGroupName: id})))
|
|
347
|
+
} catch {
|
|
348
|
+
| exn if exn->JsExn.fromException->Option.mapOr(false, isAlreadyGoneError) => ()
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
let sameTags = (a: dict<string>, b: dict<string>): bool => {
|
|
353
|
+
let normalise = (d: dict<string>) => {
|
|
354
|
+
let entries = d->Dict.toArray
|
|
355
|
+
entries->Array.sort(((k1, _), (k2, _)) => String.compare(k1, k2))
|
|
356
|
+
entries->Array.map(((k, v)) => `${k}=${v}`)->Array.join("")
|
|
357
|
+
}
|
|
358
|
+
normalise(a) == normalise(b)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** The group name is the resource's identity — CloudWatch cannot rename a group
|
|
362
|
+
in place, so a change replaces. Everything else is applied by `update`.
|
|
363
|
+
|
|
364
|
+
State adopted from the classic resource carries no `managedBy`, and that
|
|
365
|
+
alone counts as a change: the following update is what writes this provider's
|
|
366
|
+
output shape (and Pulumi's `__provider`) into state. */
|
|
367
|
+
let diff_ = (_id: string, olds: outs, news: providerInputs): diffResult => {
|
|
368
|
+
let replaces = switch olds.logGroupName->Nullable.toOption {
|
|
369
|
+
| Some(oldName) if oldName != news.logGroupName => ["logGroupName"]
|
|
370
|
+
| _ => []
|
|
371
|
+
}
|
|
372
|
+
let notNormalisedYet = olds.managedBy->Nullable.toOption->Option.isNone
|
|
373
|
+
let retentionChanged = switch olds.retentionInDays->Nullable.toOption {
|
|
374
|
+
| Some(days) => days != news.retentionInDays
|
|
375
|
+
| None => false
|
|
376
|
+
}
|
|
377
|
+
let tagsChanged = switch olds.tags->Nullable.toOption {
|
|
378
|
+
| Some(tags) => !sameTags(tags, news.tags)
|
|
379
|
+
| None => false
|
|
380
|
+
}
|
|
381
|
+
{
|
|
382
|
+
changes: replaces->Array.length > 0 || notNormalisedYet || retentionChanged || tagsChanged,
|
|
383
|
+
replaces,
|
|
384
|
+
// A replace means a different group name, so the new group can exist
|
|
385
|
+
// alongside the old one and the old one's logs stay readable until it goes.
|
|
386
|
+
deleteBeforeReplace: false,
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Live state for `pulumi refresh`. A group that is gone returns `{}` so Pulumi
|
|
391
|
+
drops it from state and the next `up` recreates it. */
|
|
392
|
+
let read_ = async (id: string, props: outs): readResult =>
|
|
393
|
+
switch await describe(~logGroupName=id) {
|
|
394
|
+
| None =>
|
|
395
|
+
log.info(~comp="Util_LogGroup_Adopting", `log group "${id}" is gone; reporting drift`)
|
|
396
|
+
({}: readResult)
|
|
397
|
+
| Some(live) => {
|
|
398
|
+
id,
|
|
399
|
+
props: {
|
|
400
|
+
...props,
|
|
401
|
+
logGroupName: Nullable.make(id),
|
|
402
|
+
arn: live.arn->Nullable.fromOption,
|
|
403
|
+
// Absent means "never expire", which is the `0` this provider accepts.
|
|
404
|
+
retentionInDays: Nullable.make(live.retentionInDays->Option.getOr(0)),
|
|
405
|
+
},
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Provider as a plain JS object — no Pulumi Output captures; all state flows
|
|
410
|
+
// through inputs / olds / news.
|
|
411
|
+
let provider = {
|
|
412
|
+
"create": create,
|
|
413
|
+
"update": update,
|
|
414
|
+
"delete": delete_,
|
|
415
|
+
"diff": diff_,
|
|
416
|
+
"read": read_,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── Pulumi dynamic resource binding ──────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
/** Output shape matches `Cloudwatch.LogGroup.t` so consumers read `.name` /
|
|
422
|
+
`.arn` / `.id` unchanged. */
|
|
423
|
+
type t = PulumiAws.Cloudwatch.LogGroup.t
|
|
424
|
+
|
|
425
|
+
type constructorProps = {
|
|
426
|
+
logGroupName: Pulumi.Input.t<string>,
|
|
427
|
+
retentionInDays: Pulumi.Input.t<int>,
|
|
428
|
+
tags: Pulumi.Input.t<dict<string>>,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** What is handed to `pulumi.dynamic.Resource`. The base constructor defines a
|
|
432
|
+
resource property for every KEY of `props`, so an output-only field absent
|
|
433
|
+
here never materialises on the resource and reads as a plain `undefined`
|
|
434
|
+
instead of an Output. Declaring them `undefined` is Pulumi's documented way
|
|
435
|
+
to register output-only properties; undefined values are dropped from the
|
|
436
|
+
inputs RPC, so `create` still receives only the three real inputs. */
|
|
437
|
+
type resourceProps = {
|
|
438
|
+
logGroupName: Pulumi.Input.t<string>,
|
|
439
|
+
retentionInDays: Pulumi.Input.t<int>,
|
|
440
|
+
tags: Pulumi.Input.t<dict<string>>,
|
|
441
|
+
name: Nullable.t<string>,
|
|
442
|
+
arn: Nullable.t<string>,
|
|
443
|
+
managedBy: Nullable.t<string>,
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let resourcePropsOf = (props: constructorProps): resourceProps => {
|
|
447
|
+
logGroupName: props.logGroupName,
|
|
448
|
+
retentionInDays: props.retentionInDays,
|
|
449
|
+
tags: props.tags,
|
|
450
|
+
name: Nullable.undefined,
|
|
451
|
+
arn: Nullable.undefined,
|
|
452
|
+
managedBy: Nullable.undefined,
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// pulumi.dynamic.Resource constructor: (provider, name, props, opts). Explicit
|
|
456
|
+
// /index.js path because @pulumi/pulumi/dynamic is a directory import not
|
|
457
|
+
// resolvable in ESM mode.
|
|
458
|
+
@module("@pulumi/pulumi/dynamic/index.js") @new
|
|
459
|
+
external _newResource: ('provider, string, 'props, Pulumi.CustomResourceOptions.t) => t = "Resource"
|
|
460
|
+
|
|
461
|
+
/** Same call shape as `Cloudwatch.LogGroup.make`, so a site swaps over by
|
|
462
|
+
changing the module name and passing `~logGroupName` instead of `name`.
|
|
463
|
+
|
|
464
|
+
The alias adopts any group already in state as the classic
|
|
465
|
+
`aws:cloudwatch/logGroup:LogGroup` resource, in place — without it, every
|
|
466
|
+
already-managed group would be destroyed and recreated, losing its history
|
|
467
|
+
for no reason. A dynamic provider's implementation changing does not itself
|
|
468
|
+
force a replace, so the migration is a diff over the fields `diff_` reads. */
|
|
469
|
+
let make = (
|
|
470
|
+
~name: string,
|
|
471
|
+
~props: constructorProps,
|
|
472
|
+
~opts: option<Pulumi.CustomResourceOptions.t>,
|
|
473
|
+
): t => {
|
|
474
|
+
let migrationAlias = Pulumi.Alias.make(~type_="aws:cloudwatch/logGroup:LogGroup", ~name, ())
|
|
475
|
+
let finalOpts: Pulumi.CustomResourceOptions.t = switch opts {
|
|
476
|
+
| Some(o) => {...o, aliases: [migrationAlias]}
|
|
477
|
+
| None => {aliases: [migrationAlias]}
|
|
478
|
+
}
|
|
479
|
+
_newResource(provider, name, props->resourcePropsOf, finalOpts)
|
|
480
|
+
}
|