| 1 | import * as sd from "@distilled.cloud/aws/servicediscovery"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import { diffTags } from "../../Tags.ts"; |
| 6 | |
| 7 | /** |
| 8 | * Raised when an asynchronous Cloud Map operation (namespace create/delete, |
| 9 | * instance register/deregister, service update) terminates with `FAIL` or |
| 10 | * fails to reach a terminal status within the polling budget. |
| 11 | */ |
| 12 | export class CloudMapOperationFailed extends Data.TaggedError( |
| 13 | "CloudMapOperationFailed", |
| 14 | )<{ |
| 15 | readonly operationId: string; |
| 16 | readonly status: string | undefined; |
| 17 | readonly errorCode: string | undefined; |
| 18 | readonly errorMessage: string | undefined; |
| 19 | }> {} |
| 20 | |
| 21 | /** |
| 22 | * Repeat a `getOperation` poll until the operation reaches a terminal status |
| 23 | * (`SUCCESS` | `FAIL`), spaced 2s and bounded at 60 repetitions (~2 minutes — |
| 24 | * namespace operations typically take 30-60s). |
| 25 | * |
| 26 | * Expressed as an explicitly-typed helper: inlining `Effect.repeat` in |
| 27 | * lifecycle code leaves its conditional return type unresolved in the |
| 28 | * provider's declaration emit, which widens the `AWS.providers()` layer type |
| 29 | * for every downstream consumer. |
| 30 | */ |
| 31 | const untilTerminalStatus = <E, R>( |
| 32 | self: Effect.Effect<sd.GetOperationResponse, E, R>, |
| 33 | ): Effect.Effect<sd.Operation | undefined, E, R> => |
| 34 | Effect.repeat( |
| 35 | Effect.map(self, (response) => response.Operation), |
| 36 | { |
| 37 | schedule: Schedule.spaced("2 seconds"), |
| 38 | until: (operation) => |
| 39 | operation?.Status === "SUCCESS" || operation?.Status === "FAIL", |
| 40 | times: 60, |
| 41 | }, |
| 42 | ); |
| 43 | |
| 44 | /** |
| 45 | * Await a Cloud Map async operation: poll `getOperation` (bounded) until it |
| 46 | * reaches a terminal status and fail with `CloudMapOperationFailed` unless |
| 47 | * that status is `SUCCESS`. Returns the terminal `Operation` (whose |
| 48 | * `Targets` map carries the created NAMESPACE/SERVICE/INSTANCE ids). |
| 49 | */ |
| 50 | export const awaitOperation = Effect.fn("AWS.CloudMap.awaitOperation")( |
| 51 | function* (operationId: string) { |
| 52 | const operation = yield* untilTerminalStatus( |
| 53 | sd.getOperation({ OperationId: operationId }), |
| 54 | ); |
| 55 | if (operation?.Status !== "SUCCESS") { |
| 56 | return yield* Effect.fail( |
| 57 | new CloudMapOperationFailed({ |
| 58 | operationId, |
| 59 | status: operation?.Status, |
| 60 | errorCode: operation?.ErrorCode, |
| 61 | errorMessage: operation?.ErrorMessage, |
| 62 | }), |
| 63 | ); |
| 64 | } |
| 65 | return operation; |
| 66 | }, |
| 67 | ); |
| 68 | |
| 69 | /** |
| 70 | * Bounded retry through transient `ResourceInUse` dependency violations — |
| 71 | * e.g. deleting a namespace while its last service deletion is still |
| 72 | * propagating, or deleting a service while an instance deregistration is in |
| 73 | * flight. Explicitly typed for the same declaration-emit reason as above. |
| 74 | */ |
| 75 | export const retryWhileResourceInUse = <A, E extends { _tag: string }, R>( |
| 76 | self: Effect.Effect<A, E, R>, |
| 77 | ): Effect.Effect<A, E, R> => |
| 78 | Effect.retry(self, { |
| 79 | while: (e) => e._tag === "ResourceInUse", |
| 80 | schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(20)]), |
| 81 | }); |
| 82 | |
| 83 | /** |
| 84 | * Deregister every instance still registered on a service and await the |
| 85 | * async deregistration operations. Instances registered at runtime (via the |
| 86 | * `RegisterInstance` binding) are data-plane ephemera owned by the service — |
| 87 | * they would otherwise block `DeleteService` with `ResourceInUse` forever. |
| 88 | * Deregistrations already in flight surface as `DuplicateRequest` and are |
| 89 | * tolerated; `DeleteService`'s own `ResourceInUse` retry rides out the |
| 90 | * remaining visibility window. |
| 91 | */ |
| 92 | export const deregisterAllInstances = Effect.fn( |
| 93 | "AWS.CloudMap.deregisterAllInstances", |
| 94 | )(function* (serviceId: string) { |
| 95 | const operationIds: string[] = []; |
| 96 | let nextToken: string | undefined; |
| 97 | do { |
| 98 | const page = yield* sd.listInstances({ |
| 99 | ServiceId: serviceId, |
| 100 | NextToken: nextToken, |
| 101 | }); |
| 102 | for (const instance of page.Instances ?? []) { |
| 103 | if (instance.Id === undefined) { |
| 104 | continue; |
| 105 | } |
| 106 | const response = yield* sd |
| 107 | .deregisterInstance({ ServiceId: serviceId, InstanceId: instance.Id }) |
| 108 | .pipe( |
| 109 | // already gone / a deregistration already in flight — both mean |
| 110 | // the instance is on its way out |
| 111 | Effect.catchTag(["InstanceNotFound", "DuplicateRequest"], () => |
| 112 | Effect.succeed(undefined), |
| 113 | ), |
| 114 | ); |
| 115 | if (response?.OperationId !== undefined) { |
| 116 | operationIds.push(response.OperationId); |
| 117 | } |
| 118 | } |
| 119 | nextToken = page.NextToken; |
| 120 | } while (nextToken !== undefined); |
| 121 | for (const operationId of operationIds) { |
| 122 | yield* awaitOperation(operationId); |
| 123 | } |
| 124 | }); |
| 125 | |
| 126 | export type NamespaceKind = "DNS_PRIVATE" | "DNS_PUBLIC" | "HTTP"; |
| 127 | |
| 128 | /** Find a namespace of the given type by exact name via the list API. */ |
| 129 | export const findNamespaceByName = Effect.fn( |
| 130 | "AWS.CloudMap.findNamespaceByName", |
| 131 | )(function* (type: NamespaceKind, name: string) { |
| 132 | const response = yield* sd.listNamespaces({ |
| 133 | Filters: [ |
| 134 | { Name: "TYPE", Values: [type], Condition: "EQ" }, |
| 135 | { Name: "NAME", Values: [name], Condition: "EQ" }, |
| 136 | ], |
| 137 | }); |
| 138 | return response.Namespaces?.[0]; |
| 139 | }); |
| 140 | |
| 141 | /** |
| 142 | * Observe a namespace: by cached id when we have one (tolerating out-of-band |
| 143 | * deletion), falling back to an exact-name list lookup. |
| 144 | */ |
| 145 | export const observeNamespace = Effect.fn("AWS.CloudMap.observeNamespace")( |
| 146 | function* ( |
| 147 | type: NamespaceKind, |
| 148 | name: string, |
| 149 | namespaceId: string | undefined, |
| 150 | ) { |
| 151 | if (namespaceId !== undefined) { |
| 152 | const byId = yield* sd.getNamespace({ Id: namespaceId }).pipe( |
| 153 | Effect.map((r) => r.Namespace), |
| 154 | Effect.catchTag("NamespaceNotFound", () => Effect.succeed(undefined)), |
| 155 | ); |
| 156 | if (byId !== undefined) { |
| 157 | return byId; |
| 158 | } |
| 159 | } |
| 160 | const summary = yield* findNamespaceByName(type, name); |
| 161 | if (summary?.Id === undefined) { |
| 162 | return undefined; |
| 163 | } |
| 164 | // hydrate the summary into the full Namespace shape (keeps one return |
| 165 | // type; also tolerates a delete race between list and get) |
| 166 | return yield* sd.getNamespace({ Id: summary.Id }).pipe( |
| 167 | Effect.map((r) => r.Namespace), |
| 168 | Effect.catchTag("NamespaceNotFound", () => Effect.succeed(undefined)), |
| 169 | ); |
| 170 | }, |
| 171 | ); |
| 172 | |
| 173 | /** |
| 174 | * Ensure a namespace exists: submit `create`, await its async operation, and |
| 175 | * observe the created namespace **by the operation's target id** — the |
| 176 | * `listNamespaces` name lookup is eventually consistent and can miss a |
| 177 | * namespace created milliseconds earlier. |
| 178 | * |
| 179 | * A same-name predecessor still mid-deletion (e.g. the old physical resource |
| 180 | * of a replacement, or an out-of-band cleaner) surfaces the create as |
| 181 | * `NamespaceAlreadyExists` while remaining invisible to observation; the |
| 182 | * whole create+observe sequence retries (bounded, ~40s) until the deletion |
| 183 | * completes and the create goes through. A `DuplicateRequest` means an |
| 184 | * identical create is already in flight — its operation is awaited instead. |
| 185 | */ |
| 186 | const retryWhileNamespaceNotVisible = <A, E extends { _tag: string }, R>( |
| 187 | self: Effect.Effect<A, E, R>, |
| 188 | ): Effect.Effect<A, E, R> => |
| 189 | Effect.retry(self, { |
| 190 | while: (e) => e._tag === "NamespaceNotFound", |
| 191 | schedule: Schedule.max([Schedule.spaced("5 seconds"), Schedule.recurs(8)]), |
| 192 | }); |
| 193 | |
| 194 | /** |
| 195 | * The (identical) typed error union of the three namespace create operations. |
| 196 | * Kept concrete — a generic error parameter here would flow into |
| 197 | * `Effect.catchTag`, leaving an un-narrowable `{ _tag: unknown }` residue in |
| 198 | * the handler under TypeScript 7. |
| 199 | */ |
| 200 | type CreateNamespaceError = |
| 201 | | sd.CreateHttpNamespaceError |
| 202 | | sd.CreatePrivateDnsNamespaceError |
| 203 | | sd.CreatePublicDnsNamespaceError; |
| 204 | |
| 205 | export const ensureNamespace = <R>( |
| 206 | type: NamespaceKind, |
| 207 | name: string, |
| 208 | create: Effect.Effect< |
| 209 | { OperationId?: string | undefined }, |
| 210 | CreateNamespaceError, |
| 211 | R |
| 212 | >, |
| 213 | ) => |
| 214 | retryWhileNamespaceNotVisible( |
| 215 | Effect.gen(function* () { |
| 216 | const created: { |
| 217 | OperationId?: string | undefined; |
| 218 | NamespaceId?: string | undefined; |
| 219 | } = yield* create.pipe( |
| 220 | Effect.catchTag(["NamespaceAlreadyExists", "DuplicateRequest"], (e) => |
| 221 | e._tag === "NamespaceAlreadyExists" |
| 222 | ? // the name is taken — by an ACTIVE namespace (observed below) |
| 223 | // or by one still deleting (observation misses it and the |
| 224 | // bounded outer retry re-submits the create) |
| 225 | Effect.succeed({ |
| 226 | OperationId: undefined, |
| 227 | NamespaceId: e.NamespaceId, |
| 228 | }) |
| 229 | : // an identical create is already in flight — await THAT one |
| 230 | Effect.succeed({ |
| 231 | OperationId: e.DuplicateOperationId, |
| 232 | NamespaceId: undefined, |
| 233 | }), |
| 234 | ), |
| 235 | ); |
| 236 | let namespaceId = created.NamespaceId; |
| 237 | if (created.OperationId !== undefined) { |
| 238 | const operation = yield* awaitOperation(created.OperationId); |
| 239 | namespaceId = operation?.Targets?.NAMESPACE ?? namespaceId; |
| 240 | } |
| 241 | const namespace = yield* observeNamespace(type, name, namespaceId); |
| 242 | if (namespace?.Id === undefined) { |
| 243 | return yield* Effect.fail( |
| 244 | new sd.NamespaceNotFound({ |
| 245 | message: `namespace ${name} not visible after create`, |
| 246 | }), |
| 247 | ); |
| 248 | } |
| 249 | return namespace; |
| 250 | }), |
| 251 | ); |
| 252 | |
| 253 | /** |
| 254 | * Fetch the observed Cloud Map tags for a resource ARN as a plain record, |
| 255 | * tolerating a missing resource (race during create/delete) as `{}`. |
| 256 | */ |
| 257 | export const fetchObservedTags = Effect.fn("AWS.CloudMap.fetchObservedTags")( |
| 258 | function* (resourceArn: string) { |
| 259 | const tags = yield* sd |
| 260 | .listTagsForResource({ ResourceARN: resourceArn }) |
| 261 | .pipe( |
| 262 | Effect.map((response) => response.Tags ?? []), |
| 263 | Effect.catch(() => Effect.succeed([] as sd.Tag[])), |
| 264 | ); |
| 265 | return Object.fromEntries(tags.map((tag) => [tag.Key, tag.Value])); |
| 266 | }, |
| 267 | ); |
| 268 | |
| 269 | /** |
| 270 | * Sync a Cloud Map resource's tags: diff the OBSERVED cloud tags against the |
| 271 | * desired set and apply only the delta via tagResource/untagResource. |
| 272 | */ |
| 273 | export const syncTags = Effect.fn("AWS.CloudMap.syncTags")(function* ( |
| 274 | resourceArn: string, |
| 275 | observed: Record<string, string>, |
| 276 | desired: Record<string, string>, |
| 277 | ) { |
| 278 | const { upsert, removed } = diffTags(observed, desired); |
| 279 | if (upsert.length > 0) { |
| 280 | yield* sd.tagResource({ ResourceARN: resourceArn, Tags: upsert }); |
| 281 | } |
| 282 | if (removed.length > 0) { |
| 283 | yield* sd.untagResource({ ResourceARN: resourceArn, TagKeys: removed }); |
| 284 | } |
| 285 | }); |