1import * as gkebackup from "@distilled.cloud/gcp/gkebackup_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 fieldMask,
19 listAtLocation,
20 listLabeledPages,
21 normalizeLocation,
22 parentOf,
23 parseName,
24 projectName,
25 replaceOnIdentity,
26 ResourceNotResolved,
27 sameText,
28 toPhysicalId,
29 userLabels,
30 waitForDeleteOperation,
31 waitForOperation,
32 waitUntilExists,
33 waitUntilGone,
34} from "./internal.ts";
35
36export type RestoreChannelProps = {
37 /**
38 * Restore channel id (the `{restoreChannel}` segment of
39 * `projects/{project}/locations/{location}/restoreChannels/{restoreChannel}`).
40 * If omitted, a unique RFC1035 name is generated from the stack, stage,
41 * and logical id. Immutable — changing it replaces the channel.
42 */
43 restoreChannelId?: string;
44 /**
45 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
46 * replaces the channel. `US-CENTRAL1` is accepted and normalized to
47 * `us-central1`.
48 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
49 */
50 location?: string;
51 /**
52 * Project that is allowed to restore backups via this channel. Full
53 * name `projects/{project}` or a project id. Must differ from the
54 * source project. Immutable — changing it replaces the channel.
55 */
56 destinationProject: string;
57 /**
58 * Human-readable description.
59 */
60 description?: string;
61 /**
62 * User labels. Alchemy ownership labels are merged in automatically.
63 */
64 labels?: Record<string, string>;
65};
66
67export type RestoreChannel = Resource<
68 "GCP.GKEBackup.RestoreChannel",
69 RestoreChannelProps,
70 {
71 /** Full resource name. */
72 name: string;
73 /** Restore channel id (last path segment). */
74 restoreChannelId: string;
75 /** Project id. */
76 project: string;
77 /** Location id. */
78 location: string;
79 /** Destination project resource name. */
80 destinationProject: string | undefined;
81 /** Destination project id. */
82 destinationProjectId: string | undefined;
83 /** Human-readable description. */
84 description: string | undefined;
85 /** User labels (Alchemy ownership labels stripped). */
86 labels: Record<string, string>;
87 /** Server-generated resource uid. */
88 uid: string | undefined;
89 /** RFC3339 creation timestamp. */
90 createTime: string | undefined;
91 /** RFC3339 last-update timestamp. */
92 updateTime: string | undefined;
93 },
94 never,
95 Providers
96>;
97
98/**
99 * A Backup for GKE restore channel that authorizes restoring backups
100 * into a destination project.
101 *
102 * Changing `restoreChannelId`, `location`, or `destinationProject`
103 * replaces the channel. Description and labels update in place.
104 *
105 * ### Creating a Restore Channel
106 * **Example:** Generated name
107 * ```typescript
108 * const channel = yield* GCP.GKEBackup.RestoreChannel("CrossProject", {
109 * destinationProject: "projects/my-restore-project",
110 * });
111 * ```
112 *
113 * **Example:** Explicit id, destination, and labels
114 * ```typescript
115 * const channel = yield* GCP.GKEBackup.RestoreChannel("CrossProject", {
116 * restoreChannelId: "app-restores",
117 * destinationProject: "projects/my-restore-project",
118 * description: "cross-project restores",
119 * labels: { env: "prod" },
120 * });
121 * ```
122 *
123 * ### Updating a Restore Channel
124 * **Example:** Description and labels
125 * ```typescript
126 * const channel = yield* GCP.GKEBackup.RestoreChannel("CrossProject", {
127 * destinationProject: "projects/my-restore-project",
128 * description: "cross-project restores v2",
129 * labels: { env: "prod", team: "platform" },
130 * });
131 * ```
132 *
133 * @resource
134 * @category GKEBackup
135 */
136export const RestoreChannel = Resource<RestoreChannel>(
137 "GCP.GKEBackup.RestoreChannel",
138);
139
140const resourceName = (
141 project: string,
142 location: string,
143 restoreChannelId: string,
144) =>
145 `projects/${project}/locations/${location}/restoreChannels/${restoreChannelId}`;
146
147const toAttrs = (
148 item: gkebackup.RestoreChannel,
149 project: string,
150 region: string,
151) => {
152 const name = item.name ?? "";
153 const parsed = parseName(name, "restoreChannels", region);
154 return {
155 name,
156 restoreChannelId: parsed.id,
157 project: parsed.project || project,
158 location: parsed.location,
159 destinationProject: item.destinationProject,
160 destinationProjectId: item.destinationProjectId,
161 description: item.description,
162 labels: userLabels(item.labels),
163 uid: item.uid,
164 createTime: item.createTime,
165 updateTime: item.updateTime,
166 };
167};
168
169const getByName = (name: string) =>
170 gkebackup
171 .getProjectsLocationsRestoreChannels({ name })
172 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
173
174const listOwned = (project: string) =>
175 listAtLocation(project, (parent) =>
176 listLabeledPages(
177 gkebackup.listProjectsLocationsRestoreChannels.pages({
178 parent,
179 pageSize: 1000,
180 }),
181 (page) => page.restoreChannels,
182 (item) => item.labels,
183 ),
184 );
185
186export const RestoreChannelProvider = () =>
187 Provider.succeed(RestoreChannel, {
188 stables: [
189 "name",
190 "restoreChannelId",
191 "project",
192 "location",
193 "uid",
194 "createTime",
195 ],
196
197 diff: Effect.fn(function* ({ news, olds, output }) {
198 if (!isResolved(news)) return undefined;
199 const env = yield* GcpEnvironment.current;
200 const previousDest =
201 olds?.destinationProject ?? output?.destinationProject;
202 const nextDest = news.destinationProject;
203 return replaceOnIdentity({
204 previousId: olds?.restoreChannelId ?? output?.restoreChannelId,
205 nextId:
206 news.restoreChannelId ??
207 olds?.restoreChannelId ??
208 output?.restoreChannelId,
209 previousLocation: normalizeLocation(
210 olds?.location ?? output?.location,
211 env.region,
212 ),
213 nextLocation: normalizeLocation(
214 news.location ?? olds?.location ?? output?.location,
215 env.region,
216 ),
217 extra:
218 previousDest !== undefined &&
219 nextDest !== undefined &&
220 previousDest !== nextDest &&
221 !previousDest.endsWith(`/${nextDest}`) &&
222 previousDest !== `projects/${nextDest}`,
223 });
224 }),
225
226 read: Effect.fn(function* ({ id, olds, output }) {
227 const env = yield* GcpEnvironment.current;
228 const restoreChannelId = yield* toPhysicalId(
229 id,
230 olds?.restoreChannelId,
231 output?.restoreChannelId,
232 "restorechannel",
233 );
234 const location = normalizeLocation(
235 olds?.location ?? output?.location,
236 env.region,
237 );
238 const name =
239 output?.name ?? resourceName(env.project, location, restoreChannelId);
240 const existing = yield* getByName(name);
241 if (existing === undefined) return undefined;
242 const attrs = toAttrs(existing, env.project, env.region);
243 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
244 ? attrs
245 : Unowned(attrs);
246 }),
247
248 list: () =>
249 Effect.gen(function* () {
250 const env = yield* GcpEnvironment.current;
251 const items = yield* listOwned(env.project);
252 return items.map((item) => toAttrs(item, env.project, env.region));
253 }),
254
255 reconcile: Effect.fn(function* ({ id, news, output }) {
256 const env = yield* GcpEnvironment.current;
257 const restoreChannelId = yield* toPhysicalId(
258 id,
259 news.restoreChannelId,
260 output?.restoreChannelId,
261 "restorechannel",
262 );
263 const location = normalizeLocation(
264 news.location ?? output?.location,
265 env.region,
266 );
267 const name = resourceName(env.project, location, restoreChannelId);
268 const destinationProject = projectName(
269 news.destinationProject,
270 env.project,
271 );
272 const desiredLabels = {
273 ...toLabels(news.labels),
274 ...(yield* createInternalLabels(id)),
275 };
276
277 let current = yield* getByName(output?.name ?? name);
278
279 if (current === undefined) {
280 const created = yield* gkebackup
281 .createProjectsLocationsRestoreChannels({
282 parent: parentOf(env.project, location),
283 restoreChannelId,
284 body: {
285 destinationProject,
286 description: news.description,
287 labels: desiredLabels,
288 },
289 })
290 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
291 if (created !== undefined) {
292 yield* waitForOperation(created);
293 }
294 current = yield* waitUntilExists(getByName(name), name);
295 }
296
297 if (current === undefined) {
298 return yield* new ResourceNotResolved({ name });
299 }
300
301 const observedLabels = tagRecord(current.labels);
302 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
303 const mask = fieldMask([
304 (upsert.length > 0 || removed.length > 0) && "labels",
305 !sameText(current.description, news.description) && "description",
306 ]);
307
308 if (mask.length > 0) {
309 const operation =
310 yield* gkebackup.patchProjectsLocationsRestoreChannels({
311 name: current.name ?? name,
312 updateMask: mask,
313 body: {
314 etag: current.etag,
315 labels: desiredLabels,
316 description: news.description,
317 },
318 });
319 yield* waitForOperation(operation);
320 current = yield* waitUntilExists(
321 getByName(current.name ?? name),
322 current.name ?? name,
323 );
324 }
325
326 return toAttrs(current, env.project, env.region);
327 }),
328
329 delete: Effect.fn(function* ({ output }) {
330 const operation = yield* gkebackup
331 .deleteProjectsLocationsRestoreChannels({ name: output.name })
332 .pipe(
333 Effect.retry({
334 while: (error) => error._tag === "Conflict",
335 times: 8,
336 schedule: Schedule.spaced("2 seconds"),
337 }),
338 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
339 );
340 if (operation !== undefined) {
341 yield* waitForDeleteOperation(operation);
342 }
343 yield* waitUntilGone(getByName(output.name), output.name);
344 }),
345 });