1import * as AWS from "@/AWS";
2import {
3 Policy,
4 PolicyAttachment,
5 Root,
6 RootPolicyType,
7} from "@/AWS/Organizations";
8import * as Provider from "@/Provider";
9import { isResourceState, State, type ResourceState } from "@/State";
10import * as Test from "@/Test/Alchemy";
11import * as organizations from "@distilled.cloud/aws/organizations";
12import { expect } from "alchemy-test";
13import * as Effect from "effect/Effect";
14import * as Schedule from "effect/Schedule";
15
16const { test } = Test.make({ providers: AWS.providers() });
17
18// `list()` enumerates every (policyId, targetId) attachment by fanning out over
19// all policy types (listPolicies per type) and listing each policy's targets
20// (listTargetsForPolicy per policy). This runs read-only: when the testing
21// account isn't an org management account / delegated administrator, the typed
22// AccessDeniedException / AWSOrganizationsNotInUseException catches degrade the
23// result to [], so the assertion holds without deploying anything.
24test.provider(
25 "list enumerates policy attachments",
26 () =>
27 Effect.gen(function* () {
28 const provider = yield* Provider.findProvider(PolicyAttachment);
29 const all = yield* provider.list();
30
31 expect(Array.isArray(all)).toBe(true);
32
33 for (const attachment of all) {
34 expect(typeof attachment.policyId).toBe("string");
35 expect(typeof attachment.targetId).toBe("string");
36 }
37 }),
38 { tags: ["provider:aws", "provider:aws:organizations", "live"] },
39);
40
41// Regression test for https://github.com/alchemy-run/alchemy/issues/736.
42//
43// An interrupted first deploy persists the attachment as `status: "creating"`
44// with no attributes — and the Output-valued props (`policyId` from the Policy
45// resource, `targetId` from the RootPolicyType resource) do not survive the
46// state round-trip: they deserialize as `undefined`. Plan's creating-recovery
47// branch then calls `provider.read` with those junk props, which crashed in
48// `listTargetsForPolicy({ PolicyId: undefined })` and wedged the stack.
49//
50// Simulate exactly that state row after a real deploy and assert the next
51// deploy recovers: `read` reports "not found", the engine re-drives the
52// create, and reconcile observes the existing attachment before attaching —
53// SAME (policyId, targetId) identity, no duplicate attachment.
54//
55// Requires an org MANAGEMENT account (enablePolicyType / createPolicy /
56// attachPolicy all reject off one), so gate behind the same env var the other
57// Organizations lifecycle tests use.
58test.provider.skipIf(!process.env.AWS_ORG_MANAGEMENT_ACCOUNT)(
59 "recovers a half-created attachment whose creating-state lost Output-valued props (#736)",
60 (stack) =>
61 Effect.gen(function* () {
62 const policyName = "alchemy-test-736-attachment";
63
64 // A prior interrupted run may have left an (untagged, hence unowned)
65 // SCP with our deterministic name behind — the adoption probe would
66 // then fail the create with `OwnedBySomeoneElse`. Clean it up
67 // out-of-band before deploying, and again on scope close.
68 const cleanLeftoverPolicies = Effect.gen(function* () {
69 const policies = yield* organizations.listPolicies({
70 Filter: "SERVICE_CONTROL_POLICY",
71 });
72 const leftovers = (policies.Policies ?? []).filter(
73 (policy): policy is organizations.PolicySummary & { Id: string } =>
74 policy.Name === policyName && policy.Id != null,
75 );
76 for (const leftover of leftovers) {
77 const targets = yield* organizations
78 .listTargetsForPolicy({ PolicyId: leftover.Id })
79 .pipe(
80 Effect.catchTag("PolicyNotFoundException", () =>
81 Effect.succeed({ Targets: [] }),
82 ),
83 );
84 for (const target of targets.Targets ?? []) {
85 if (target.TargetId == null) continue;
86 yield* organizations
87 .detachPolicy({
88 PolicyId: leftover.Id,
89 TargetId: target.TargetId,
90 })
91 .pipe(
92 Effect.catchTag(
93 [
94 "PolicyNotAttachedException",
95 "PolicyNotFoundException",
96 "TargetNotFoundException",
97 ],
98 () => Effect.void,
99 ),
100 );
101 }
102 // The detach above can lag — retry the delete through the typed
103 // `PolicyInUseException` (bounded), and tolerate already-gone.
104 yield* organizations.deletePolicy({ PolicyId: leftover.Id }).pipe(
105 Effect.retry({
106 while: (error) => error._tag === "PolicyInUseException",
107 schedule: Schedule.spaced("3 seconds"),
108 times: 8,
109 }),
110 Effect.catchTag("PolicyNotFoundException", () => Effect.void),
111 );
112 }
113 });
114 yield* cleanLeftoverPolicies;
115 yield* Effect.addFinalizer(() =>
116 cleanLeftoverPolicies.pipe(Effect.ignore),
117 );
118
119 yield* stack.destroy();
120
121 const deployStack = (includeAttachment: boolean) =>
122 stack.deploy(
123 Effect.gen(function* () {
124 // Import-style resource — adopts the single organization root.
125 const root = yield* Root("WedgedRoot", {});
126 const scpType = yield* RootPolicyType("WedgedScpType", {
127 rootId: root.rootId,
128 policyType: "SERVICE_CONTROL_POLICY",
129 });
130 // Allow-all SCP — attaching it alongside the AWS-managed
131 // FullAWSAccess policy changes nothing about effective access.
132 const policy = yield* Policy("WedgedScp", {
133 name: policyName,
134 type: "SERVICE_CONTROL_POLICY",
135 document: {
136 Version: "2012-10-17",
137 Statement: [{ Effect: "Allow", Action: ["*"], Resource: "*" }],
138 },
139 });
140 if (!includeAttachment) {
141 return undefined;
142 }
143 return yield* PolicyAttachment("WedgedAttachment", {
144 // Both identity props are Output-valued — the #736 shape.
145 policyId: policy.policyId,
146 // Thread the RootPolicyType's output so the attachment plans
147 // after SERVICE_CONTROL_POLICY is enabled on the root.
148 targetId: scpType.rootId,
149 });
150 }),
151 );
152
153 // Stage 1: enable the policy type + create the SCP, WITHOUT the
154 // attachment. `enablePolicyType` completes asynchronously
155 // (PENDING_ENABLE), and `attachPolicy` rejects with the typed
156 // `PolicyTypeNotEnabledException` until it lands — so wait (bounded)
157 // for ENABLED before deploying the attachment.
158 yield* deployStack(false);
159 yield* organizations.listRoots({}).pipe(
160 Effect.map((page) =>
161 (page.Roots ?? []).some((root) =>
162 (root.PolicyTypes ?? []).some(
163 (summary) =>
164 summary.Type === "SERVICE_CONTROL_POLICY" &&
165 summary.Status === "ENABLED",
166 ),
167 ),
168 ),
169 Effect.repeat({
170 schedule: Schedule.spaced("3 seconds"),
171 until: (enabled) => enabled,
172 times: 10,
173 }),
174 );
175
176 // Stage 2: deploy the attachment itself.
177 const created = yield* deployStack(true);
178 if (created === undefined) {
179 return yield* Effect.die(
180 new Error("stage-2 deploy returned no attachment"),
181 );
182 }
183
184 // Rewrite the attachment's persisted row into the wedged shape an
185 // interrupted deploy leaves behind: `creating`, no attributes, and
186 // every Output-valued prop lost in the round-trip.
187 const state = yield* yield* State;
188 const stage = stack.stage;
189 const fqns = yield* state.list({ stack: stack.name, stage });
190 const rows = yield* Effect.forEach(fqns, (fqn) =>
191 state
192 .get({ stack: stack.name, stage, fqn })
193 .pipe(Effect.map((row) => ({ fqn, row }))),
194 );
195 const wedged = rows.find(
196 (r): r is { fqn: string; row: ResourceState } =>
197 isResourceState(r.row) &&
198 r.row.resourceType === "AWS.Organizations.PolicyAttachment",
199 );
200 if (!wedged) {
201 return yield* Effect.die(
202 new Error(
203 "no AWS.Organizations.PolicyAttachment state row found after deploy",
204 ),
205 );
206 }
207 yield* state.set({
208 stack: stack.name,
209 stage,
210 fqn: wedged.fqn,
211 value: {
212 ...wedged.row,
213 status: "creating",
214 attr: undefined,
215 props: {
216 ...wedged.row.props,
217 policyId: undefined,
218 targetId: undefined,
219 },
220 },
221 });
222
223 // Before the fix this failed in plan: `read` called
224 // `listTargetsForPolicy({ PolicyId: undefined })` with the junk olds.
225 const recovered = yield* deployStack(true);
226 expect(recovered?.policyId).toEqual(created.policyId);
227 expect(recovered?.targetId).toEqual(created.targetId);
228
229 yield* stack.destroy();
230 }),
231 {
232 tags: ["provider:aws", "provider:aws:organizations", "live"],
233 timeout: 240_000,
234 },
235);