1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8import { GcpEnvironment } from "@/GCP/Environment";
9
10const { test } = Test.make({ providers: GCP.providers() });
11
12const logLevel = Effect.provideService(
13 MinimumLogLevel,
14 process.env.DEBUG ? "Debug" : "Info",
15);
16// Live create returns Forbidden: "The caller does not have permission"
17// (resourcemanager.projects.create on the parent organization/folder).
18// Set GOOGLE_ORGANIZATION_ID when the credentials can create projects there.
19const runLifecycle = !!process.env.GOOGLE_ORGANIZATION_ID;
20
21const waitUntilGone = (name: string) =>
22 resourcemanager.getProjects({ name }).pipe(
23 Effect.map((resource) =>
24 resource.state === "DELETE_REQUESTED"
25 ? ("gone" as const)
26 : ("found" as const),
27 ),
28 Effect.catchTag(["NotFound", "ProjectNotFound"], () =>
29 Effect.succeed("gone" as const),
30 ),
31 Effect.repeat({
32 schedule: Schedule.spaced("1 second"),
33 until: (status) => status === "gone",
34 times: 10,
35 }),
36 );
37
38const resolveParent = Effect.gen(function* () {
39 const { project } = yield* GcpEnvironment.current;
40 const resource = yield* resourcemanager.getProjects({
41 name: `projects/${project}`,
42 });
43 return resource.parent;
44});
45
46test.provider(
47 "getProjects on a missing project fails with a typed tag",
48 (stack) =>
49 Effect.gen(function* () {
50 yield* stack.destroy();
51
52 const error = yield* Effect.flip(
53 resourcemanager.getProjects({
54 name: "projects/alchemy-missing-proj",
55 }),
56 );
57 expect(error._tag).toEqual("ProjectNotFound");
58
59 yield* stack.destroy();
60 }).pipe(logLevel),
61 {
62 tags: ["provider:gcp", "provider:gcp:resourcemanager", "live"],
63 timeout: 90_000,
64 },
65);
66
67test.provider.skipIf(runLifecycle)(
68 "createProjects without project-creator IAM fails with Forbidden",
69 (stack) =>
70 Effect.gen(function* () {
71 yield* stack.destroy();
72
73 const parent = yield* resolveParent.pipe(
74 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
75 );
76 const error = yield* Effect.flip(
77 resourcemanager.createProjects({
78 body: {
79 projectId: "alchemy-rm-probe-xxxx",
80 parent: parent ?? "organizations/0",
81 displayName: "Alchemy Probe",
82 },
83 }),
84 );
85 expect(error._tag).toEqual("Forbidden");
86
87 yield* stack.destroy();
88 }).pipe(logLevel),
89 {
90 tags: ["provider:gcp", "provider:gcp:resourcemanager", "live"],
91 timeout: 90_000,
92 },
93);
94
95test.provider.skipIf(!runLifecycle)(
96 "create, update, and delete a project",
97 (stack) =>
98 Effect.gen(function* () {
99 yield* stack.destroy();
100
101 const created = yield* stack.deploy(
102 Effect.gen(function* () {
103 return yield* GCP.ResourceManager.Project("Sandbox", {
104 displayName: "Sandbox",
105 labels: { env: "test" },
106 });
107 }),
108 );
109
110 expect(created.name).toMatch(/^projects\//);
111 expect(created.projectId).toEqual(expect.any(String));
112 expect(created.projectId.length).toBeGreaterThanOrEqual(6);
113 expect(created.displayName).toEqual("Sandbox");
114 expect(created.labels).toMatchObject({ env: "test" });
115 expect(created.state).toEqual("ACTIVE");
116
117 const fetched = yield* resourcemanager.getProjects({
118 name: created.name,
119 });
120 expect(fetched.name).toEqual(created.name);
121 expect(fetched.projectId).toEqual(created.projectId);
122 expect(fetched.labels?.env).toEqual("test");
123 expect(fetched.labels?.["alchemy-id"]).toEqual(expect.any(String));
124
125 const updated = yield* stack.deploy(
126 Effect.gen(function* () {
127 return yield* GCP.ResourceManager.Project("Sandbox", {
128 projectId: created.projectId,
129 parent: created.parent,
130 displayName: "Sandbox prod",
131 labels: { env: "prod" },
132 });
133 }),
134 );
135
136 expect(updated.name).toEqual(created.name);
137 expect(updated.projectId).toEqual(created.projectId);
138 expect(updated.displayName).toEqual("Sandbox prod");
139 expect(updated.labels).toMatchObject({ env: "prod" });
140 expect(updated.createTime).toEqual(created.createTime);
141
142 const fetchedUpdate = yield* resourcemanager.getProjects({
143 name: updated.name,
144 });
145 expect(fetchedUpdate.displayName).toEqual("Sandbox prod");
146 expect(fetchedUpdate.labels?.env).toEqual("prod");
147
148 yield* stack.destroy();
149
150 const gone = yield* waitUntilGone(updated.name);
151 expect(gone).toEqual("gone");
152 }).pipe(logLevel),
153 {
154 tags: ["provider:gcp", "provider:gcp:resourcemanager", "live"],
155 timeout: 90_000,
156 },
157);