1import * as AWS from "@/AWS";
2import { LicenseConfiguration } from "@/AWS/LicenseManager";
3import type { LicenseConfigurationProps } from "@/AWS/LicenseManager/LicenseConfiguration.ts";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as iam from "@distilled.cloud/aws/iam";
7import * as licensemanager from "@distilled.cloud/aws/license-manager";
8import * as sts from "@distilled.cloud/aws/sts";
9import { expect } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15// License Manager requires one-time account onboarding: without its
16// service-linked role EVERY operation fails with AccessDeniedException
17// ("Service role not found..."). Creating the SLR is idempotent — an
18// already-onboarded account returns the typed InvalidInputException
19// ("has been taken in this account"). A freshly created SLR takes a few
20// seconds of IAM propagation before License Manager sees it, so probe
21// with a bounded typed retry on AccessDeniedException.
22const ensureOnboarded = Effect.gen(function* () {
23 yield* iam
24 .createServiceLinkedRole({
25 AWSServiceName: "license-manager.amazonaws.com",
26 })
27 .pipe(Effect.catchTag("InvalidInputException", () => Effect.void));
28 yield* licensemanager.listLicenseConfigurations({ MaxResults: 1 }).pipe(
29 Effect.retry({
30 while: (e) => e._tag === "AccessDeniedException",
31 schedule: Schedule.exponential("1 second"),
32 times: 8,
33 }),
34 );
35});
36
37// Ungated typed-error probe: prove the distilled error union carries the
38// not-found tag this provider's read/delete paths depend on.
39test.provider(
40 "getLicenseConfiguration on a bogus ARN fails with LicenseConfigurationNotFound",
41 () =>
42 Effect.gen(function* () {
43 yield* ensureOnboarded;
44 const region = yield* yield* AWS.Region;
45 const { Account } = yield* sts.getCallerIdentity({});
46 const error = yield* Effect.flip(
47 licensemanager.getLicenseConfiguration({
48 LicenseConfigurationArn: `arn:aws:license-manager:${region}:${Account}:license-configuration:lic-00000000000000000000000000000000`,
49 }),
50 );
51 expect(error._tag).toBe("LicenseConfigurationNotFound");
52 }),
53 {
54 tags: [
55 "provider:aws",
56 "provider:aws:iam",
57 "provider:aws:licensemanager",
58 "live",
59 ],
60 timeout: 60_000,
61 },
62);
63
64// CreateLicenseConfiguration has a small DAILY account quota (~10 creates;
65// deletes do not refund it). Keep create-based lifecycle coverage explicit so
66// aggregate sweeps never exhaust the shared account for the rest of the day.
67// The typed not-found probe and provider-diff assertion remain unconditional.
68const RUN_CREATE_LIFECYCLE =
69 process.env.AWS_TEST_LICENSE_MANAGER_CREATE === "1";
70
71const callDiff = (
72 olds: LicenseConfigurationProps,
73 news: LicenseConfigurationProps,
74) =>
75 Effect.gen(function* () {
76 const provider = yield* Provider.findProvider(LicenseConfiguration);
77 return yield* provider.diff!({
78 id: "Licenses",
79 fqn: "Licenses",
80 instanceId: "licenses",
81 olds,
82 news,
83 oldBindings: undefined as never,
84 newBindings: undefined as never,
85 output: undefined,
86 });
87 });
88const isLive = (status: string | undefined) => status !== "DELETED";
89
90// Out-of-band read via distilled; DELETED (soft-deleted) counts as gone.
91const getLive = (arn: string) =>
92 licensemanager.getLicenseConfiguration({ LicenseConfigurationArn: arn }).pipe(
93 Effect.map((r) => (isLive(r.Status) ? r : undefined)),
94 Effect.catchTag("LicenseConfigurationNotFound", () =>
95 Effect.succeed(undefined),
96 ),
97 );
98
99test.provider.skipIf(!RUN_CREATE_LIFECYCLE)(
100 "create, update, and delete a license configuration",
101 (stack) =>
102 Effect.gen(function* () {
103 yield* ensureOnboarded;
104 yield* stack.destroy();
105
106 // Create — vCPU counting, soft count.
107 const { licenses } = yield* stack.deploy(
108 Effect.gen(function* () {
109 const licenses = yield* LicenseConfiguration("Licenses", {
110 licenseCountingType: "vCPU",
111 licenseCount: 10,
112 description: "alchemy license-manager test",
113 tags: { fixture: "license-configuration" },
114 });
115 return { licenses };
116 }),
117 );
118
119 expect(licenses.licenseConfigurationId).toMatch(/^lic-/);
120 expect(licenses.licenseConfigurationArn).toContain(
121 ":license-configuration:",
122 );
123 expect(licenses.licenseCountingType).toBe("vCPU");
124
125 // Out-of-band verification via distilled.
126 const observed = yield* licensemanager.getLicenseConfiguration({
127 LicenseConfigurationArn: licenses.licenseConfigurationArn,
128 });
129 expect(observed.Name).toBe(licenses.name);
130 expect(observed.LicenseCountingType).toBe("vCPU");
131 expect(observed.LicenseCount).toBe(10);
132 expect(observed.LicenseCountHardLimit).toBe(false);
133 const tags = Object.fromEntries(
134 (observed.Tags ?? []).map((t) => [t.Key, t.Value]),
135 );
136 expect(tags.fixture).toBe("license-configuration");
137 expect(tags["alchemy::id"]).toBe("Licenses");
138
139 // Update in place — count, hard limit, description, tags.
140 const { licenses: updated } = yield* stack.deploy(
141 Effect.gen(function* () {
142 const licenses = yield* LicenseConfiguration("Licenses", {
143 licenseCountingType: "vCPU",
144 licenseCount: 20,
145 licenseCountHardLimit: true,
146 description: "alchemy license-manager test (updated)",
147 tags: { fixture: "license-configuration", phase: "two" },
148 });
149 return { licenses };
150 }),
151 );
152
153 // Same physical resource — update, not replace.
154 expect(updated.licenseConfigurationArn).toBe(
155 licenses.licenseConfigurationArn,
156 );
157 const afterUpdate = yield* licensemanager.getLicenseConfiguration({
158 LicenseConfigurationArn: licenses.licenseConfigurationArn,
159 });
160 expect(afterUpdate.LicenseCount).toBe(20);
161 expect(afterUpdate.LicenseCountHardLimit).toBe(true);
162 expect(afterUpdate.Description).toBe(
163 "alchemy license-manager test (updated)",
164 );
165 const updatedTags = Object.fromEntries(
166 (afterUpdate.Tags ?? []).map((t) => [t.Key, t.Value]),
167 );
168 expect(updatedTags.phase).toBe("two");
169
170 // Destroy and verify deletion out-of-band (soft-delete counts).
171 yield* stack.destroy();
172 const gone = yield* getLive(licenses.licenseConfigurationArn);
173 expect(gone).toBeUndefined();
174 }),
175 {
176 tags: [
177 "provider:aws",
178 "provider:aws:iam",
179 "provider:aws:licensemanager",
180 "live",
181 ],
182 timeout: 120_000,
183 },
184);
185
186test.provider(
187 "diff: changing licenseCountingType forces replacement",
188 () =>
189 Effect.gen(function* () {
190 const result = yield* callDiff(
191 { licenseCountingType: "Instance", licenseCount: 2 },
192 { licenseCountingType: "Core", licenseCount: 2 },
193 );
194 expect(result).toEqual({ action: "replace" });
195 }),
196 { tags: ["provider:aws", "provider:aws:licensemanager", "live"] },
197);
198
199test.provider.skipIf(!RUN_CREATE_LIFECYCLE)(
200 "changing licenseCountingType replaces the license configuration",
201 (stack) =>
202 Effect.gen(function* () {
203 yield* ensureOnboarded;
204 yield* stack.destroy();
205
206 const { licenses: first } = yield* stack.deploy(
207 Effect.gen(function* () {
208 const licenses = yield* LicenseConfiguration("Replaceable", {
209 licenseCountingType: "Instance",
210 licenseCount: 2,
211 });
212 return { licenses };
213 }),
214 );
215 expect(first.licenseCountingType).toBe("Instance");
216
217 const { licenses: second } = yield* stack.deploy(
218 Effect.gen(function* () {
219 const licenses = yield* LicenseConfiguration("Replaceable", {
220 licenseCountingType: "Core",
221 licenseCount: 2,
222 });
223 return { licenses };
224 }),
225 );
226
227 expect(second.licenseCountingType).toBe("Core");
228 expect(second.licenseConfigurationArn).not.toBe(
229 first.licenseConfigurationArn,
230 );
231
232 // The replaced (old) configuration is deleted.
233 const old = yield* getLive(first.licenseConfigurationArn);
234 expect(old).toBeUndefined();
235
236 yield* stack.destroy();
237 const gone = yield* getLive(second.licenseConfigurationArn);
238 expect(gone).toBeUndefined();
239 }),
240 {
241 tags: [
242 "provider:aws",
243 "provider:aws:iam",
244 "provider:aws:licensemanager",
245 "live",
246 ],
247 timeout: 120_000,
248 },
249);