@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +8 -7
  3. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +15 -13
  4. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +3 -2
  5. package/src/adapter/Api/Platform_UIFragments_Lambda.res +15 -13
  6. package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +3 -2
  7. package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res +8 -1
  8. package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res.mjs +3 -2
  9. package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res +15 -13
  10. package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res.mjs +3 -2
  11. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +6 -12
  12. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +4 -8
  13. package/src/adapter/QueryDb/QueryInterceptor_Provisioning.res +155 -0
  14. package/src/adapter/QueryDb/QueryInterceptor_Provisioning.res.mjs +92 -0
  15. package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res +4 -9
  16. package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res.mjs +8 -2
  17. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +32 -62
  18. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +7 -20
  19. package/src/adapter/StateTopic/StateTopic_AppSync.res +15 -13
  20. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +3 -2
  21. package/src/adapter/Upload/Upload_Claim_S3.res +15 -13
  22. package/src/adapter/Upload/Upload_Claim_S3.res.mjs +3 -2
  23. package/src/adapter/Upload/Upload_Presign_S3.res +15 -13
  24. package/src/adapter/Upload/Upload_Presign_S3.res.mjs +3 -2
  25. package/src/components/Api/AppSync_Adapter.res +13 -9
  26. package/src/components/Api/AppSync_Adapter.res.mjs +4 -3
  27. package/src/util/Util_LambdaLogging.res +83 -21
  28. package/src/util/Util_LambdaLogging.res.mjs +21 -8
  29. package/src/util/Util_LogGroup_Adopting.res +480 -0
  30. package/src/util/Util_LogGroup_Adopting.res.mjs +365 -0
  31. package/src/util/Util_LogGroup_Adopting_Js.mjs +20 -0
  32. package/src/util/Util_LogRetention.res +11 -12
  33. package/tests/Util_LambdaLoggingTest.res +48 -0
  34. package/tests/Util_LambdaLoggingTest.res.mjs +30 -0
  35. package/tests/Util_LogGroup_AdoptingTest.res +169 -0
  36. 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
- // Create a managed CloudWatch log group with tier retention for a Lambda when the
48
- // stack manages its groups (`Util_LogRetention.managesLogGroup`). The group name
49
- // derives from the function's own *physical* name output — never the logical
50
- // name — so it matches the group Lambda would auto-create and carries Pulumi's
51
- // uniqueness suffix. The caller supplies `~tags` (Logs-role, its own attribution
52
- // convention) and `~opts` (so the group parents to the same component and is torn
53
- // down with it — closing the orphan-group loop for the bespoke builders too). The
54
- // trailing `()` terminates the optional `~opts`.
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
- ~lambdaName: Pulumi.Output.t<string>,
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
- if Util_LogRetention.managesLogGroup(~stack, ~unmanagedStacks) {
64
- let days = Util_LogRetention.retentionDaysFor(
65
- ~stack,
66
- ~prodStacks,
67
- ~configOverride=?Util_LocalConfig.get("logRetentionDays")->Option.flatMap(s =>
68
- Int.fromString(s)
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
- let _ = Cloudwatch.LogGroup.make(
108
+ | (None, false) => None
109
+ }
110
+ retentionDays->Option.map(days =>
111
+ Cloudwatch.LogGroup.make(
72
112
  ~name=`${name}LogGroup`,
73
113
  ~args={
74
- name: lambdaName
75
- ->Pulumi.Output.apply(n => `/aws/lambda/${n}`)
76
- ->Pulumi.Output.asInput,
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 makeManagedLogGroup(name, lambdaName, tags, opts, param) {
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
- if (!Util_LogRetention$ReventlessAws.managesLogGroup(stack, match[2])) {
43
- return;
44
- }
45
- let days = Util_LogRetention$ReventlessAws.retentionDaysFor(stack, match[1], Stdlib_Option.flatMap(Util_LocalConfig$ReventlessAws.get("logRetentionDays"), s => Stdlib_Int.fromString(s, undefined)));
46
- new (Aws.cloudwatch.LogGroup)(name + `LogGroup`, {
47
- name: lambdaName.apply(n => `/aws/lambda/` + n),
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
+ }