1import * as GCP from "@/GCP";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import * as FetchHttpClient from "effect/http/FetchHttpClient";
7import * as HttpClient from "effect/http/HttpClient";
8
9export const REGION = "us-central1";
10/** Network tag carried by the function; only it may egress on tcp:443. */
11export const EGRESS_TAG = "smoke-egress";
12export const APP_CIDR = "10.40.0.0/24";
13export const DATA_CIDR = "10.40.1.0/24";
14export const PODS_CIDR = "10.44.0.0/16";
15export const SERVICES_CIDR = "10.48.0.0/20";
16
17/** Custom-mode VPC: no auto-created subnetworks, regional dynamic routing. */
18// Fixed names: Cloud Run holds the app subnet (and so the network) for
19// 1-2 hours after the function is deleted, so a later run must re-adopt
20// the held network instead of creating another one — the project allows
21// only 5 VPC networks.
22export const SmokeNetwork = GCP.Compute.Network("SmokeNetwork", {
23 networkName: "alchemy-network-smoke",
24 autoCreateSubnetworks: false,
25 routingMode: "REGIONAL",
26 description: "network smoke",
27});
28
29/** Subnet the function egresses from (Direct VPC egress). */
30export const AppSubnet = Effect.gen(function* () {
31 const network = yield* SmokeNetwork;
32 return yield* GCP.Compute.Subnetwork("AppSubnet", {
33 subnetworkName: "alchemy-network-smoke-app",
34 region: REGION,
35 network: network.networkName,
36 ipCidrRange: APP_CIDR,
37 privateIpGoogleAccess: true,
38 });
39});
40
41/** Second subnet with GKE-style secondary ranges. */
42export const DataSubnet = Effect.gen(function* () {
43 const network = yield* SmokeNetwork;
44 return yield* GCP.Compute.Subnetwork("DataSubnet", {
45 region: REGION,
46 network: network.networkName,
47 ipCidrRange: DATA_CIDR,
48 secondaryIpRanges: [
49 { rangeName: "pods", ipCidrRange: PODS_CIDR },
50 { rangeName: "services", ipCidrRange: SERVICES_CIDR },
51 ],
52 });
53});
54
55/** Results the function writes after each probe. */
56export const Results = GCP.Storage.Bucket("EgressResults", {
57 forceDestroy: true,
58});
59/** Topic pushed to the (internal-only) function. */
60export const Probes = GCP.PubSub.Topic("EgressProbes", {});
61
62/**
63 * Probe outcome, stored as the result object's custom metadata:
64 * `ip` (public IP the echo service saw — the NAT address) or `ipError`,
65 * and `port80Blocked` (`"true"` when plain HTTP egress was dropped).
66 */
67export type ProbeResult = Record<string, string>;
68
69const IP_ECHO = "api.ipify.org";
70
71/**
72 * Private Cloud Function: internal-only ingress, all egress through the
73 * VPC (`AppSubnet`) → firewall → Cloud NAT. Each Pub/Sub message triggers
74 * an outbound probe; the result lands in {@link Results} as
75 * `probe-{messageId}` (outcome in its custom metadata). Deployed from
76 * {@link ../Network.smoke.test.ts}.
77 */
78export default class EgressFunction extends GCP.CloudFunctions.Function<EgressFunction>()(
79 "EgressFunction",
80 Effect.gen(function* () {
81 const network = yield* SmokeNetwork;
82 const subnet = yield* AppSubnet;
83 return {
84 main: import.meta.url,
85 location: REGION,
86 serviceConfig: {
87 timeoutSeconds: 180,
88 maxInstanceCount: 1,
89 ingressSettings: "ALLOW_INTERNAL_ONLY",
90 directVpcEgress: "VPC_EGRESS_ALL_TRAFFIC",
91 directVpcNetworkInterface: [
92 {
93 network: network.networkName,
94 subnetwork: subnet.subnetworkName,
95 tags: [EGRESS_TAG],
96 },
97 ],
98 },
99 };
100 }),
101 Effect.gen(function* () {
102 const bucket = yield* Results;
103 const putObject = yield* GCP.Storage.PutObject(bucket);
104 const http = yield* HttpClient.HttpClient;
105
106 const probe = Effect.gen(function* () {
107 const ip = yield* http.get(`https://${IP_ECHO}?format=json`).pipe(
108 Effect.flatMap((response) => response.json),
109 Effect.map((body): ProbeResult => ({
110 ip: String((body as { ip?: string }).ip),
111 })),
112 Effect.timeout(Duration.seconds(15)),
113 // Cloud NAT programs a fresh Direct VPC egress instance
114 // asynchronously (tens of seconds after cold start).
115 Effect.retry({
116 schedule: Schedule.spaced("5 seconds"),
117 times: 7,
118 }),
119 Effect.catch((error) =>
120 Effect.succeed<ProbeResult>({
121 ipError: `${String(error)} ${String((error as { cause?: unknown }).cause ?? "")}`,
122 }),
123 ),
124 );
125 // Runs after NAT is proven to work, so a failure here is the deny-all
126 // egress rule dropping tcp:80 (the request can only time out).
127 const port80Blocked = yield* http.get(`http://${IP_ECHO}`).pipe(
128 Effect.timeout(Duration.seconds(8)),
129 Effect.as(false),
130 Effect.catch(() => Effect.succeed(true)),
131 );
132 return { ...ip, port80Blocked: String(port80Blocked) } as ProbeResult;
133 });
134
135 yield* GCP.PubSub.consumeTopicMessages(Probes, (messages) =>
136 messages.pipe(
137 Stream.runForEach(({ message }) =>
138 probe.pipe(
139 Effect.flatMap((result) =>
140 putObject({
141 name: `probe-${message.messageId}`,
142 body: "probe",
143 metadata: result,
144 }),
145 ),
146 Effect.orDie,
147 ),
148 ),
149 ),
150 );
151
152 return {};
153 }).pipe(
154 Effect.provide(GCP.Storage.PutObjectHttp),
155 Effect.provide(GCP.CloudFunctions.TopicEventSource),
156 Effect.provide(FetchHttpClient.layer),
157 ),
158) {}