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 BackupChannelProps = {
37 /**
38 * Backup channel id (the `{backupChannel}` segment of
39 * `projects/{project}/locations/{location}/backupChannels/{backupChannel}`).
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 backupChannelId?: 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 store backups created via this channel.
53 * Full name `projects/{project}` or a project id. Must differ from
54 * the 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 BackupChannel = Resource<
68 "GCP.GKEBackup.BackupChannel",
69 BackupChannelProps,
70 {
71 /** Full resource name. */
72 name: string;
73 /** Backup channel id (last path segment). */
74 backupChannelId: 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 backup channel that authorizes storing backups in a
100 * destination project.
101 *
102 * Changing `backupChannelId`, `location`, or `destinationProject` replaces
103 * the channel. Description and labels update in place.
104 *
105 * ### Creating a Backup Channel
106 * **Example:** Generated name
107 * ```typescript
108 * const channel = yield* GCP.GKEBackup.BackupChannel("CrossProject", {
109 * destinationProject: "projects/my-backup-project",
110 * });
111 * ```
112 *
113 * **Example:** Explicit id, destination, and labels
114 * ```typescript
115 * const channel = yield* GCP.GKEBackup.BackupChannel("CrossProject", {
116 * backupChannelId: "app-backups",
117 * destinationProject: "projects/my-backup-project",
118 * description: "cross-project backups",
119 * labels: { env: "prod" },
120 * });
121 * ```
122 *
123 * ### Updating a Backup Channel
124 * **Example:** Description and labels
125 * ```typescript
126 * const channel = yield* GCP.GKEBackup.BackupChannel("CrossProject", {
127 * destinationProject: "projects/my-backup-project",
128 * description: "cross-project backups v2",
129 * labels: { env: "prod", team: "platform" },
130 * });
131 * ```
132 *
133 * @resource
134 * @category GKEBackup
135 */
136export const BackupChannel = Resource<BackupChannel>(
137 "GCP.GKEBackup.BackupChannel",
138);
139
140const resourceName = (
141 project: string,
142 location: string,
143 backupChannelId: string,
144) =>
145 `projects/${project}/locations/${location}/backupChannels/${backupChannelId}`;
146
147const toAttrs = (
148 item: gkebackup.BackupChannel,
149 project: string,
150 region: string,
151) => {
152 const name = item.name ?? "";
153 const parsed = parseName(name, "backupChannels", region);
154 return {
155 name,
156 backupChannelId: 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 .getProjectsLocationsBackupChannels({ name })
172 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
173
174const listOwned = (project: string) =>
175 listAtLocation(project, (parent) =>
176 listLabeledPages(
177 gkebackup.listProjectsLocationsBackupChannels.pages({
178 parent,
179 pageSize: 1000,
180 }),
181 (page) => page.backupChannels,
182 (item) => item.labels,
183 ),
184 );
185
186export const BackupChannelProvider = () =>
187 Provider.succeed(BackupChannel, {
188 stables: [
189 "name",
190 "backupChannelId",
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?.backupChannelId ?? output?.backupChannelId,
205 nextId:
206 news.backupChannelId ??
207 olds?.backupChannelId ??
208 output?.backupChannelId,
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 backupChannelId = yield* toPhysicalId(
229 id,
230 olds?.backupChannelId,
231 output?.backupChannelId,
232 "backupchannel",
233 );
234 const location = normalizeLocation(
235 olds?.location ?? output?.location,
236 env.region,
237 );
238 const name =
239 output?.name ?? resourceName(env.project, location, backupChannelId);
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 backupChannelId = yield* toPhysicalId(
258 id,
259 news.backupChannelId,
260 output?.backupChannelId,
261 "backupchannel",
262 );
263 const location = normalizeLocation(
264 news.location ?? output?.location,
265 env.region,
266 );
267 const name = resourceName(env.project, location, backupChannelId);
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 .createProjectsLocationsBackupChannels({
282 parent: parentOf(env.project, location),
283 backupChannelId,
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 = yield* gkebackup.patchProjectsLocationsBackupChannels(
310 {
311 name: current.name ?? name,
312 updateMask: mask,
313 body: {
314 etag: current.etag,
315 labels: desiredLabels,
316 description: news.description,
317 },
318 },
319 );
320 yield* waitForOperation(operation);
321 current = yield* waitUntilExists(
322 getByName(current.name ?? name),
323 current.name ?? name,
324 );
325 }
326
327 return toAttrs(current, env.project, env.region);
328 }),
329
330 delete: Effect.fn(function* ({ output }) {
331 const operation = yield* gkebackup
332 .deleteProjectsLocationsBackupChannels({
333 name: output.name,
334 force: true,
335 })
336 .pipe(
337 Effect.retry({
338 while: (error) => error._tag === "Conflict",
339 times: 8,
340 schedule: Schedule.spaced("2 seconds"),
341 }),
342 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
343 );
344 if (operation !== undefined) {
345 yield* waitForDeleteOperation(operation);
346 }
347 yield* waitUntilGone(getByName(output.name), output.name);
348 }),
349 });