1import * as appengine from "@distilled.cloud/gcp/appengine_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 CATCH_ALL_PRIORITY,
12 catchMissing,
13 DEFAULT_ACTION,
14 DEFAULT_SOURCE_RANGE,
15 encodeOwnership,
16 findOwnedFirewallRule,
17 hasOwnershipMarker,
18 ignoreMissing,
19 listFirewallRules,
20 MAX_DESCRIPTION_LENGTH,
21 ownedByAlchemy,
22 ownershipLabels,
23 parseOwnership,
24 resolveAppsId,
25 sameText,
26 toPriority,
27 updateMaskOf,
28} from "./internal.ts";
29
30export type AppsFirewallIngressRuleProps = {
31 /**
32 * App Engine application id. Defaults to the current GCP project.
33 * Immutable — changing it replaces the rule.
34 */
35 appsId?: string;
36 /**
37 * Rule priority (lower is evaluated first). If omitted, a unique
38 * priority in `10000-910000` is generated. Immutable — changing it
39 * replaces the rule. The default catch-all (`2147483647`) cannot be
40 * deleted.
41 */
42 priority?: number;
43 /**
44 * Action taken on matching requests.
45 * @default "ALLOW"
46 */
47 action?: appengine.FirewallRuleActionEnum | (string & {});
48 /**
49 * CIDR source range. `"*"` matches all IPv4 and IPv6 addresses.
50 * @default "*"
51 */
52 sourceRange?: string;
53 /**
54 * Human-readable description (max 400 characters). Firewall rules have
55 * no labels field, so Alchemy ownership is stored in a `[alchemy …]`
56 * prefix and stripped from attributes.
57 */
58 description?: string;
59};
60
61export type AppsFirewallIngressRule = Resource<
62 "GCP.AppEngine.AppsFirewallIngressRule",
63 AppsFirewallIngressRuleProps,
64 {
65 /** App Engine application id. */
66 appsId: string;
67 /** Project id. */
68 project: string;
69 /** Rule priority (the `{ingressRulesId}` path segment). */
70 priority: number;
71 /** ALLOW or DENY. */
72 action: string;
73 /** CIDR source range. */
74 sourceRange: string | undefined;
75 /** Description with the Alchemy ownership prefix stripped. */
76 description: string | undefined;
77 },
78 never,
79 Providers
80>;
81
82/**
83 * An App Engine ingress firewall rule.
84 *
85 * Firewall rules have no labels field, so Alchemy stamps ownership into
86 * `description` for `list` / nuke. Application id and priority are
87 * identity — changing either replaces the rule. Action, source range,
88 * and description update in place.
89 *
90 * ### Creating a Rule
91 * **Example:** Deny a CIDR range
92 * ```typescript
93 * const rule = yield* GCP.AppEngine.AppsFirewallIngressRule("BlockOffice", {
94 * action: "DENY",
95 * sourceRange: "203.0.113.0/24",
96 * description: "office network",
97 * });
98 * ```
99 *
100 * **Example:** Explicit priority
101 * ```typescript
102 * const rule = yield* GCP.AppEngine.AppsFirewallIngressRule("AllowHealth", {
103 * priority: 12000,
104 * action: "ALLOW",
105 * sourceRange: "35.191.0.0/16",
106 * });
107 * ```
108 *
109 * ### Updating a Rule
110 * **Example:** Flip the action
111 * ```typescript
112 * const rule = yield* GCP.AppEngine.AppsFirewallIngressRule("BlockOffice", {
113 * action: "ALLOW",
114 * sourceRange: "203.0.113.0/24",
115 * });
116 * ```
117 *
118 * @resource
119 * @category AppEngine
120 */
121export const AppsFirewallIngressRule = Resource<AppsFirewallIngressRule>(
122 "GCP.AppEngine.AppsFirewallIngressRule",
123);
124
125export class AppsFirewallIngressRuleNotResolved extends Data.TaggedError(
126 "GCP.AppEngine.AppsFirewallIngressRuleNotResolved",
127)<{
128 appsId: string;
129 priority: number;
130}> {}
131
132const toAttrs = (
133 rule: appengine.FirewallRule,
134 appsId: string,
135 project: string,
136) => ({
137 appsId,
138 project,
139 priority: rule.priority ?? 0,
140 action: String(rule.action ?? DEFAULT_ACTION),
141 sourceRange: rule.sourceRange,
142 description: parseOwnership(rule.description).text,
143});
144
145const getByPriority = (appsId: string, priority: number) =>
146 catchMissing(
147 appengine.getAppsFirewallIngressRules({
148 appsId,
149 ingressRulesId: String(priority),
150 }),
151 );
152
153export const AppsFirewallIngressRuleProvider = () =>
154 Provider.succeed(AppsFirewallIngressRule, {
155 stables: ["appsId", "project", "priority"],
156
157 diff: Effect.fn(function* ({ news, olds, output }) {
158 if (!isResolved(news)) return undefined;
159 const previousApp = olds?.appsId ?? output?.appsId;
160 const nextApp = news.appsId ?? previousApp;
161 if (
162 previousApp !== undefined &&
163 nextApp !== undefined &&
164 nextApp !== previousApp
165 ) {
166 return { action: "replace" as const, deleteFirst: false };
167 }
168 const previousPriority = olds?.priority ?? output?.priority;
169 if (
170 previousPriority !== undefined &&
171 news.priority !== undefined &&
172 news.priority !== previousPriority
173 ) {
174 return { action: "replace" as const, deleteFirst: true };
175 }
176 return undefined;
177 }),
178
179 read: Effect.fn(function* ({ id, olds, output }) {
180 const env = yield* GcpEnvironment.current;
181 const appsId = yield* resolveAppsId(olds?.appsId, output?.appsId);
182 const priority = olds?.priority ?? output?.priority;
183 let existing =
184 priority === undefined
185 ? undefined
186 : yield* getByPriority(appsId, priority);
187 if (existing === undefined) {
188 existing = yield* findOwnedFirewallRule(
189 id,
190 yield* listFirewallRules(appsId),
191 );
192 }
193 if (existing === undefined) return undefined;
194 const attrs = toAttrs(existing, appsId, env.project);
195 return (yield* ownedByAlchemy(id, existing.description))
196 ? attrs
197 : Unowned(attrs);
198 }),
199
200 list: () =>
201 Effect.gen(function* () {
202 const env = yield* GcpEnvironment.current;
203 const rules = yield* listFirewallRules(env.project);
204 return rules
205 .filter((rule) => hasOwnershipMarker(rule.description))
206 .map((rule) => toAttrs(rule, env.project, env.project));
207 }),
208
209 reconcile: Effect.fn(function* ({ id, news, output }) {
210 const env = yield* GcpEnvironment.current;
211 const appsId = yield* resolveAppsId(news.appsId, output?.appsId);
212 const ownership = yield* ownershipLabels(id);
213 const description = encodeOwnership(
214 ownership,
215 news.description,
216 MAX_DESCRIPTION_LENGTH,
217 );
218 const priority = yield* toPriority(id, news.priority, output?.priority);
219 const action = news.action ?? DEFAULT_ACTION;
220 const sourceRange = news.sourceRange ?? DEFAULT_SOURCE_RANGE;
221
222 let current = yield* getByPriority(appsId, priority);
223 if (current === undefined) {
224 current = yield* findOwnedFirewallRule(
225 id,
226 yield* listFirewallRules(appsId),
227 );
228 }
229
230 if (current === undefined) {
231 const created = yield* appengine
232 .createAppsFirewallIngressRules({
233 appsId,
234 body: {
235 priority,
236 action,
237 sourceRange,
238 description,
239 },
240 })
241 .pipe(
242 Effect.catchTag("Conflict", () => getByPriority(appsId, priority)),
243 );
244 current = created ?? undefined;
245 }
246
247 if (current === undefined) {
248 return yield* new AppsFirewallIngressRuleNotResolved({
249 appsId,
250 priority,
251 });
252 }
253
254 const observedPriority = current.priority ?? priority;
255 const actionChanged = !sameText(String(current.action ?? ""), action);
256 const rangeChanged = !sameText(current.sourceRange, sourceRange);
257 const descriptionChanged = !sameText(current.description, description);
258 const updateMask = updateMaskOf(
259 actionChanged ? "action" : undefined,
260 rangeChanged ? "sourceRange" : undefined,
261 descriptionChanged ? "description" : undefined,
262 );
263 if (updateMask.length > 0) {
264 current = yield* appengine.patchAppsFirewallIngressRules({
265 appsId,
266 ingressRulesId: String(observedPriority),
267 updateMask,
268 body: {
269 priority: observedPriority,
270 action,
271 sourceRange,
272 description,
273 },
274 });
275 }
276
277 return toAttrs(current, appsId, env.project);
278 }),
279
280 delete: Effect.fn(function* ({ output }) {
281 if (output.priority === CATCH_ALL_PRIORITY) return;
282 yield* ignoreMissing(
283 appengine.deleteAppsFirewallIngressRules({
284 appsId: output.appsId,
285 ingressRulesId: String(output.priority),
286 }),
287 );
288 }),
289 });