1import { GcpEnvironment } from "@/GCP/Environment";
2import { Action } from "@/Action";
3import * as GCP from "@/GCP";
4import * as Test from "@/Test/Alchemy";
5import * as alloydb from "@distilled.cloud/gcp/alloydb_v1";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: GCP.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18// AlloyDB clusters and instances take well over 5 minutes to provision.
19const runLifecycle = !!process.env.GCP_TEST_SLOW && !process.env.FAST;
20
21const waitUntilGone = (name: string) =>
22 alloydb.getProjectsLocationsBackups({ name }).pipe(
23 Effect.as("found" as const),
24 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
25 Effect.repeat({
26 schedule: Schedule.spaced("2 seconds"),
27 until: (status) => status === "gone",
28 times: 10,
29 }),
30 );
31
32test.provider(
33 "getProjectsLocationsBackups on a missing backup fails with a typed tag",
34 (stack) =>
35 Effect.gen(function* () {
36 const { project } = yield* GcpEnvironment.current;
37
38 yield* stack.destroy();
39
40 const error = yield* Effect.flip(
41 alloydb.getProjectsLocationsBackups({
42 name: `projects/${project}/locations/us-central1/backups/alchemy-alloydb-missing`,
43 }),
44 );
45 expect(error._tag).toEqual("NotFound");
46
47 const page = yield* alloydb.listProjectsLocationsBackups({
48 parent: `projects/${project}/locations/-`,
49 pageSize: 10,
50 });
51 expect((page.backups ?? []).map((item) => item.name)).not.toContain(
52 `projects/${project}/locations/us-central1/backups/alchemy-alloydb-missing`,
53 );
54
55 yield* stack.destroy();
56 }).pipe(logLevel),
57 { tags: ["provider:gcp", "provider:gcp:alloydb", "live"], timeout: 90_000 },
58);
59
60test.provider.skipIf(!runLifecycle)(
61 "create, update, and delete an alloydb backup",
62 (stack) =>
63 Effect.gen(function* () {
64 yield* stack.destroy();
65
66 const created = yield* stack.deploy(
67 Effect.gen(function* () {
68 const cluster = yield* GCP.AlloyDB.Cluster("AppDb", {
69 location: "us-central1",
70 pscConfig: { pscEnabled: true },
71 displayName: "alchemy-test-backup-cluster",
72 labels: { env: "test" },
73 initialUser: { user: "postgres", password: "AlchemyTest1" },
74 automatedBackupPolicy: { enabled: false },
75 continuousBackupConfig: { enabled: false },
76 });
77 // AlloyDB only backs up clusters with a primary instance.
78 const instance = yield* GCP.AlloyDB.Instance("Primary", {
79 cluster: cluster.name,
80 instanceType: "PRIMARY",
81 machineConfig: { cpuCount: 2 },
82 });
83 const backup = yield* GCP.AlloyDB.Backup("OnDemand", {
84 clusterName: instance.clusterName,
85 displayName: "alchemy-test-backup",
86 description: "test snapshot",
87 labels: { env: "test" },
88 });
89 const Probe = Action(
90 "Probe",
91 Effect.gen(function* () {
92 const getBackup = yield* GCP.AlloyDB.GetBackup(backup);
93 return Effect.fn(function* () {
94 return yield* getBackup();
95 });
96 }),
97 );
98 return { cluster, backup, probe: yield* Probe({}) };
99 }),
100 );
101
102 expect(created.backup.name).toContain("/backups/");
103 expect(created.backup.clusterId).toEqual(created.cluster.clusterId);
104 expect(created.backup.location).toEqual("us-central1");
105 expect(created.backup.displayName).toEqual("alchemy-test-backup");
106 expect(created.backup.description).toEqual("test snapshot");
107 expect(created.backup.labels).toMatchObject({ env: "test" });
108 expect(created.backup.type).toEqual("ON_DEMAND");
109 expect(created.backup.state).toEqual("READY");
110 expect(created.probe.name).toEqual(created.backup.name);
111
112 const fetched = yield* alloydb.getProjectsLocationsBackups({
113 name: created.backup.name,
114 });
115 expect(fetched.name).toEqual(created.backup.name);
116 // GCP omits displayName from backup reads; the attributes carry it.
117 expect(fetched.labels?.env).toEqual("test");
118 expect(fetched.type).toEqual("ON_DEMAND");
119
120 const updated = yield* stack.deploy(
121 Effect.gen(function* () {
122 const cluster = yield* GCP.AlloyDB.Cluster("AppDb", {
123 clusterId: created.cluster.clusterId,
124 location: "us-central1",
125 pscConfig: { pscEnabled: true },
126 displayName: "alchemy-test-backup-cluster",
127 labels: { env: "test" },
128 automatedBackupPolicy: { enabled: false },
129 continuousBackupConfig: { enabled: false },
130 });
131 const instance = yield* GCP.AlloyDB.Instance("Primary", {
132 cluster: cluster.name,
133 instanceType: "PRIMARY",
134 machineConfig: { cpuCount: 2 },
135 });
136 const backup = yield* GCP.AlloyDB.Backup("OnDemand", {
137 clusterName: instance.clusterName,
138 backupId: created.backup.backupId,
139 displayName: "alchemy-prod-backup",
140 // Description is create-only (a change replaces the backup), so
141 // this step only changes fields that update in place.
142 description: "test snapshot",
143 labels: { env: "prod", role: "backup" },
144 });
145 return { cluster, backup };
146 }),
147 );
148
149 expect(updated.backup.name).toEqual(created.backup.name);
150 expect(updated.backup.displayName).toEqual("alchemy-prod-backup");
151 expect(updated.backup.description).toEqual("test snapshot");
152 expect(updated.backup.labels).toMatchObject({
153 env: "prod",
154 role: "backup",
155 });
156
157 const refetched = yield* alloydb.getProjectsLocationsBackups({
158 name: created.backup.name,
159 });
160 // GCP omits displayName from backup reads; the attributes carry it.
161 expect(refetched.description).toEqual("test snapshot");
162 expect(refetched.labels?.env).toEqual("prod");
163 expect(refetched.labels?.role).toEqual("backup");
164
165 yield* stack.destroy();
166
167 const gone = yield* waitUntilGone(created.backup.name);
168 expect(gone).toEqual("gone");
169 }).pipe(logLevel),
170 {
171 tags: ["provider:gcp", "provider:gcp:alloydb", "live"],
172 timeout: 3_600_000,
173 retry: 0,
174 },
175);