| 1 | import * as GCP from "@/GCP"; |
| 2 | import * as Test from "@/Test/Alchemy"; |
| 3 | import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3"; |
| 4 | import * as orgpolicy from "@distilled.cloud/gcp/orgpolicy_v2"; |
| 5 | import { expect } from "alchemy-test"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import { MinimumLogLevel } from "effect/References"; |
| 8 | import * as Schedule from "effect/Schedule"; |
| 9 | import { GcpEnvironment } from "@/GCP/Environment"; |
| 10 | |
| 11 | const { test } = Test.make({ providers: GCP.providers() }); |
| 12 | |
| 13 | const logLevel = Effect.provideService( |
| 14 | MinimumLogLevel, |
| 15 | process.env.DEBUG ? "Debug" : "Info", |
| 16 | ); |
| 17 | |
| 18 | const 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 | |
| 29 | const 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 | |
| 57 | const 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 | |
| 70 | test.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. |
| 92 | const runLifecycle = !!process.env.GOOGLE_ORGANIZATION_ID; |
| 93 | |
| 94 | test.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 | |
| 107 | test.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 | ); |