1import * as licensemanager from "@distilled.cloud/aws/license-manager";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import {
9 createInternalTags,
10 createTagsList,
11 diffTags,
12 hasAlchemyTags,
13} from "../../Tags.ts";
14import type { Providers } from "../Providers.ts";
15
16/**
17 * Dimension used to track license inventory.
18 */
19export type LicenseCountingType = "vCPU" | "Instance" | "Core" | "Socket";
20
21export interface LicenseConfigurationProps {
22 /**
23 * Name of the license configuration.
24 * @default ${app}-${stage}-${id}
25 */
26 name?: string;
27 /**
28 * Description of the license configuration.
29 */
30 description?: string;
31 /**
32 * Dimension used to track the license inventory: `vCPU`, `Instance`,
33 * `Core`, or `Socket`. Cannot be changed after creation — changing it
34 * replaces the license configuration.
35 */
36 licenseCountingType: LicenseCountingType;
37 /**
38 * Number of licenses managed by the license configuration.
39 */
40 licenseCount?: number;
41 /**
42 * Whether the number of licenses is a hard limit. A hard limit blocks
43 * new instance launches once the license count is consumed.
44 * @default false
45 */
46 licenseCountHardLimit?: boolean;
47 /**
48 * License rules (e.g. `#allowedTenancy=EC2-DedicatedHost`,
49 * `#licenseAffinityToHost=30`). The rules allowed depend on the
50 * license counting type.
51 */
52 licenseRules?: string[];
53 /**
54 * When true, disassociates a resource from the license configuration
55 * when the resource is no longer found (e.g. the instance was
56 * terminated).
57 * @default false
58 */
59 disassociateWhenNotFound?: boolean;
60 /**
61 * Tags to apply to the license configuration. Merged with internal
62 * Alchemy tags.
63 */
64 tags?: Record<string, string>;
65}
66
67export interface LicenseConfiguration extends Resource<
68 "AWS.LicenseManager.LicenseConfiguration",
69 LicenseConfigurationProps,
70 {
71 /**
72 * Unique ID of the license configuration, e.g. `lic-0123abcd...`.
73 */
74 licenseConfigurationId: string;
75 /**
76 * ARN of the license configuration.
77 */
78 licenseConfigurationArn: string;
79 /**
80 * Name of the license configuration.
81 */
82 name: string;
83 /**
84 * Dimension used to track the license inventory.
85 */
86 licenseCountingType: string;
87 },
88 never,
89 Providers
90> {}
91
92/**
93 * An AWS License Manager license configuration — an abstraction of a
94 * customer license agreement that License Manager can track and enforce.
95 *
96 * A license configuration specifies the licensing dimension (vCPUs,
97 * instances, cores, or sockets), an optional license count, and whether
98 * the count is a hard limit that blocks new launches once consumed.
99 * ### Creating License Configurations
100 * **Example:** Track licenses by vCPU
101 * ```typescript
102 * import * as LicenseManager from "alchemy/AWS/LicenseManager";
103 *
104 * const licenses = yield* LicenseManager.LicenseConfiguration("Licenses", {
105 * licenseCountingType: "vCPU",
106 * });
107 * ```
108 *
109 * **Example:** Enforce a hard license limit
110 * ```typescript
111 * const licenses = yield* LicenseManager.LicenseConfiguration("Licenses", {
112 * licenseCountingType: "Instance",
113 * licenseCount: 10,
114 * licenseCountHardLimit: true,
115 * });
116 * ```
117 *
118 * **Example:** Socket licensing with dedicated-host rules
119 * ```typescript
120 * const licenses = yield* LicenseManager.LicenseConfiguration("Licenses", {
121 * licenseCountingType: "Socket",
122 * licenseCount: 4,
123 * licenseRules: ["#allowedTenancy=EC2-DedicatedHost"],
124 * description: "Oracle DB socket licenses",
125 * });
126 * ```
127 *
128 * @resource
129 */
130export const LicenseConfiguration = Resource<LicenseConfiguration>(
131 "AWS.LicenseManager.LicenseConfiguration",
132);
133
134/**
135 * `DeleteLicenseConfiguration` soft-deletes: the configuration remains
136 * visible with `Status: "DELETED"` for a while. Treat those as gone.
137 */
138const isLive = (status: string | undefined) => status !== "DELETED";
139
140export const LicenseConfigurationProvider = () =>
141 Provider.effect(
142 LicenseConfiguration,
143 Effect.gen(function* () {
144 const createName = Effect.fn(function* (
145 id: string,
146 props: { name?: string },
147 ) {
148 return props.name ?? (yield* createPhysicalName({ id, maxLength: 96 }));
149 });
150
151 // Enumerate all license configurations (bounded manual pagination —
152 // distilled exposes no `.pages` for this operation).
153 const listAll = Effect.gen(function* () {
154 const items: licensemanager.LicenseConfiguration[] = [];
155 let nextToken: string | undefined;
156 for (let page = 0; page < 50; page++) {
157 const response = yield* licensemanager.listLicenseConfigurations({
158 MaxResults: 100,
159 ...(nextToken ? { NextToken: nextToken } : {}),
160 });
161 items.push(...(response.LicenseConfigurations ?? []));
162 // Empty-string markers are terminal.
163 if (!response.NextToken) break;
164 nextToken = response.NextToken;
165 }
166 return items.filter((item) => isLive(item.Status));
167 });
168
169 const findByName = Effect.fn(function* (name: string) {
170 const items = yield* listAll;
171 return items.find((item) => item.Name === name);
172 });
173
174 // Observe a configuration by its ARN; a deleted/nonexistent ARN
175 // surfaces as the typed not-found tag and maps to `undefined`.
176 const getByArn = Effect.fn(function* (arn: string) {
177 const found = yield* licensemanager
178 .getLicenseConfiguration({ LicenseConfigurationArn: arn })
179 .pipe(
180 Effect.catchTag("LicenseConfigurationNotFound", () =>
181 Effect.succeed(undefined),
182 ),
183 );
184 if (found === undefined || !isLive(found.Status)) return undefined;
185 return found;
186 });
187
188 // Distilled's `Tag` has optional `Key`/`Value`; narrow to a plain
189 // record, dropping any entries the API returned without both fields.
190 const toTagRecord = (
191 tags: licensemanager.Tag[] | undefined,
192 ): Record<string, string> => {
193 const record: Record<string, string> = {};
194 for (const tag of tags ?? []) {
195 if (tag.Key !== undefined && tag.Value !== undefined) {
196 record[tag.Key] = tag.Value;
197 }
198 }
199 return record;
200 };
201
202 const toAttributes = (observed: {
203 LicenseConfigurationId?: string;
204 LicenseConfigurationArn?: string;
205 Name?: string;
206 LicenseCountingType?: string;
207 }) => ({
208 licenseConfigurationId: observed.LicenseConfigurationId!,
209 licenseConfigurationArn: observed.LicenseConfigurationArn!,
210 name: observed.Name!,
211 licenseCountingType: observed.LicenseCountingType!,
212 });
213
214 return LicenseConfiguration.Provider.of({
215 stables: [
216 "licenseConfigurationId",
217 "licenseConfigurationArn",
218 "licenseCountingType",
219 ],
220
221 list: () =>
222 Effect.gen(function* () {
223 const items = yield* listAll;
224 return items.map(toAttributes);
225 }),
226
227 read: Effect.fn(function* ({ id, olds, output }) {
228 let observed:
229 | licensemanager.GetLicenseConfigurationResponse
230 | undefined;
231 if (output?.licenseConfigurationArn) {
232 observed = yield* getByArn(output.licenseConfigurationArn);
233 } else {
234 const name = yield* createName(id, olds ?? {});
235 const summary = yield* findByName(name);
236 if (summary?.LicenseConfigurationArn) {
237 observed = yield* getByArn(summary.LicenseConfigurationArn);
238 }
239 }
240 if (observed === undefined) return undefined;
241 const attrs = toAttributes(observed);
242 const tags = toTagRecord(observed.Tags);
243 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
244 }),
245
246 diff: Effect.fn(function* ({ news, olds }) {
247 if (!isResolved(news)) return undefined;
248 if (olds.licenseCountingType !== news.licenseCountingType) {
249 return { action: "replace" } as const;
250 }
251 // Everything else (name, description, count, hard limit, rules,
252 // tags) updates in place via the default update path.
253 }),
254
255 reconcile: Effect.fn(function* ({ id, news, output, session }) {
256 const name = yield* createName(id, news);
257 const internalTags = yield* createInternalTags(id);
258 const desiredTags: Record<string, string> = {
259 ...(news.tags ?? {}),
260 ...internalTags,
261 };
262
263 // 1. OBSERVE — output is only an ARN cache; cloud state decides.
264 let observed = output?.licenseConfigurationArn
265 ? yield* getByArn(output.licenseConfigurationArn)
266 : undefined;
267 if (observed === undefined) {
268 // State may have been lost (or this is an adoption without a
269 // persisted ARN) — search by name before creating a duplicate.
270 const summary = yield* findByName(name);
271 if (summary?.LicenseConfigurationArn) {
272 observed = yield* getByArn(summary.LicenseConfigurationArn);
273 }
274 }
275
276 // 2. ENSURE — create when missing.
277 if (observed === undefined) {
278 const created = yield* licensemanager.createLicenseConfiguration({
279 Name: name,
280 Description: news.description,
281 LicenseCountingType: news.licenseCountingType,
282 LicenseCount: news.licenseCount,
283 LicenseCountHardLimit: news.licenseCountHardLimit,
284 LicenseRules: news.licenseRules,
285 DisassociateWhenNotFound: news.disassociateWhenNotFound,
286 Tags: createTagsList(desiredTags),
287 });
288 observed = yield* licensemanager.getLicenseConfiguration({
289 LicenseConfigurationArn: created.LicenseConfigurationArn!,
290 });
291 }
292 const arn = observed.LicenseConfigurationArn!;
293
294 // 3. SYNC — diff each mutable aspect against OBSERVED cloud state
295 // and send only the delta.
296 const update: Omit<
297 licensemanager.UpdateLicenseConfigurationRequest,
298 "LicenseConfigurationArn"
299 > = {};
300 if (observed.Name !== name) {
301 update.Name = name;
302 }
303 if (
304 news.description !== undefined &&
305 observed.Description !== news.description
306 ) {
307 update.Description = news.description;
308 }
309 if (
310 news.licenseCount !== undefined &&
311 observed.LicenseCount !== news.licenseCount
312 ) {
313 update.LicenseCount = news.licenseCount;
314 }
315 const desiredHardLimit = news.licenseCountHardLimit ?? false;
316 if ((observed.LicenseCountHardLimit ?? false) !== desiredHardLimit) {
317 update.LicenseCountHardLimit = desiredHardLimit;
318 }
319 if (
320 news.licenseRules !== undefined &&
321 JSON.stringify(observed.LicenseRules ?? []) !==
322 JSON.stringify(news.licenseRules)
323 ) {
324 update.LicenseRules = news.licenseRules;
325 }
326 const desiredDisassociate = news.disassociateWhenNotFound ?? false;
327 if (
328 (observed.DisassociateWhenNotFound ?? false) !== desiredDisassociate
329 ) {
330 update.DisassociateWhenNotFound = desiredDisassociate;
331 }
332 if (Object.keys(update).length > 0) {
333 yield* licensemanager.updateLicenseConfiguration({
334 LicenseConfigurationArn: arn,
335 ...update,
336 });
337 }
338
339 // 3b. SYNC TAGS — against observed cloud tags, never olds/output.
340 const observedTags = toTagRecord(observed.Tags);
341 const { upsert, removed } = diffTags(observedTags, desiredTags);
342 if (upsert.length > 0) {
343 yield* licensemanager.tagResource({
344 ResourceArn: arn,
345 Tags: upsert,
346 });
347 }
348 if (removed.length > 0) {
349 yield* licensemanager.untagResource({
350 ResourceArn: arn,
351 TagKeys: removed,
352 });
353 }
354
355 yield* session.note(arn);
356 return {
357 licenseConfigurationId: observed.LicenseConfigurationId!,
358 licenseConfigurationArn: arn,
359 name,
360 licenseCountingType: observed.LicenseCountingType!,
361 };
362 }),
363
364 // Idempotent: deleting an already-deleted (or never-existing)
365 // configuration surfaces the typed not-found tag and is a no-op.
366 delete: Effect.fn(function* ({ output }) {
367 yield* licensemanager
368 .deleteLicenseConfiguration({
369 LicenseConfigurationArn: output.licenseConfigurationArn,
370 })
371 .pipe(
372 Effect.catchTag(
373 "LicenseConfigurationNotFound",
374 () => Effect.void,
375 ),
376 );
377 }),
378 });
379 }),
380 );