| 1 | import * as licensemanager from "@distilled.cloud/aws/license-manager"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 4 | import { isResolved } from "../../Diff.ts"; |
| 5 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 6 | import * as Provider from "../../Provider.ts"; |
| 7 | import { Resource } from "../../Resource.ts"; |
| 8 | import { |
| 9 | createInternalTags, |
| 10 | createTagsList, |
| 11 | diffTags, |
| 12 | hasAlchemyTags, |
| 13 | } from "../../Tags.ts"; |
| 14 | import type { Providers } from "../Providers.ts"; |
| 15 | |
| 16 | /** |
| 17 | * Dimension used to track license inventory. |
| 18 | */ |
| 19 | export type LicenseCountingType = "vCPU" | "Instance" | "Core" | "Socket"; |
| 20 | |
| 21 | export 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 | |
| 67 | export 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 | */ |
| 130 | export 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 | */ |
| 138 | const isLive = (status: string | undefined) => status !== "DELETED"; |
| 139 | |
| 140 | export 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 | ); |