1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as compute from "@distilled.cloud/gcp/compute_v1";
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
17const waitUntilGone = (project: string, firewallPolicy: string) =>
18 compute.getNetworkFirewallPolicies({ project, firewallPolicy }).pipe(
19 Effect.as("found" as const),
20 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
21 Effect.repeat({
22 schedule: Schedule.spaced("1 second"),
23 until: (status) => status === "gone",
24 times: 10,
25 }),
26 );
27
28const ruleAt = (
29 rules: compute.FirewallPolicyRule[] | undefined,
30 priority: number,
31) => (rules ?? []).find((rule) => rule.priority === priority);
32
33const hasReservedGotoNext = (rules: compute.FirewallPolicyRule[] | undefined) =>
34 (rules ?? []).some(
35 (rule) =>
36 rule.action === "goto_next" &&
37 rule.priority !== undefined &&
38 rule.priority >= 2147483548 &&
39 rule.priority <= 2147483647,
40 );
41
42const nextName = (name: string) => {
43 const flip = name.endsWith("x") ? "z" : "x";
44 return name.length < 63 ? `${name}${flip}` : `${name.slice(0, -1)}${flip}`;
45};
46
47test.provider(
48 "getNetworkFirewallPolicies on a missing policy fails with NotFound",
49 (stack) =>
50 Effect.gen(function* () {
51 yield* stack.destroy();
52 const { project } = yield* GcpEnvironment.current;
53 const error = yield* Effect.flip(
54 compute.getNetworkFirewallPolicies({
55 project,
56 firewallPolicy: "alchemy-missing-nfw",
57 }),
58 );
59 expect(error._tag).toBe("NotFound");
60 yield* stack.destroy();
61 }).pipe(logLevel),
62 { tags: ["provider:gcp", "provider:gcp:compute", "live"], timeout: 90_000 },
63);
64
65test.provider(
66 "create, update, replace, and delete a network firewall policy",
67 (stack) =>
68 Effect.gen(function* () {
69 yield* stack.destroy();
70
71 const created = yield* stack.deploy(
72 Effect.gen(function* () {
73 return yield* GCP.Compute.NetworkFirewallPolicy("VpcFw", {
74 description: "allow internal http",
75 rules: [
76 {
77 action: "allow",
78 priority: 1000,
79 direction: "INGRESS",
80 match: {
81 srcIpRanges: ["10.0.0.0/8"],
82 layer4Configs: [{ ipProtocol: "tcp", ports: ["80"] }],
83 },
84 },
85 ],
86 });
87 }),
88 );
89
90 expect(created.networkFirewallPolicyName).toEqual(expect.any(String));
91 expect(created.policyType).toEqual("VPC_POLICY");
92 expect(created.description).toEqual("allow internal http");
93 expect(ruleAt(created.rules, 1000)?.action).toEqual("allow");
94 expect(hasReservedGotoNext(created.rules)).toEqual(true);
95
96 const fetched = yield* compute.getNetworkFirewallPolicies({
97 project: created.project,
98 firewallPolicy: created.networkFirewallPolicyName,
99 });
100 expect(fetched.name).toEqual(created.networkFirewallPolicyName);
101 expect(fetched.description).toContain("[alchemy ");
102 expect(fetched.description).toContain("allow internal http");
103 expect(ruleAt(fetched.rules, 1000)?.match?.srcIpRanges).toEqual(
104 expect.arrayContaining(["10.0.0.0/8"]),
105 );
106
107 const listed = yield* compute.listNetworkFirewallPolicies({
108 project: created.project,
109 maxResults: 500,
110 });
111 expect(
112 (listed.items ?? []).some(
113 (policy) => policy.name === created.networkFirewallPolicyName,
114 ),
115 ).toEqual(true);
116
117 const updated = yield* stack.deploy(
118 Effect.gen(function* () {
119 return yield* GCP.Compute.NetworkFirewallPolicy("VpcFw", {
120 networkFirewallPolicyName: created.networkFirewallPolicyName,
121 description: "updated vpc fw",
122 rules: [
123 {
124 action: "allow",
125 priority: 1000,
126 direction: "INGRESS",
127 description: "http and https",
128 match: {
129 srcIpRanges: ["10.0.0.0/8", "192.168.0.0/16"],
130 layer4Configs: [{ ipProtocol: "tcp", ports: ["80", "443"] }],
131 },
132 },
133 ],
134 });
135 }),
136 );
137
138 expect(updated.networkFirewallPolicyName).toEqual(
139 created.networkFirewallPolicyName,
140 );
141 expect(updated.networkFirewallPolicyId).toEqual(
142 created.networkFirewallPolicyId,
143 );
144 expect(updated.description).toEqual("updated vpc fw");
145 expect(
146 ruleAt(updated.rules, 1000)?.match?.srcIpRanges?.slice().sort(),
147 ).toEqual(["10.0.0.0/8", "192.168.0.0/16"]);
148
149 const fetchedUpdate = yield* compute.getNetworkFirewallPolicies({
150 project: updated.project,
151 firewallPolicy: updated.networkFirewallPolicyName,
152 });
153 expect(fetchedUpdate.description).toContain("updated vpc fw");
154 expect(ruleAt(fetchedUpdate.rules, 1000)?.description).toEqual(
155 "http and https",
156 );
157
158 const replaced = yield* stack.deploy(
159 Effect.gen(function* () {
160 return yield* GCP.Compute.NetworkFirewallPolicy("VpcFw", {
161 networkFirewallPolicyName: nextName(
162 created.networkFirewallPolicyName,
163 ),
164 description: "replaced vpc fw",
165 rules: [
166 {
167 action: "deny",
168 priority: 1000,
169 direction: "INGRESS",
170 match: {
171 srcIpRanges: ["0.0.0.0/0"],
172 layer4Configs: [{ ipProtocol: "tcp", ports: ["23"] }],
173 },
174 },
175 ],
176 });
177 }),
178 );
179
180 expect(replaced.networkFirewallPolicyName).toEqual(
181 nextName(created.networkFirewallPolicyName),
182 );
183 expect(replaced.networkFirewallPolicyId).not.toEqual(
184 created.networkFirewallPolicyId,
185 );
186 expect(replaced.description).toEqual("replaced vpc fw");
187 expect(ruleAt(replaced.rules, 1000)?.action).toEqual("deny");
188
189 yield* stack.destroy();
190
191 const gone = yield* waitUntilGone(
192 replaced.project,
193 replaced.networkFirewallPolicyName,
194 );
195 expect(gone).toEqual("gone");
196 }).pipe(logLevel),
197 { tags: ["provider:gcp", "provider:gcp:compute", "live"], timeout: 180_000 },
198);