import * as oam from "@distilled.cloud/aws/oam"; import * as Retry from "@distilled.cloud/aws/Retry"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import { createInternalTags, diffTags } from "../../Tags.ts"; const mutationSchedule = Schedule.max([ Schedule.spaced("1 second"), Schedule.recurs(20), ]); const isRetryableOamMutationError = ( error: unknown, conflict: boolean, ): boolean => { if (!error || typeof error !== "object") return false; const tag = (error as { _tag?: string })._tag; return ( tag === "TooManyRequestsException" || tag === "ServiceQuotaExceededException" || tag === "InternalServiceFault" || (conflict && tag === "ConflictException") ); }; /** One non-nested, bounded retry owner for OAM control-plane mutations. */ export const retryOamMutation = ( effect: Effect.Effect, options?: { conflict?: boolean }, ): Effect.Effect> => Retry.policy({ while: (error) => isRetryableOamMutationError(error, options?.conflict === true), schedule: mutationSchedule, })(effect); class SinkLinksStillAttached extends Data.TaggedError( "SinkLinksStillAttached", )<{ readonly sinkArn: string; readonly count: number }> {} class OamResourceStillExists extends Data.TaggedError( "OamResourceStillExists", )<{ readonly arn: string }> {} const dependencySchedule = Schedule.max([ Schedule.spaced("2 seconds"), Schedule.recurs(10), ]); const goneSchedule = Schedule.max([ Schedule.spaced("1 second"), Schedule.recurs(10), ]); /** * Delete a sink only after attached links have drained, then observe absence. */ export const deleteSinkAndWait = Effect.fn(function* (sinkArn: string) { yield* oam.listAttachedLinks.pages({ SinkIdentifier: sinkArn }).pipe( Stream.runCollect, Effect.map((pages) => Array.from(pages).reduce((count, page) => count + page.Items.length, 0), ), Effect.flatMap((count) => count === 0 ? Effect.void : Effect.fail(new SinkLinksStillAttached({ sinkArn, count })), ), Effect.retry({ while: (error) => error._tag === "SinkLinksStillAttached", schedule: dependencySchedule, }), Effect.catchTag("ResourceNotFoundException", () => Effect.void), ); yield* retryOamMutation(oam.deleteSink({ Identifier: sinkArn }), { // A just-deleted link can remain visible to DeleteSink after the list has // converged. Retry only this bounded dependency-violation window. conflict: true, }).pipe(Effect.catchTag("ResourceNotFoundException", () => Effect.void)); const observe = Retry.none(oam.getSink({ Identifier: sinkArn })); yield* observe.pipe( Effect.flatMap(() => Effect.fail(new OamResourceStillExists({ arn: sinkArn })), ), Effect.retry({ while: (error) => error._tag === "OamResourceStillExists" || error._tag === "TooManyRequestsException", schedule: goneSchedule, }), Effect.catchTag("ResourceNotFoundException", () => Effect.void), ); }); /** Idempotently delete a link and observe absence before its sink can delete. */ export const deleteLinkAndWait = Effect.fn(function* (linkArn: string) { yield* retryOamMutation(oam.deleteLink({ Identifier: linkArn })).pipe( Effect.catchTag("ResourceNotFoundException", () => Effect.void), ); const observe = Retry.none(oam.getLink({ Identifier: linkArn })); yield* observe.pipe( Effect.flatMap(() => Effect.fail(new OamResourceStillExists({ arn: linkArn })), ), Effect.retry({ while: (error) => error._tag === "OamResourceStillExists" || error._tag === "TooManyRequestsException", schedule: goneSchedule, }), Effect.catchTag("ResourceNotFoundException", () => Effect.void), ); }); /** * Read the observed tags on an OAM sink or link, tolerating a * not-yet-visible resource (returns `{}`). */ export const readOamTags = Effect.fn(function* (resourceArn: string) { return yield* oam.listTagsForResource({ ResourceArn: resourceArn }).pipe( Effect.map((r) => (r.Tags ?? {}) as Record), Effect.catchTag(["ResourceNotFoundException", "ValidationException"], () => Effect.succeed({} as Record), ), ); }); /** * Converge the tags on an OAM sink or link to the desired user tags merged * with the internal Alchemy ownership tags, diffing against OBSERVED cloud * tags so adoption converges. */ export const syncOamTags = Effect.fn(function* ( resourceArn: string, id: string, userTags: Record | undefined, ) { const internalTags = yield* createInternalTags(id); const desired = { ...(userTags ?? {}), ...internalTags }; const observed = yield* readOamTags(resourceArn); const { upsert, removed } = diffTags(observed, desired); if (upsert.length > 0) { yield* retryOamMutation( oam.tagResource({ ResourceArn: resourceArn, Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])), }), ); } if (removed.length > 0) { yield* retryOamMutation( oam.untagResource({ ResourceArn: resourceArn, TagKeys: removed }), ); } });