1import * as organizations from "@distilled.cloud/aws/organizations";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Schedule from "effect/Schedule";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import { createInternalTags, diffTags } from "../../Tags.ts";
7
8/**
9 * Distilled decodes `smithy.api#sensitive` strings (`Name`, `Email`,
10 * `MasterAccountEmail`) into `Redacted.Redacted<string>` at runtime.
11 * Attributes and equality checks must unwrap first — comparing the wrapper
12 * against a plain string is always `false` and would persist
13 * `{ "__redacted__": … }` into state.
14 */
15export const unredact = (
16 value: string | Redacted.Redacted<string> | undefined,
17): string | undefined =>
18 value === undefined
19 ? undefined
20 : Redacted.isRedacted(value)
21 ? Redacted.value(value)
22 : value;
23
24export type OrganizationsTags = Record<string, string>;
25
26export const createName = (
27 id: string,
28 providedName: string | undefined,
29 maxLength: number,
30) =>
31 providedName
32 ? Effect.succeed(providedName)
33 : createPhysicalName({
34 id,
35 maxLength,
36 });
37
38export const toTagRecord = (
39 tags: organizations.Tag[] | undefined,
40): OrganizationsTags =>
41 Object.fromEntries(
42 (tags ?? [])
43 .filter(
44 (tag): tag is { Key: string; Value: string } =>
45 typeof tag.Key === "string" && typeof tag.Value === "string",
46 )
47 .map((tag) => [tag.Key, tag.Value]),
48 );
49
50export const collectPages = <Page extends { NextToken?: string }, Item, E, R>(
51 fetch: (nextToken?: string) => Effect.Effect<Page, E, R>,
52 select: (page: Page) => ReadonlyArray<Item> | undefined,
53) =>
54 Effect.gen(function* () {
55 const items: Item[] = [];
56 let nextToken: string | undefined;
57
58 do {
59 const page = yield* fetch(nextToken);
60 items.push(...(select(page) ?? []));
61 nextToken = page.NextToken;
62 } while (nextToken);
63
64 return items;
65 });
66
67export const retryOrganizations = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
68 effect.pipe(
69 Effect.retry({
70 while: (error: any) =>
71 error?._tag === "ConcurrentModificationException" ||
72 error?._tag === "TooManyRequestsException" ||
73 error?._tag === "ServiceException" ||
74 error?._tag === "FinalizingOrganizationException",
75 schedule: Schedule.max([Schedule.exponential(200), Schedule.recurs(8)]),
76 }),
77 );
78
79export const createManagedTags = Effect.fn(function* (
80 id: string,
81 tags: Record<string, string> | undefined,
82) {
83 return {
84 ...(yield* createInternalTags(id)),
85 ...tags,
86 };
87});
88
89export const readResourceTags = (resourceId: string) =>
90 collectPages(
91 (NextToken) =>
92 organizations.listTagsForResource({ ResourceId: resourceId, NextToken }),
93 (page) => page.Tags,
94 ).pipe(Effect.map(toTagRecord));
95
96export const updateResourceTags = Effect.fn(function* ({
97 id,
98 resourceId,
99 olds,
100 news,
101}: {
102 id: string;
103 resourceId: string;
104 olds: Record<string, string> | undefined;
105 news: Record<string, string> | undefined;
106}) {
107 // `olds` is freshly read from AWS. Do not synthesize ownership tags into
108 // that baseline: doing so hides missing tags from the diff while returning
109 // them as if they had been persisted.
110 const oldTags = olds ?? {};
111 const newTags = yield* createManagedTags(id, news);
112 const { removed, upsert } = diffTags(oldTags, newTags);
113
114 if (removed.length > 0) {
115 yield* retryOrganizations(
116 organizations.untagResource({
117 ResourceId: resourceId,
118 TagKeys: removed,
119 }),
120 );
121 }
122
123 if (upsert.length > 0) {
124 yield* retryOrganizations(
125 organizations.tagResource({
126 ResourceId: resourceId,
127 Tags: upsert,
128 }),
129 );
130 }
131
132 return newTags;
133});