1// Shared scaffolding for AWS Control Tower resources.
2// NOT exported from the service index.ts.
3import * as controltower from "@distilled.cloud/aws/controltower";
4import * as Effect from "effect/Effect";
5import { createInternalTags, diffTags } from "../../Tags.ts";
6
7/**
8 * Reads the observed tags on a Control Tower resource (landing zone,
9 * enabled control, or enabled baseline). Tag reads are best-effort — any
10 * failure degrades to an empty map so ownership checks and tag syncs never
11 * block the main lifecycle flow.
12 */
13export const observeControlTowerTags = (resourceArn: string) =>
14 controltower.listTagsForResource({ resourceArn }).pipe(
15 Effect.map((r) => {
16 const tags: Record<string, string> = {};
17 for (const [key, value] of Object.entries(r.tags ?? {})) {
18 if (value !== undefined) {
19 tags[key] = value;
20 }
21 }
22 return tags;
23 }),
24 Effect.catch(() => Effect.succeed({} as Record<string, string>)),
25 );
26
27/**
28 * Converges the tags on a Control Tower resource to the union of the
29 * internal Alchemy ownership tags and the user's desired tags, diffing
30 * against OBSERVED cloud tags (never olds/output) so adoption converges.
31 */
32export const syncControlTowerTags = Effect.fn(function* (
33 resourceArn: string,
34 id: string,
35 userTags: Record<string, string> | undefined,
36) {
37 const internalTags = yield* createInternalTags(id);
38 const observed = yield* observeControlTowerTags(resourceArn);
39 const desired: Record<string, string> = { ...userTags, ...internalTags };
40 const { upsert, removed } = diffTags(observed, desired);
41 if (upsert.length > 0) {
42 yield* controltower.tagResource({
43 resourceArn,
44 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
45 });
46 }
47 if (removed.length > 0) {
48 yield* controltower.untagResource({ resourceArn, tagKeys: removed });
49 }
50});
51
52const sortKeysDeep = (value: unknown): unknown => {
53 if (Array.isArray(value)) {
54 return value.map(sortKeysDeep);
55 }
56 if (value !== null && typeof value === "object") {
57 return Object.fromEntries(
58 Object.entries(value as Record<string, unknown>)
59 .sort(([l], [r]) => l.localeCompare(r))
60 .map(([k, v]) => [k, sortKeysDeep(v)]),
61 );
62 }
63 return value;
64};
65
66/**
67 * Key-order-insensitive JSON serialization, used to diff manifest and
68 * parameter documents that round-trip through the Control Tower API (which
69 * may reorder object keys).
70 */
71export const canonicalJson = (value: unknown): string =>
72 JSON.stringify(sortKeysDeep(value)) ?? "";
73
74/**
75 * Canonical comparison form for `{ key, value }` parameter lists: sorted by
76 * key, order-insensitive values.
77 */
78export const canonicalParameters = (
79 parameters: readonly { key: string; value: any }[] | undefined,
80): string =>
81 canonicalJson(
82 [...(parameters ?? [])].sort((l, r) => l.key.localeCompare(r.key)),
83 );