1import * as appregistry from "@distilled.cloud/aws/service-catalog-appregistry";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 diffTags,
12 hasAlchemyTags,
13 tagRecord,
14} from "../../Tags.ts";
15import type { Providers } from "../Providers.ts";
16import { clientToken, stripAwsSystemTags } from "./internal.ts";
17
18export interface AttributeGroupProps {
19 /**
20 * Name of the attribute group. Must be unique in the account and region
21 * and may only contain letters, numbers, dots, dashes, and underscores.
22 * If omitted, a unique name is generated. Changing it replaces the
23 * attribute group.
24 */
25 attributeGroupName?: string;
26 /**
27 * Description of the attribute group. Updatable in place.
28 */
29 description?: string;
30 /**
31 * Open-content metadata for the group as a JSON object (max 8 KB).
32 * Updatable in place.
33 */
34 attributes: Record<string, unknown>;
35 /**
36 * Tags to apply to the attribute group. Merged with internal Alchemy tags.
37 */
38 tags?: Record<string, string>;
39}
40
41export interface AttributeGroup extends Resource<
42 "AWS.AppRegistry.AttributeGroup",
43 AttributeGroupProps,
44 {
45 /** The auto-generated attribute group ID. */
46 attributeGroupId: string;
47 /** The ARN of the attribute group. */
48 attributeGroupArn: string;
49 /** The name of the attribute group. */
50 attributeGroupName: string;
51 },
52 never,
53 Providers
54> {}
55
56/**
57 * An AWS Service Catalog AppRegistry attribute group — a named container of
58 * user-defined JSON metadata that can be associated with applications to
59 * enrich them (owner, cost center, compliance posture, etc.).
60 *
61 * ### Creating an Attribute Group
62 * **Example:** Basic Attribute Group
63 * ```typescript
64 * import * as AppRegistry from "alchemy/AWS/AppRegistry";
65 *
66 * const group = yield* AppRegistry.AttributeGroup("Ownership", {
67 * attributes: {
68 * owner: "commerce-team",
69 * costCenter: "1234",
70 * },
71 * });
72 * ```
73 *
74 * **Example:** Attribute Group with Description and Tags
75 * ```typescript
76 * const group = yield* AppRegistry.AttributeGroup("Ownership", {
77 * attributeGroupName: "storefront-ownership",
78 * description: "Ownership metadata for the storefront",
79 * attributes: { owner: "commerce-team" },
80 * tags: { team: "commerce" },
81 * });
82 * ```
83 *
84 * @resource
85 */
86export const AttributeGroup = Resource<AttributeGroup>(
87 "AWS.AppRegistry.AttributeGroup",
88);
89
90export const AttributeGroupProvider = () =>
91 Provider.effect(
92 AttributeGroup,
93 Effect.gen(function* () {
94 const createName = Effect.fn(function* (
95 id: string,
96 props: Pick<AttributeGroupProps, "attributeGroupName">,
97 ) {
98 return (
99 props.attributeGroupName ??
100 (yield* createPhysicalName({ id, maxLength: 256 }))
101 );
102 });
103
104 // getAttributeGroup accepts a name, ID, or ARN specifier.
105 const observe = Effect.fn(function* (specifier: string) {
106 return yield* appregistry
107 .getAttributeGroup({ attributeGroup: specifier })
108 .pipe(
109 Effect.catchTag("ResourceNotFoundException", () =>
110 Effect.succeed(undefined),
111 ),
112 );
113 });
114
115 // Canonical comparison of the observed attribute JSON against the
116 // desired object; serialization differences at worst cause one extra
117 // (idempotent) update call.
118 const attributesEqual = (
119 observed: string | undefined,
120 desired: Record<string, unknown>,
121 ): boolean => {
122 if (observed === undefined) return false;
123 try {
124 return (
125 JSON.stringify(JSON.parse(observed)) === JSON.stringify(desired)
126 );
127 } catch {
128 return false;
129 }
130 };
131
132 return AttributeGroup.Provider.of({
133 stables: [
134 "attributeGroupId",
135 "attributeGroupArn",
136 "attributeGroupName",
137 ],
138 list: () =>
139 appregistry.listAttributeGroups.pages({}).pipe(
140 Stream.runCollect,
141 Effect.map((chunk) =>
142 Array.from(chunk)
143 .flatMap((page) => page.attributeGroups ?? [])
144 .filter((g) => g.id != null && g.arn != null && g.name != null)
145 .map((g) => ({
146 attributeGroupId: g.id!,
147 attributeGroupArn: g.arn!,
148 attributeGroupName: g.name!,
149 })),
150 ),
151 ),
152 read: Effect.fn(function* ({ id, olds, output }) {
153 const specifier =
154 output?.attributeGroupId ?? (yield* createName(id, olds ?? {}));
155 const found = yield* observe(specifier);
156 if (!found?.id) return undefined;
157 const attrs = {
158 attributeGroupId: found.id,
159 attributeGroupArn: found.arn!,
160 attributeGroupName: found.name!,
161 };
162 return (yield* hasAlchemyTags(id, tagRecord(found.tags)))
163 ? attrs
164 : Unowned(attrs);
165 }),
166 // The attribute group name is its user-facing identity — changing it
167 // replaces the group.
168 diff: Effect.fn(function* ({ id, olds, news }) {
169 if (!isResolved(news)) return undefined;
170 const oldName = yield* createName(id, olds);
171 const newName = yield* createName(id, news);
172 if (oldName !== newName) return { action: "replace" } as const;
173 }),
174 reconcile: Effect.fn(function* ({
175 id,
176 news,
177 output,
178 session,
179 instanceId,
180 }) {
181 const attributeGroupName = yield* createName(id, news);
182 const internalTags = yield* createInternalTags(id);
183 const desiredTags: Record<string, string> = {
184 ...news.tags,
185 ...internalTags,
186 };
187 const desiredAttributes = JSON.stringify(news.attributes);
188
189 // 1. OBSERVE — cloud is authoritative; output caches the ID only.
190 let found = output?.attributeGroupId
191 ? yield* observe(output.attributeGroupId)
192 : undefined;
193 if (!found?.id) {
194 found = yield* observe(attributeGroupName);
195 }
196
197 // 2. ENSURE — create when missing; tolerate a concurrent-create
198 // race (ConflictException) by re-reading.
199 if (!found?.id) {
200 yield* appregistry
201 .createAttributeGroup({
202 name: attributeGroupName,
203 description: news.description,
204 attributes: desiredAttributes,
205 tags: desiredTags,
206 clientToken: clientToken(instanceId),
207 })
208 .pipe(Effect.catchTag("ConflictException", () => Effect.void));
209 found = yield* observe(attributeGroupName);
210 }
211
212 const attributeGroupId = found!.id!;
213 const attributeGroupArn = found!.arn!;
214
215 // 3a. SYNC description + attributes — apply only the delta.
216 const descriptionChanged =
217 news.description !== undefined &&
218 found!.description !== news.description;
219 const attrsChanged = !attributesEqual(
220 found!.attributes,
221 news.attributes,
222 );
223 if (descriptionChanged || attrsChanged) {
224 yield* appregistry.updateAttributeGroup({
225 attributeGroup: attributeGroupId,
226 description: descriptionChanged ? news.description : undefined,
227 attributes: attrsChanged ? desiredAttributes : undefined,
228 });
229 }
230
231 // 3b. SYNC tags — diff against OBSERVED cloud tags so adoption
232 // converges.
233 const observedTags = stripAwsSystemTags(tagRecord(found!.tags));
234 const { upsert, removed } = diffTags(observedTags, desiredTags);
235 if (upsert.length > 0) {
236 yield* appregistry.tagResource({
237 resourceArn: attributeGroupArn,
238 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
239 });
240 }
241 if (removed.length > 0) {
242 yield* appregistry.untagResource({
243 resourceArn: attributeGroupArn,
244 tagKeys: removed,
245 });
246 }
247
248 yield* session.note(attributeGroupId);
249 return {
250 attributeGroupId,
251 attributeGroupArn,
252 attributeGroupName,
253 };
254 }),
255 delete: Effect.fn(function* ({ output }) {
256 yield* appregistry
257 .deleteAttributeGroup({
258 attributeGroup: output.attributeGroupId,
259 })
260 .pipe(
261 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
262 );
263 }),
264 });
265 }),
266 );