1import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy";
2import * as Cloudflare from "@/Cloudflare";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import * as Provider from "@/Provider";
5import { isResourceState, State, type ResourceState } from "@/State";
6import * as Test from "@/Test/Alchemy";
7import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
8import { expect } from "alchemy-test";
9import * as Cause from "effect/Cause";
10import * as Effect from "effect/Effect";
11import * as Stream from "effect/Stream";
12
13const { test } = Test.make({
14 providers: Cloudflare.providers(),
15 state: Cloudflare.state(),
16});
17
18test.provider(
19 "list enumerates the deployed infrastructure target",
20 (stack) =>
21 Effect.gen(function* () {
22 const { accountId } = yield* yield* CloudflareEnvironment;
23
24 yield* stack.destroy();
25
26 const target = yield* stack.deploy(
27 Effect.gen(function* () {
28 return yield* Cloudflare.Access.InfrastructureTarget("ListTarget", {
29 hostname: "list-test.bastion.internal",
30 ip: { ipv4: { ipAddr: "10.7.0.42" } },
31 });
32 }),
33 );
34
35 const provider = yield* Provider.findProvider(
36 Cloudflare.Access.InfrastructureTarget,
37 );
38 const all = yield* provider.list();
39
40 const found = all.find((t) => t.targetId === target.targetId);
41 expect(found).toBeDefined();
42 expect(found?.accountId).toEqual(accountId);
43 expect(found?.hostname).toEqual(target.hostname);
44 expect(found?.ip.ipv4?.ipAddr).toEqual("10.7.0.42");
45
46 yield* stack.destroy();
47 }),
48 { tags: ["provider:cloudflare", "provider:cloudflare:access", "live"] },
49);
50
51test.provider(
52 "recovers a half-created target whose creating-state lost Output-valued props (#736)",
53 (stack) =>
54 Effect.gen(function* () {
55 const hostname = "wedged-recovery.bastion.internal";
56
57 yield* stack.destroy();
58
59 // Safety net: a previously crashed run leaves an orphaned cloud
60 // target (its state row is wedged attr-less, so destroy can't reach
61 // it) which would fail the fresh deploy with OwnedBySomeoneElse.
62 // Sweep any target with our deterministic hostname out-of-band.
63 const { accountId } = yield* yield* CloudflareEnvironment;
64 const orphans = yield* zeroTrust.listAccessInfrastructureTargets
65 .items({ accountId, hostname })
66 .pipe(
67 Stream.filter((t) => t.hostname === hostname),
68 Stream.runCollect,
69 );
70 yield* Effect.forEach(Array.from(orphans), (t) =>
71 zeroTrust
72 .deleteAccessInfrastructureTarget({ accountId, targetId: t.id })
73 .pipe(Effect.catchTag("TargetNotFound", () => Effect.void)),
74 );
75
76 const deployTarget = (adoptIt?: boolean) =>
77 stack.deploy(
78 Effect.gen(function* () {
79 const target = Cloudflare.Access.InfrastructureTarget(
80 "WedgedTarget",
81 {
82 hostname,
83 ip: { ipv4: { ipAddr: "10.7.0.99" } },
84 },
85 );
86 return yield* adoptIt ? target.pipe(adopt(true)) : target;
87 }),
88 );
89
90 const created = yield* deployTarget();
91
92 // Rewrite the target's persisted row into the wedged shape an
93 // interrupted deploy leaves behind: `creating`, no attributes, and
94 // the Output-valued `ip` prop lost in the round-trip. `hostname`
95 // survives so the cold-read identity path runs.
96 const state = yield* yield* State;
97 const stage = stack.stage;
98 const fqns = yield* state.list({ stack: stack.name, stage });
99 const rows = yield* Effect.forEach(fqns, (fqn) =>
100 state
101 .get({ stack: stack.name, stage, fqn })
102 .pipe(Effect.map((row) => ({ fqn, row }))),
103 );
104 const wedged = rows.find(
105 (r): r is { fqn: string; row: ResourceState } =>
106 isResourceState(r.row) &&
107 r.row.resourceType === "Cloudflare.Access.InfrastructureTarget",
108 );
109 if (!wedged) {
110 return yield* Effect.die(
111 new Error(
112 "no Cloudflare.Access.InfrastructureTarget state row found after deploy",
113 ),
114 );
115 }
116 yield* state.set({
117 stack: stack.name,
118 stage,
119 fqn: wedged.fqn,
120 value: {
121 ...wedged.row,
122 status: "creating",
123 attr: undefined,
124 props: { ...wedged.row.props, ip: undefined },
125 },
126 });
127
128 // Before the #736 fix, `read` crashed here with
129 // `TypeError: undefined is not an object (evaluating 'ip.ipv4')`
130 // (resolvedIp(olds.ip) with olds.ip === undefined). After the fix,
131 // read falls back to the output hint and matches by hostname —
132 // but targets carry no ownership markers, so the match surfaces as
133 // `Unowned`. Since #862, resuming an interrupted create onto an
134 // Unowned resource fails loudly instead of silently taking over.
135 const error = yield* deployTarget().pipe(
136 Effect.as(undefined),
137 Effect.catchCause((cause) => Effect.succeed(findOwnedError(cause))),
138 );
139 expect(error).toBeInstanceOf(OwnedBySomeoneElse);
140
141 // adopt(true) opts in: the engine converges onto the same target —
142 // no duplicate created.
143 const recovered = yield* deployTarget(true);
144 expect(recovered.targetId).toEqual(created.targetId);
145 expect(recovered.hostname).toEqual(created.hostname);
146 expect(recovered.ip.ipv4?.ipAddr).toEqual("10.7.0.99");
147
148 yield* stack.destroy();
149 }),
150 {
151 tags: ["provider:cloudflare", "provider:cloudflare:access", "live"],
152 timeout: 240_000,
153 },
154);
155
156const findOwnedError = (
157 cause: Cause.Cause<unknown>,
158): OwnedBySomeoneElse | undefined =>
159 cause.reasons
160 .map((reason) =>
161 Cause.isFailReason(reason)
162 ? reason.error
163 : Cause.isDieReason(reason)
164 ? reason.defect
165 : undefined,
166 )
167 .find(
168 (value): value is OwnedBySomeoneElse =>
169 value instanceof OwnedBySomeoneElse,
170 );