1import * as AWS from "@/AWS";
2import { ScalableTarget, ScalingPolicy } from "@/AWS/ApplicationAutoScaling";
3import { Table } from "@/AWS/DynamoDB";
4import * as Output from "@/Output";
5import * as Provider from "@/Provider";
6import { Stack } from "@/Stack";
7import { State, type ResourceState } from "@/State";
8import * as Test from "@/Test/Alchemy";
9import * as aas from "@distilled.cloud/aws/application-auto-scaling";
10import { expect } from "alchemy-test";
11import * as Effect from "effect/Effect";
12import * as Schedule from "effect/Schedule";
13
14const { test } = Test.make({ providers: AWS.providers() });
15
16const describePolicy = (policyName: string) =>
17 aas
18 .describeScalingPolicies({
19 ServiceNamespace: "dynamodb",
20 PolicyNames: [policyName],
21 })
22 .pipe(
23 Effect.map((res) =>
24 res.ScalingPolicies?.find((p) => p.PolicyName === policyName),
25 ),
26 );
27
28const waitUntilPolicyGone = (policyName: string) =>
29 describePolicy(policyName).pipe(
30 Effect.repeat({
31 schedule: Schedule.spaced("2 seconds"),
32 until: (policy) => policy === undefined,
33 times: 10,
34 }),
35 );
36
37// Target-tracking lifecycle against a provisioned DynamoDB table: put a
38// DynamoDBReadCapacityUtilization tracking policy on a fresh scalable target
39// (exercising the target-propagation retry), verify the managed CloudWatch
40// alarms out-of-band, update the target value in place (same policy ARN),
41// then destroy and verify deletion.
42test.provider(
43 "target tracking policy lifecycle (dynamodb)",
44 (stack) =>
45 Effect.gen(function* () {
46 yield* stack.destroy();
47
48 const deploy = (targetValue: number) =>
49 stack.deploy(
50 Effect.gen(function* () {
51 const table = yield* Table("AasPolicyTable", {
52 partitionKey: "id",
53 attributes: { id: "S" },
54 billingMode: "PROVISIONED",
55 provisionedThroughput: {
56 ReadCapacityUnits: 1,
57 WriteCapacityUnits: 1,
58 },
59 });
60 const target = yield* ScalableTarget("AasPolicyTarget", {
61 serviceNamespace: "dynamodb",
62 resourceId: Output.interpolate`table/${table.tableName}`,
63 scalableDimension: "dynamodb:table:ReadCapacityUnits",
64 minCapacity: 1,
65 maxCapacity: 5,
66 });
67 const policy = yield* ScalingPolicy("AasTrackingPolicy", {
68 serviceNamespace: target.serviceNamespace,
69 resourceId: target.resourceId,
70 scalableDimension: target.scalableDimension,
71 targetTracking: {
72 TargetValue: targetValue,
73 PredefinedMetricSpecification: {
74 PredefinedMetricType: "DynamoDBReadCapacityUtilization",
75 },
76 ScaleOutCooldown: 60,
77 ScaleInCooldown: 60,
78 },
79 });
80 return {
81 resourceId: target.resourceId.as<string>(),
82 policyName: policy.policyName.as<string>(),
83 policyArn: policy.policyArn.as<string>(),
84 policyType: policy.policyType.as<string>(),
85 };
86 }),
87 );
88
89 const created = yield* deploy(70);
90 expect(created.policyType).toBe("TargetTrackingScaling");
91 expect(created.policyArn).toContain("scalingPolicy");
92
93 // Out-of-band: the policy tracks the requested value and Application
94 // Auto Scaling created its managed CloudWatch alarms.
95 const observed = yield* describePolicy(created.policyName);
96 expect(observed?.PolicyType).toBe("TargetTrackingScaling");
97 expect(
98 observed?.TargetTrackingScalingPolicyConfiguration?.TargetValue,
99 ).toBe(70);
100 expect((observed?.Alarms ?? []).length).toBeGreaterThan(0);
101
102 // Update in place — same name and ARN, new target value.
103 const updated = yield* deploy(60);
104 expect(updated.policyArn).toEqual(created.policyArn);
105 const observedAfterUpdate = yield* describePolicy(created.policyName);
106 expect(
107 observedAfterUpdate?.TargetTrackingScalingPolicyConfiguration
108 ?.TargetValue,
109 ).toBe(60);
110
111 yield* stack.destroy();
112 const gone = yield* waitUntilPolicyGone(created.policyName);
113 expect(gone).toBeUndefined();
114
115 // The target is deregistered too.
116 const targets = yield* aas.describeScalableTargets({
117 ServiceNamespace: "dynamodb",
118 ResourceIds: [created.resourceId],
119 ScalableDimension: "dynamodb:table:ReadCapacityUnits",
120 });
121 expect(targets.ScalableTargets ?? []).toHaveLength(0);
122 }),
123 {
124 tags: [
125 "provider:aws",
126 "provider:aws:applicationautoscaling",
127 "provider:aws:dynamodb",
128 "live",
129 ],
130 timeout: 240_000,
131 },
132);
133
134// Deregistering a scalable target implicitly deletes its policies. Simulate
135// an out-of-band deregistration and assert destroy still converges: the
136// policy delete observes the typed ObjectNotFoundException and succeeds.
137test.provider(
138 "destroy tolerates a target deregistered out-of-band (implicit policy deletion)",
139 (stack) =>
140 Effect.gen(function* () {
141 yield* stack.destroy();
142
143 const created = yield* stack.deploy(
144 Effect.gen(function* () {
145 const table = yield* Table("AasDeregTable", {
146 partitionKey: "id",
147 attributes: { id: "S" },
148 billingMode: "PROVISIONED",
149 provisionedThroughput: {
150 ReadCapacityUnits: 1,
151 WriteCapacityUnits: 1,
152 },
153 });
154 const target = yield* ScalableTarget("AasDeregTarget", {
155 serviceNamespace: "dynamodb",
156 resourceId: Output.interpolate`table/${table.tableName}`,
157 scalableDimension: "dynamodb:table:ReadCapacityUnits",
158 minCapacity: 1,
159 maxCapacity: 3,
160 });
161 // NOTE: DynamoDB targets only support TargetTrackingScaling —
162 // putScalingPolicy with StepScaling is rejected (step-scaling
163 // coverage lives in EcsServiceScaling.test.ts).
164 const policy = yield* ScalingPolicy("AasDeregPolicy", {
165 serviceNamespace: target.serviceNamespace,
166 resourceId: target.resourceId,
167 scalableDimension: target.scalableDimension,
168 targetTracking: {
169 TargetValue: 70,
170 PredefinedMetricSpecification: {
171 PredefinedMetricType: "DynamoDBReadCapacityUtilization",
172 },
173 },
174 });
175 return {
176 resourceId: target.resourceId.as<string>(),
177 policyName: policy.policyName.as<string>(),
178 policyType: policy.policyType.as<string>(),
179 };
180 }),
181 );
182 expect(created.policyType).toBe("TargetTrackingScaling");
183
184 // Deregister the target out-of-band — AWS implicitly deletes the
185 // attached scaling policy.
186 yield* aas.deregisterScalableTarget({
187 ServiceNamespace: "dynamodb",
188 ResourceId: created.resourceId,
189 ScalableDimension: "dynamodb:table:ReadCapacityUnits",
190 });
191 const gone = yield* waitUntilPolicyGone(created.policyName);
192 expect(gone).toBeUndefined();
193
194 // Destroy must still converge: deleteScalingPolicy and
195 // deregisterScalableTarget both hit ObjectNotFoundException and treat
196 // it as success.
197 yield* stack.destroy();
198 }),
199 {
200 tags: [
201 "provider:aws",
202 "provider:aws:applicationautoscaling",
203 "provider:aws:dynamodb",
204 "live",
205 ],
206 timeout: 240_000,
207 },
208);
209
210const getState = Effect.fn(function* (fqn: string) {
211 const state = yield* yield* State;
212 const stk = yield* Stack;
213 return (yield* state.get({
214 stack: stk.name,
215 stage: stk.stage,
216 fqn,
217 })) as ResourceState | undefined;
218});
219
220const setState = Effect.fn(function* (fqn: string, value: ResourceState) {
221 const state = yield* yield* State;
222 const stk = yield* Stack;
223 yield* state.set({ stack: stk.name, stage: stk.stage, fqn, value });
224});
225
226// An apply interrupted mid-create (Ctrl-C, crash, CI timeout) can persist a
227// policy row WITHOUT its target triple — `scalableDimension` set,
228// `resourceId` missing. Application Auto Scaling treats ResourceId and
229// ScalableDimension as a pair: one without the other is
230// "ValidationException: Scalable dimension cannot be provided without a
231// resource ID", which used to fail `read` (so every plan) and `delete` (so
232// every destroy of the stack — and every resource depending on the policy
233// was skipped with it). The provider must converge from that row: a
234// re-deploy observes the live policy by name and restores the triple, and a
235// destroy deletes it with the triple the live policy reports.
236test.provider(
237 "read and destroy converge from a row persisted by an interrupted create",
238 (stack) =>
239 Effect.gen(function* () {
240 yield* stack.destroy();
241
242 const program = Effect.gen(function* () {
243 const table = yield* Table("AasPartialTable", {
244 partitionKey: "id",
245 attributes: { id: "S" },
246 billingMode: "PROVISIONED",
247 provisionedThroughput: {
248 ReadCapacityUnits: 1,
249 WriteCapacityUnits: 1,
250 },
251 });
252 const target = yield* ScalableTarget("AasPartialTarget", {
253 serviceNamespace: "dynamodb",
254 resourceId: Output.interpolate`table/${table.tableName}`,
255 scalableDimension: "dynamodb:table:ReadCapacityUnits",
256 minCapacity: 1,
257 maxCapacity: 3,
258 });
259 const policy = yield* ScalingPolicy("AasPartialPolicy", {
260 serviceNamespace: target.serviceNamespace,
261 resourceId: target.resourceId,
262 scalableDimension: target.scalableDimension,
263 targetTracking: {
264 TargetValue: 70,
265 PredefinedMetricSpecification: {
266 PredefinedMetricType: "DynamoDBReadCapacityUtilization",
267 },
268 },
269 });
270 return {
271 resourceId: target.resourceId.as<string>(),
272 policyName: policy.policyName.as<string>(),
273 policyResourceId: policy.resourceId.as<string>(),
274 };
275 });
276
277 const created = yield* stack.deploy(program);
278 expect(created.policyResourceId).toBe(created.resourceId);
279
280 // Simulate the interrupted create: the persisted attrs lose their
281 // resourceId but keep the scalable dimension.
282 const row = yield* getState("AasPartialPolicy");
283 expect(row?.status).toBe("created");
284 if (row?.status !== "created") return;
285 const attr = row.attr as { resourceId?: string; [key: string]: unknown };
286 expect(attr.resourceId).toBe(created.resourceId);
287 const { resourceId: _dropped, ...partialAttr } = attr;
288 const partialRow: ResourceState = { ...row, attr: partialAttr };
289 const readResourceId = Effect.map(
290 getState("AasPartialPolicy"),
291 (r) => (r?.status === "created" ? r.attr : undefined)?.resourceId,
292 );
293 yield* setState("AasPartialPolicy", partialRow);
294 expect(yield* readResourceId).toBe(undefined);
295
296 // `read` (refresh/sync/adopt) must observe the live policy by name
297 // and report the full triple rather than fail on the partial row —
298 // including when the Output-valued `resourceId` prop was lost too.
299 const provider = yield* Provider.findProvider(ScalingPolicy);
300 const { resourceId: _droppedProp, ...partialProps } =
301 partialRow.props as { resourceId?: string; [key: string]: unknown };
302 const observed = yield* provider.read!({
303 id: "AasPartialPolicy",
304 fqn: partialRow.fqn,
305 instanceId: partialRow.instanceId,
306 olds: partialProps as unknown as ScalingPolicy["Props"],
307 output: partialAttr as ScalingPolicy["Attributes"],
308 });
309 expect(observed?.resourceId).toBe(created.resourceId);
310 expect(observed?.scalableDimension).toBe(
311 "dynamodb:table:ReadCapacityUnits",
312 );
313
314 // And `delete` (every destroy) must converge from the partial row
315 // instead of wedging the stack.
316 yield* stack.destroy();
317 const gone = yield* waitUntilPolicyGone(created.policyName);
318 expect(gone).toBeUndefined();
319 }),
320 {
321 tags: [
322 "provider:aws",
323 "provider:aws:applicationautoscaling",
324 "provider:aws:dynamodb",
325 "live",
326 ],
327 timeout: 240_000,
328 },
329);