1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
4import * as orgpolicy from "@distilled.cloud/gcp/orgpolicy_v2";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import { MinimumLogLevel } from "effect/References";
8import * as Schedule from "effect/Schedule";
9import { GcpEnvironment } from "@/GCP/Environment";
10
11const { test } = Test.make({ providers: GCP.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18const waitUntilGone = (name: string) =>
19 orgpolicy.getOrganizationsCustomConstraints({ name }).pipe(
20 Effect.as("found" as const),
21 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
22 Effect.repeat({
23 schedule: Schedule.spaced("1 second"),
24 until: (status) => status === "gone",
25 times: 10,
26 }),
27 );
28
29const organizationOf = () =>
30 Effect.gen(function* () {
31 const { project } = yield* GcpEnvironment.current;
32 const fromEnv = process.env.GOOGLE_ORGANIZATION_ID;
33 if (fromEnv && fromEnv.length > 0) {
34 return fromEnv.startsWith("organizations/")
35 ? fromEnv
36 : `organizations/${fromEnv}`;
37 }
38 let current: string | undefined = `projects/${project}`;
39 for (let i = 0; i < 8; i++) {
40 if (current === undefined) return "";
41 if (current.startsWith("organizations/")) return current;
42 current = current.startsWith("projects/")
43 ? yield* resourcemanager.getProjects({ name: current }).pipe(
44 Effect.map((resource) => resource.parent),
45 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
46 )
47 : current.startsWith("folders/")
48 ? yield* resourcemanager.getFolders({ name: current }).pipe(
49 Effect.map((folder) => folder.parent),
50 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
51 )
52 : undefined;
53 }
54 return "";
55 });
56
57const probeCreate = (parent: string) =>
58 orgpolicy.createOrganizationsCustomConstraints({
59 parent,
60 body: {
61 name: `${parent}/customConstraints/custom.alchemyMissingProbe`,
62 resourceTypes: ["compute.googleapis.com/Instance"],
63 methodTypes: ["CREATE"],
64 condition: "resource.name.startsWith('alchemy-probe-')",
65 actionType: "DENY",
66 displayName: "alchemy probe",
67 },
68 });
69
70test.provider(
71 "getOrganizationsCustomConstraints on a missing constraint fails with a typed tag",
72 (stack) =>
73 Effect.gen(function* () {
74 yield* stack.destroy();
75
76 const organization = (yield* organizationOf()) || "organizations/0";
77 const error = yield* Effect.flip(
78 orgpolicy.getOrganizationsCustomConstraints({
79 name: `${organization}/customConstraints/custom.alchemyDoesNotExist`,
80 }),
81 );
82 expect(error._tag).toEqual("Forbidden");
83
84 yield* stack.destroy();
85 }).pipe(logLevel),
86 { tags: ["provider:gcp", "provider:gcp:orgpolicy", "live"], timeout: 90_000 },
87);
88
89// Custom constraints need Organization Policy Administrator on the org; the
90// testing credentials get Forbidden (probe). Set GOOGLE_ORGANIZATION_ID when
91// the credentials administer the org to run the lifecycle.
92const runLifecycle = !!process.env.GOOGLE_ORGANIZATION_ID;
93
94test.provider.skipIf(runLifecycle)(
95 "createOrganizationsCustomConstraints without org policy admin fails with a typed tag",
96 (stack) =>
97 Effect.gen(function* () {
98 yield* stack.destroy();
99 const organization = (yield* organizationOf()) || "organizations/0";
100 const error = yield* Effect.flip(probeCreate(organization));
101 expect(error._tag).toEqual("Forbidden");
102 yield* stack.destroy();
103 }).pipe(logLevel),
104 { tags: ["provider:gcp", "provider:gcp:orgpolicy", "live"], timeout: 90_000 },
105);
106
107test.provider.skipIf(!runLifecycle)(
108 "create, update, replace, and delete an organization custom constraint",
109 (stack) =>
110 Effect.gen(function* () {
111 const { project } = yield* GcpEnvironment.current;
112 yield* stack.destroy();
113
114 const organization = yield* organizationOf();
115
116 const created = yield* stack.deploy(
117 Effect.gen(function* () {
118 return yield* GCP.OrgPolicy.CustomConstraint("NoTestVms", {
119 organization,
120 resourceTypes: ["compute.googleapis.com/Instance"],
121 methodTypes: ["CREATE"],
122 condition: "resource.name.startsWith('test-')",
123 actionType: "DENY",
124 displayName: "Deny test VMs",
125 description: "blocks test-prefixed instances",
126 });
127 }),
128 );
129
130 expect(created.constraintId.startsWith("custom.")).toEqual(true);
131 expect(created.organization).toEqual(organization);
132 expect(created.name).toEqual(
133 `${organization}/customConstraints/${created.constraintId}`,
134 );
135 expect(created.project).toEqual(project);
136 expect(created.resourceTypes).toEqual([
137 "compute.googleapis.com/Instance",
138 ]);
139 expect(created.methodTypes).toEqual(["CREATE"]);
140 expect(created.condition).toEqual("resource.name.startsWith('test-')");
141 expect(created.actionType).toEqual("DENY");
142 expect(created.displayName).toEqual("Deny test VMs");
143 expect(created.description).toEqual("blocks test-prefixed instances");
144
145 const fetched = yield* orgpolicy.getOrganizationsCustomConstraints({
146 name: created.name,
147 });
148 expect(fetched.name).toEqual(created.name);
149 expect(fetched.condition).toEqual(created.condition);
150 expect(fetched.actionType).toEqual("DENY");
151 expect(fetched.description ?? "").toContain(
152 "blocks test-prefixed instances",
153 );
154
155 const updated = yield* stack.deploy(
156 Effect.gen(function* () {
157 return yield* GCP.OrgPolicy.CustomConstraint("NoTestVms", {
158 organization,
159 constraintId: created.constraintId,
160 resourceTypes: ["compute.googleapis.com/Instance"],
161 methodTypes: ["CREATE", "UPDATE"],
162 condition: "resource.name.startsWith('tmp-')",
163 actionType: "DENY",
164 displayName: "Deny tmp VMs",
165 description: "blocks tmp-prefixed instances",
166 });
167 }),
168 );
169
170 expect(updated.name).toEqual(created.name);
171 expect(updated.constraintId).toEqual(created.constraintId);
172 expect(updated.methodTypes).toEqual(["CREATE", "UPDATE"]);
173 expect(updated.condition).toEqual("resource.name.startsWith('tmp-')");
174 expect(updated.displayName).toEqual("Deny tmp VMs");
175 expect(updated.description).toEqual("blocks tmp-prefixed instances");
176
177 const fetchedUpdate = yield* orgpolicy.getOrganizationsCustomConstraints({
178 name: updated.name,
179 });
180 expect(fetchedUpdate.condition).toEqual(updated.condition);
181 expect(fetchedUpdate.methodTypes).toEqual(["CREATE", "UPDATE"]);
182
183 const replaced = yield* stack.deploy(
184 Effect.gen(function* () {
185 return yield* GCP.OrgPolicy.CustomConstraint("NoTestVms", {
186 organization,
187 constraintId: `${created.constraintId}.b`,
188 resourceTypes: ["compute.googleapis.com/Instance"],
189 methodTypes: ["CREATE"],
190 condition: "resource.name.startsWith('tmp-')",
191 actionType: "DENY",
192 displayName: "Deny tmp VMs",
193 });
194 }),
195 );
196
197 expect(replaced.constraintId).toEqual(`${created.constraintId}.b`);
198 expect(replaced.name).not.toEqual(created.name);
199 expect(replaced.name).toContain(
200 `/customConstraints/${replaced.constraintId}`,
201 );
202
203 const fetchedReplace = yield* orgpolicy.getOrganizationsCustomConstraints(
204 {
205 name: replaced.name,
206 },
207 );
208 expect(fetchedReplace.name).toEqual(replaced.name);
209
210 const oldGone = yield* waitUntilGone(created.name);
211 expect(oldGone).toEqual("gone");
212
213 yield* stack.destroy();
214
215 const gone = yield* waitUntilGone(replaced.name);
216 expect(gone).toEqual("gone");
217 }).pipe(logLevel),
218 { tags: ["provider:gcp", "provider:gcp:orgpolicy", "live"], timeout: 90_000 },
219);