1import type { GcpOpContext } from "@distilled.cloud/gcp/Protocol";
2import * as Effect from "effect/Effect";
3import type { Input } from "../Input.ts";
4import * as Output from "../Output.ts";
5import { bindGcpHost, type GcpIamGrant } from "./Host.ts";
6import type { GcpIamResourceKind } from "./IamPolicy.ts";
7
8/**
9 * Distilled ops are `OperationMethod`s: yield them once at Layer
10 * construction so the inner runtime Effect is `Effect<A, E>` and does
11 * not leak `GcpOpContext`. Do not `provideService` Credentials/HttpClient
12 * here — the host Layer already provides them.
13 */
14export type GcpHttpOp<I, A, E> = Effect.Effect<
15 (input: I) => Effect.Effect<A, E>,
16 never,
17 GcpOpContext
18> &
19 ((input: I) => Effect.Effect<A, E, GcpOpContext>);
20
21/**
22 * IAM a binding grants the host's runtime service account on the bound
23 * resource: `role` is the narrowest predefined role covering the
24 * operation. With `on`, the role is granted on the bound resource's own
25 * IAM policy (resource-scoped, like an AWS statement's `Resource: [arn]`);
26 * without it — only for services that have no resource-level IAM — the
27 * role is granted on the project.
28 */
29export interface BindingIam {
30 role: string;
31 on?: Exclude<GcpIamResourceKind, "project">;
32 /**
33 * For services with no per-resource IAM policy but IAM Conditions
34 * support (Firestore, Cloud SQL, Managed Kafka): grant on the project
35 * under a condition matching only the bound resource and its children.
36 */
37 scopeByCondition?: boolean;
38}
39
40const nameExpression = (value: string) =>
41 `resource.name == "${value}" || resource.name.startsWith("${value}/")`;
42
43/** IAM Condition matching one resource (by full name) and its children. */
44export const resourceNameCondition = (name: Input<string>) => ({
45 title: "alchemy-scoped",
46 expression:
47 typeof name === "string"
48 ? nameExpression(name)
49 : Output.map(name as Output.Output<string>, nameExpression),
50});
51
52/** Build the grant for `iam` against a bound resource's full name. */
53export const grantFor = (
54 iam: BindingIam,
55 name: Input<string>,
56): Input<GcpIamGrant> =>
57 iam.on !== undefined
58 ? // The engine resolves the name Output before the host reconciles.
59 { role: iam.role, resource: { kind: iam.on, name } }
60 : iam.scopeByCondition
61 ? { role: iam.role, condition: resourceNameCondition(name) }
62 : { role: iam.role };
63
64/**
65 * Shared HTTP scaffolding for GCP named-resource bindings.
66 *
67 * Yields the distilled operation (no double-provide) and, at deploy
68 * time, grants `iam` on the ambient Cloud Run / Function host the way
69 * AWS bindings attach IAM policy statements.
70 */
71export const makeNamedHttpBinding = <
72 Resource extends { LogicalId: string },
73 I extends { name?: string },
74 A,
75 E,
76>(options: {
77 tag: string;
78 operation: GcpHttpOp<I, A, E>;
79 iam: BindingIam;
80 resourceName: (resource: Resource) => Output.Output<string, never>;
81}) =>
82 Effect.gen(function* () {
83 const run = yield* options.operation;
84 return Effect.fn(function* (resource: Resource) {
85 const resourceName = options.resourceName(resource);
86 yield* bindGcpHost({
87 tag: options.tag,
88 resource,
89 iam: [grantFor(options.iam, resourceName)],
90 });
91 const name = yield* resourceName;
92 return Effect.fn(`${options.tag}(${resource.LogicalId})`)(function* (
93 request?: Omit<I, "name">,
94 ) {
95 return yield* run({
96 ...(request ?? {}),
97 name: yield* name,
98 } as I);
99 });
100 });
101 });