Skip to content
Source
Sign inSign up
Open History to see the last commit

previewTunnelSweep.ts

183 lines6.8 KB

1import { Resource } from "alchemy";
2import * as Provider from "alchemy/Provider";
3import * as Config from "effect/Config";
4import * as Effect from "effect/Effect";
5import * as Redacted from "effect/Redacted";
6import * as Schema from "effect/Schema";
7import * as FetchHttpClient from "effect/http/FetchHttpClient";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import * as HttpClientResponse from "effect/http/HttpClientResponse";
11​
12import { managedEndpointTunnelNamePrefix } from "./deploymentConfig.ts";
13import { isPreviewStage, previewSweepTargets } from "./previewStages.ts";
14​
15// Managed endpoint tunnels and their CNAMEs are created by the Worker at
16// runtime, so Alchemy never tracks them and `alchemy destroy` leaves them behind.
17// This resource exists only so its delete runs during a preview stage's destroy.
18​
19export interface PreviewTunnelSweepProps {
20 readonly stage: string;
21 readonly tunnelZoneName: string;
22 // Depending on the Worker makes Alchemy delete this sweep before the Worker,
23 // because dependents go first. A live Worker could create a tunnel during
24 // that gap; the relay accepts that window.
25 readonly workerName: string;
27​
28export interface PreviewTunnelSweepAttributes {
29 readonly stage: string;
30 readonly tunnelZoneName: string;
31 readonly workerName: string;
33​
34export type PreviewTunnelSweep = Resource<
35 "T3CodeRelay.PreviewTunnelSweep",
36 PreviewTunnelSweepProps,
37 PreviewTunnelSweepAttributes
38>;
39​
40export const PreviewTunnelSweep = Resource<PreviewTunnelSweep>("T3CodeRelay.PreviewTunnelSweep");
41​
42const API = "https://api.cloudflare.com/client/v4";
43const PAGE_SIZE = 100;
44​
45const TunnelPage = Schema.Struct({
46 result: Schema.Array(
47 Schema.Struct({ id: Schema.String, name: Schema.optional(Schema.NullOr(Schema.String)) }),
48 ),
49});
50const ZonePage = Schema.Struct({ result: Schema.Array(Schema.Struct({ id: Schema.String })) });
51const DnsRecordPage = Schema.Struct({
52 result: Schema.Array(
53 Schema.Struct({
54 id: Schema.String,
55 name: Schema.String,
56 content: Schema.optional(Schema.NullOr(Schema.String)),
57 }),
58 ),
59});
60​
61const collectPages = Effect.fn("PreviewTunnelSweep.collectPages")(function* <T, E, R>(
62 fetchPage: (page: number) => Effect.Effect<ReadonlyArray<T>, E, R>,
63) {
64 const items: Array<T> = [];
65 for (let page = 1; ; page += 1) {
66 const batch = yield* fetchPage(page);
67 items.push(...batch);
68 if (batch.length < PAGE_SIZE) return items;
69 }
70});
71​
72// Idempotent: a second run lists nothing and deletes nothing.
73export const sweepPreviewStage = Effect.fn("PreviewTunnelSweep.sweep")(function* (input: {
74 readonly stage: string;
75 readonly tunnelZoneName: string;
76 readonly accountId: string;
77 readonly token: Redacted.Redacted<string>;
78}) {
79 const client = yield* HttpClient.HttpClient;
80 const send = (request: HttpClientRequest.HttpClientRequest) =>
81 request.pipe(
82 HttpClientRequest.bearerToken(Redacted.value(input.token)),
83 client.execute,
84 Effect.flatMap(HttpClientResponse.filterStatusOk),
85 );
86 const getJson = <A, I>(url: string, params: Record<string, string>, schema: Schema.Codec<A, I>) =>
87 send(HttpClientRequest.get(url, { urlParams: params })).pipe(
88 Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
89 );
90 // A 404 means the object is already gone, which is the outcome teardown wants.
91 // Any other failure, including 429, fails the step so the next run retries it.
92 const remove = (url: string) =>
93 HttpClientRequest.make("DELETE")(url).pipe(
94 HttpClientRequest.bearerToken(Redacted.value(input.token)),
95 client.execute,
96 Effect.flatMap((response) =>
97 response.status === 404
98 ? Effect.void
99 : HttpClientResponse.filterStatusOk(response).pipe(Effect.asVoid),
100 ),
101 );
102​
103 const listedTunnels = yield* collectPages((page) =>
104 getJson(
105 `${API}/accounts/${input.accountId}/cfd_tunnel`,
106 {
107 is_deleted: "false",
108 include_prefix: managedEndpointTunnelNamePrefix(input.stage),
109 per_page: String(PAGE_SIZE),
110 page: String(page),
111 },
112 TunnelPage,
113 ).pipe(Effect.map((body) => body.result)),
114 );
115​
116 // No tunnels means nothing to clean, so the zone is not needed and may be gone.
117 if (listedTunnels.length === 0) return;
118​
119 const zone = yield* getJson(`${API}/zones`, { name: input.tunnelZoneName }, ZonePage);
120 const zoneId = zone.result[0]?.id;
121 // Without the zone there are no CNAMEs to remove, but the tunnels still must go.
122 const listedRecords =
123 zoneId === undefined
124 ? []
125 : yield* collectPages((page) =>
126 getJson(
127 `${API}/zones/${zoneId}/dns_records`,
128 { type: "CNAME", per_page: String(PAGE_SIZE), page: String(page) },
129 DnsRecordPage,
130 ).pipe(Effect.map((body) => body.result)),
131 );
132​
133 const targets = previewSweepTargets({
134 stage: input.stage,
135 tunnelZoneName: input.tunnelZoneName,
136 tunnels: listedTunnels,
137 records: listedRecords,
138 });
139​
140 // Records first, so no hostname points at a tunnel that is about to vanish.
141 if (zoneId !== undefined) {
142 for (const record of targets.records) {
143 yield* remove(`${API}/zones/${zoneId}/dns_records/${record.id}`);
144 }
145 }
146 // Cloudflare refuses to delete a tunnel that still has connectors attached.
147 for (const tunnel of targets.tunnels) {
148 yield* remove(`${API}/accounts/${input.accountId}/cfd_tunnel/${tunnel.id}/connections`);
149 yield* remove(`${API}/accounts/${input.accountId}/cfd_tunnel/${tunnel.id}`);
150 }
151});
152​
153export const PreviewTunnelSweepProvider = () =>
154 Provider.succeed(
155 PreviewTunnelSweep,
156 PreviewTunnelSweep.Provider.of({
157 reconcile: ({ news }) =>
158 Effect.succeed({
159 stage: news.stage,
160 tunnelZoneName: news.tunnelZoneName,
161 workerName: news.workerName,
162 }),
163 // Nothing in Cloudflare is created for this resource, so there is nothing to list.
164 list: () => Effect.succeed([]),
165 delete: Effect.fn(function* ({ output }) {
166 // The sweep runs only on preview stages. This guard keeps a mistaken
167 // stage from reaching the production relay's tunnels.
168 if (!isPreviewStage(output.stage)) {
169 return yield* Effect.die(`Refusing to sweep non-preview stage '${output.stage}'`);
170 }
171 // Read from the environment, not the Cloudflare service, so the provider
172 // needs no extra service in its layer. CI sets both for the destroy step.
173 const accountId = yield* Config.String("CLOUDFLARE_ACCOUNT_ID");
174 const token = yield* Config.Redacted("CLOUDFLARE_API_TOKEN");
175 yield* sweepPreviewStage({
176 stage: output.stage,
177 tunnelZoneName: output.tunnelZoneName,
178 accountId,
179 token,
180 }).pipe(Effect.provide(FetchHttpClient.layer));
181 }),
182 }),
183 );