1import { ConfigError } from "@distilled.cloud/core/errors";
2import {
3 Credentials,
4 type Config as CredentialsConfig,
5} from "@distilled.cloud/gcp/Credentials";
6import * as Clock from "effect/Clock";
7import * as Config from "effect/Config";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Redacted from "effect/Redacted";
12import * as Ref from "effect/Ref";
13import * as Semaphore from "effect/Semaphore";
14import * as HttpClient from "effect/http/HttpClient";
15import * as HttpClientRequest from "effect/http/HttpClientRequest";
16
17/**
18 * Credentials sourced from the GCE / Cloud Run metadata server — the
19 * runtime identity of a deployed Cloud Run Service, Job, or WorkerPool.
20 *
21 * Kept dependency-light: the generated container bootstraps import this
22 * module, so it must not pull in the deploy-time AuthProvider machinery.
23 */
24
25/** A resolved token plus the wall-clock time it stops being valid. */
26export interface ExpiringCredentials {
27 readonly config: CredentialsConfig;
28 /** Epoch millis; `undefined` never expires (e.g. a static env token). */
29 readonly expiresAt: number | undefined;
30}
31
32/** Refresh this long before the reported expiry. */
33const REFRESH_WINDOW_MS = 5 * 60 * 1000;
34
35/**
36 * Single-flight, expiry-aware cache over a credential resolver: callers
37 * reuse the cached token until it nears expiry, and N concurrent callers
38 * observing a stale token trigger exactly one refresh.
39 */
40export const cacheCredentials = <E, R>(
41 resolve: Effect.Effect<ExpiringCredentials, E, R>,
42) =>
43 Effect.gen(function* () {
44 const cache = yield* Ref.make<ExpiringCredentials | undefined>(undefined);
45 const lock = yield* Semaphore.make(1);
46 const fresh = (current: ExpiringCredentials | undefined, now: number) =>
47 current !== undefined &&
48 (current.expiresAt === undefined ||
49 current.expiresAt - REFRESH_WINDOW_MS > now);
50 return Effect.gen(function* () {
51 const current = yield* Ref.get(cache);
52 if (fresh(current, yield* Clock.currentTimeMillis))
53 return current!.config;
54 return yield* lock.withPermits(1)(
55 Effect.gen(function* () {
56 const latest = yield* Ref.get(cache);
57 if (fresh(latest, yield* Clock.currentTimeMillis)) {
58 return latest!.config;
59 }
60 const resolved = yield* resolve;
61 yield* Ref.set(cache, resolved);
62 return resolved.config;
63 }),
64 );
65 });
66 });
67
68const METADATA_ROOT = "http://metadata.google.internal/computeMetadata/v1";
69
70const metadataGet = (http: HttpClient.HttpClient, path: string) =>
71 http
72 .execute(
73 HttpClientRequest.get(`${METADATA_ROOT}${path}`).pipe(
74 HttpClientRequest.setHeader("Metadata-Flavor", "Google"),
75 ),
76 )
77 .pipe(
78 Effect.filterOrFail(
79 (response) => response.status === 200,
80 (response) =>
81 new ConfigError({
82 message: `GCE metadata ${path} returned HTTP ${response.status}`,
83 }),
84 ),
85 Effect.mapError((cause) =>
86 cause instanceof ConfigError
87 ? cause
88 : new ConfigError({
89 message: `GCE metadata ${path} is unreachable: ${String(cause)}`,
90 }),
91 ),
92 );
93
94/** Mint an access token for the instance's attached service account. */
95export const fetchMetadataToken = (http: HttpClient.HttpClient) =>
96 Effect.gen(function* () {
97 const response = yield* metadataGet(
98 http,
99 "/instance/service-accounts/default/token",
100 );
101 const body = yield* response.json.pipe(
102 Effect.mapError(
103 () =>
104 new ConfigError({
105 message: "GCE metadata token endpoint returned invalid JSON",
106 }),
107 ),
108 );
109 const record =
110 typeof body === "object" && body !== null
111 ? (body as { access_token?: unknown; expires_in?: unknown })
112 : {};
113 if (typeof record.access_token !== "string") {
114 return yield* new ConfigError({
115 message: "GCE metadata token endpoint returned no access_token",
116 });
117 }
118 const expiresIn =
119 typeof record.expires_in === "number" ? record.expires_in : 300;
120 const now = yield* Clock.currentTimeMillis;
121 return {
122 accessToken: Redacted.make(record.access_token),
123 expiresAt: now + expiresIn * 1000,
124 };
125 });
126
127/** Project id of the instance, from the metadata server. */
128export const fetchMetadataProject = (http: HttpClient.HttpClient) =>
129 metadataGet(http, "/project/project-id").pipe(
130 Effect.flatMap((response) => response.text),
131 Effect.map((text) => text.trim()),
132 Effect.mapError(
133 () =>
134 new ConfigError({
135 message: "GCE metadata project-id endpoint returned no project",
136 }),
137 ),
138 );
139
140/**
141 * Region of the instance (`projects/{n}/regions/{region}` on Cloud Run and
142 * Cloud Functions), from the metadata server.
143 */
144export const fetchMetadataRegion = (http: HttpClient.HttpClient) =>
145 metadataGet(http, "/instance/region").pipe(
146 Effect.flatMap((response) => response.text),
147 Effect.map((text) => text.trim().split("/").pop() || undefined),
148 Effect.orElseSucceed(() => undefined),
149 );
150
151/**
152 * `Credentials` for code running on Cloud Run (or any GCE-backed runtime):
153 * the attached service account's token from the metadata server, cached
154 * until shortly before expiry. The project comes from
155 * `GOOGLE_CLOUD_PROJECT` when set, else from the metadata server once.
156 */
157export const fromMetadataServer = (): Layer.Layer<
158 Credentials,
159 never,
160 HttpClient.HttpClient
161> =>
162 Layer.effect(
163 Credentials,
164 Effect.gen(function* () {
165 const http = yield* HttpClient.HttpClient;
166 const projectCache = yield* Ref.make<string | undefined>(undefined);
167 const project = Effect.gen(function* () {
168 const cached = yield* Ref.get(projectCache);
169 if (cached !== undefined) return cached;
170 const fromEnv = yield* Config.option(
171 Config.String("GOOGLE_CLOUD_PROJECT"),
172 );
173 const resolved = Option.isSome(fromEnv)
174 ? fromEnv.value
175 : yield* fetchMetadataProject(http);
176 yield* Ref.set(projectCache, resolved);
177 return resolved;
178 });
179 // The runtime's default region is the region it runs in.
180 const regionCache = yield* Ref.make<string | undefined>(undefined);
181 const region = Effect.gen(function* () {
182 const cached = yield* Ref.get(regionCache);
183 if (cached !== undefined) return cached;
184 const resolved = yield* fetchMetadataRegion(http);
185 if (resolved !== undefined) yield* Ref.set(regionCache, resolved);
186 return resolved;
187 });
188 const cached = yield* cacheCredentials(
189 Effect.gen(function* () {
190 const token = yield* fetchMetadataToken(http);
191 return {
192 config: {
193 accessToken: token.accessToken,
194 project: yield* project,
195 region: yield* region,
196 },
197 expiresAt: token.expiresAt,
198 };
199 }),
200 );
201 // Distilled `Credentials` is `Effect<Config>` (error `never`): a
202 // runtime without a metadata server cannot call GCP at all.
203 return cached.pipe(Effect.orDie);
204 }),
205 );