1import * as Clock from "effect/Clock";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Ref from "effect/Ref";
5import * as Semaphore from "effect/Semaphore";
6import * as HttpClient from "effect/http/HttpClient";
7import * as HttpClientRequest from "effect/http/HttpClientRequest";
8import type { HttpMethod } from "effect/http/HttpMethod";
9import { bindGcpHost } from "../Host.ts";
10import { grantFor } from "../HttpBinding.ts";
11import {
12 InvokeService,
13 InvokeServiceError,
14 type InvokeServiceRequestInit,
15 type InvokeServiceResponse,
16} from "./InvokeService.ts";
17import type { Service } from "./Service.ts";
18
19const IDENTITY_ENDPOINT =
20 "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity";
21
22/** Refresh this long before the token's `exp`. */
23const REFRESH_WINDOW_MS = 5 * 60 * 1000;
24
25/** Google ID tokens live one hour; used when `exp` cannot be decoded. */
26const DEFAULT_LIFETIME_MS = 55 * 60 * 1000;
27
28interface CachedToken {
29 readonly token: string;
30 readonly expiresAt: number;
31}
32
33/** Epoch millis of a JWT's `exp` claim, if present. */
34const jwtExpiry = (token: string): number | undefined => {
35 try {
36 const payload = token.split(".")[1];
37 if (payload === undefined) return undefined;
38 const json = JSON.parse(
39 atob(payload.replace(/-/g, "+").replace(/_/g, "/")),
40 ) as { exp?: unknown };
41 return typeof json.exp === "number" ? json.exp * 1000 : undefined;
42 } catch {
43 return undefined;
44 }
45};
46
47/** Mint a Google-signed ID token for `audience` from the metadata server. */
48const fetchIdToken = (http: HttpClient.HttpClient, audience: string) =>
49 Effect.gen(function* () {
50 const response = yield* http.execute(
51 HttpClientRequest.get(IDENTITY_ENDPOINT).pipe(
52 HttpClientRequest.setUrlParams({ audience, format: "full" }),
53 HttpClientRequest.setHeader("Metadata-Flavor", "Google"),
54 ),
55 );
56 const body = yield* response.text;
57 if (response.status !== 200) {
58 return yield* new InvokeServiceError({
59 message: `GCE metadata identity endpoint returned HTTP ${response.status}: ${body}`,
60 });
61 }
62 const token = body.trim();
63 const now = yield* Clock.currentTimeMillis;
64 return {
65 token,
66 expiresAt: jwtExpiry(token) ?? now + DEFAULT_LIFETIME_MS,
67 } satisfies CachedToken;
68 }).pipe(
69 Effect.mapError((cause) =>
70 cause instanceof InvokeServiceError
71 ? cause
72 : new InvokeServiceError({
73 message: `GCE metadata identity endpoint is unreachable: ${String(cause)}`,
74 cause,
75 }),
76 ),
77 );
78
79/** Single-flight, expiry-aware ID token cache for one audience. */
80const makeIdTokenCache = (http: HttpClient.HttpClient) =>
81 Effect.gen(function* () {
82 const cache = yield* Ref.make<CachedToken | undefined>(undefined);
83 const lock = yield* Semaphore.make(1);
84 const fresh = (current: CachedToken | undefined, now: number) =>
85 current !== undefined && current.expiresAt - REFRESH_WINDOW_MS > now;
86 return (audience: string) =>
87 Effect.gen(function* () {
88 const current = yield* Ref.get(cache);
89 if (fresh(current, yield* Clock.currentTimeMillis)) {
90 return current!.token;
91 }
92 return yield* lock.withPermits(1)(
93 Effect.gen(function* () {
94 const latest = yield* Ref.get(cache);
95 if (fresh(latest, yield* Clock.currentTimeMillis)) {
96 return latest!.token;
97 }
98 const minted = yield* fetchIdToken(http, audience);
99 yield* Ref.set(cache, minted);
100 return minted.token;
101 }),
102 );
103 });
104 });
105
106/**
107 * HTTP implementation of {@link InvokeService}: ID tokens from the Cloud
108 * Run metadata server, requests over the ambient `HttpClient`.
109 *
110 * @layer
111 * @provides GCP.Run.InvokeService
112 */
113export const InvokeServiceHttp: Layer.Layer<
114 InvokeService,
115 never,
116 HttpClient.HttpClient
117> = Layer.effect(
118 InvokeService,
119 Effect.gen(function* () {
120 const http = yield* HttpClient.HttpClient;
121 return Effect.fn(function* <T extends Service>(service: T) {
122 yield* bindGcpHost({
123 tag: "GCP.Run.InvokeService",
124 resource: service,
125 iam: [
126 grantFor(
127 { role: "roles/run.invoker", on: "run.service" },
128 service.name,
129 ),
130 ],
131 });
132 const uri = yield* service.uri;
133 const idToken = yield* makeIdTokenCache(http);
134
135 const fetch = Effect.fn(`GCP.Run.InvokeService(${service.LogicalId})`)(
136 function* (path: string, init?: InvokeServiceRequestInit) {
137 const base = yield* uri;
138 if (base === undefined) {
139 return yield* new InvokeServiceError({
140 message: `Cloud Run service ${service.LogicalId} has no URI`,
141 });
142 }
143 const audience = base.replace(/\/+$/, "");
144 const token = yield* idToken(audience);
145 const url = `${audience}/${path.replace(/^\/+/, "")}`;
146 let request = HttpClientRequest.make(
147 (init?.method?.toUpperCase() ?? "GET") as HttpMethod,
148 )(url).pipe(
149 HttpClientRequest.setHeaders(init?.headers ?? {}),
150 HttpClientRequest.bearerToken(token),
151 );
152 if (init?.body !== undefined) {
153 const contentType =
154 init.headers?.["content-type"] ?? init.headers?.["Content-Type"];
155 request =
156 typeof init.body === "string"
157 ? HttpClientRequest.bodyText(request, init.body, contentType)
158 : HttpClientRequest.bodyUint8Array(
159 request,
160 init.body,
161 contentType,
162 );
163 }
164 const response = yield* http.execute(request).pipe(
165 Effect.mapError(
166 (cause) =>
167 new InvokeServiceError({
168 message: `Request to ${url} failed: ${cause.message}`,
169 cause,
170 }),
171 ),
172 );
173 const text = yield* response.text.pipe(
174 Effect.mapError(
175 (cause) =>
176 new InvokeServiceError({
177 message: `Reading the response from ${url} failed: ${cause.message}`,
178 cause,
179 }),
180 ),
181 );
182 return {
183 status: response.status,
184 headers: { ...response.headers },
185 text: Effect.succeed(text),
186 json: Effect.try({
187 try: () => JSON.parse(text) as unknown,
188 catch: (cause) =>
189 new InvokeServiceError({
190 message: `Response from ${url} is not JSON`,
191 cause,
192 }),
193 }),
194 } satisfies InvokeServiceResponse;
195 },
196 );
197 return { fetch };
198 });
199 }),
200);