1/**
2 * Shared scaffolding for AMP data-plane HTTP bindings.
3 *
4 * Amazon Managed Service for Prometheus exposes its data plane as a
5 * Prometheus-compatible HTTP API under the workspace's `prometheusEndpoint`
6 * (`aps:RemoteWrite`, `aps:QueryMetrics`, `aps:GetLabels`, `aps:GetSeries`,
7 * `aps:GetMetricMetadata`). There is no distilled operation for these — every
8 * request is a SigV4-signed HTTP call (service `"aps"`) made with the host
9 * Function's own credentials.
10 *
11 * NOT exported from `index.ts` — only the per-capability contracts and
12 * `*Http` layers are public.
13 */
14import * as Credentials from "@distilled.cloud/aws/Credentials";
15import * as Region from "@distilled.cloud/aws/Region";
16import * as SigV4 from "@distilled.cloud/aws/SigV4";
17import type * as Duration from "effect/Duration";
18import * as Effect from "effect/Effect";
19import * as Redacted from "effect/Redacted";
20import * as Binding from "../../Binding.ts";
21import { toWireSeconds } from "../../Util/Duration.ts";
22import { isBindingHost } from "../Lambda/Function.ts";
23import { PrometheusApiError, type PrometheusTime } from "./PrometheusTypes.ts";
24import type { Workspace } from "./Workspace.ts";
25
26/** A request against the workspace's Prometheus-compatible endpoint. */
27export interface AmpHttpRequest {
28 method: "GET" | "POST";
29 /** Path relative to `prometheusEndpoint`, e.g. `"api/v1/query"`. */
30 path: string;
31 /** Query-string parameters; array values are appended repeatedly. */
32 query?: Record<string, string | string[] | undefined>;
33 /** URL-encoded form body (`application/x-www-form-urlencoded`). */
34 form?: Record<string, string | string[] | undefined>;
35 /** Raw binary body (used by remote-write). */
36 bytes?: {
37 data: Uint8Array;
38 contentType: string;
39 headers?: Record<string, string>;
40 };
41}
42
43/**
44 * Signs and sends one {@link AmpHttpRequest}, unwraps the Prometheus
45 * `{ status, data }` envelope, and returns `data` (or `undefined` for
46 * empty-body responses like remote-write's `200`).
47 */
48export type AmpSend = (
49 request: AmpHttpRequest,
50) => Effect.Effect<
51 unknown,
52 PrometheusApiError | Credentials.CredentialsError | SigV4.SigningError
53>;
54
55const appendParams = (
56 target: URLSearchParams,
57 params: Record<string, string | string[] | undefined>,
58) => {
59 for (const [key, value] of Object.entries(params)) {
60 if (value === undefined) continue;
61 if (Array.isArray(value)) {
62 for (const item of value) target.append(key, item);
63 } else {
64 target.set(key, value);
65 }
66 }
67};
68
69/** Serialize a {@link PrometheusTime} for the query API. */
70export const toPromTime = (time: PrometheusTime): string =>
71 time instanceof Date ? time.toISOString() : String(time);
72
73/** Serialize a `Duration.Input` as a Prometheus duration string (`"30s"`). */
74export const toPromDuration = (input: Duration.Input): string =>
75 `${toWireSeconds(input)}s`;
76
77/**
78 * Build the shared body of an AMP data-plane `*Http` binding layer:
79 * resolve the ambient distilled `Credentials`/`Region` context once at layer
80 * construction, register the least-privilege IAM statement on the host
81 * Function at deploy time, and hand a signed-request `send` function to the
82 * capability-specific `makeClient`.
83 */
84export const makeAmpWorkspaceHttpBinding = <Client>(options: {
85 /** Capability name used in the binding SID and trace spans, e.g. `"QueryMetrics"`. */
86 name: string;
87 /** IAM actions the capability requires on the workspace, e.g. `["aps:QueryMetrics"]`. */
88 iamActions: string[];
89 /** Build the typed runtime client from the signed-request sender. */
90 makeClient: (send: AmpSend) => Client;
91}) =>
92 Effect.gen(function* () {
93 const services = yield* Effect.context<
94 Credentials.Credentials | Region.Region
95 >();
96
97 return Effect.fn(function* (workspace: Workspace) {
98 const PrometheusEndpoint = yield* workspace.prometheusEndpoint;
99 if (!globalThis.__ALCHEMY_RUNTIME__) {
100 const host = yield* Binding.Host;
101 if (isBindingHost(host)) {
102 yield* host.bind`Allow(${host}, AWS.AMP.${options.name}(${workspace}))`(
103 {
104 policyStatements: [
105 {
106 Effect: "Allow",
107 Action: options.iamActions,
108 Resource: [workspace.workspaceArn],
109 },
110 ],
111 },
112 );
113 }
114 }
115
116 const send: AmpSend = Effect.fn(
117 `AWS.AMP.${options.name}(${workspace.LogicalId})`,
118 )(function* (request: AmpHttpRequest) {
119 const endpoint = yield* PrometheusEndpoint;
120 if (endpoint === undefined) {
121 return yield* Effect.fail(
122 new PrometheusApiError({
123 method: request.method,
124 path: request.path,
125 status: 0,
126 body: "workspace has no prometheusEndpoint",
127 }),
128 );
129 }
130
131 const base = endpoint.endsWith("/") ? endpoint : `${endpoint}/`;
132 const url = new URL(request.path, base);
133 appendParams(url.searchParams, request.query ?? {});
134
135 const headers: Record<string, string> = {};
136 let body: string | Uint8Array<ArrayBuffer> | undefined;
137 if (request.form !== undefined) {
138 const form = new URLSearchParams();
139 appendParams(form, request.form);
140 body = form.toString();
141 headers["content-type"] = "application/x-www-form-urlencoded";
142 } else if (request.bytes !== undefined) {
143 // Copy into a fresh ArrayBuffer-backed view — `fetch`'s `BodyInit`
144 // rejects `Uint8Array<ArrayBufferLike>` (SharedArrayBuffer-backed).
145 body = new Uint8Array(request.bytes.data);
146 headers["content-type"] = request.bytes.contentType;
147 Object.assign(headers, request.bytes.headers);
148 }
149
150 // Resolve credentials fresh per request (SSO/STS sessions rotate) and
151 // sign for the workspace's own region, parsed from its endpoint.
152 const { credentials, region } = yield* Effect.gen(function* () {
153 const credentials = yield* yield* Credentials.Credentials;
154 const region =
155 /^aps-workspaces\.([a-z0-9-]+)\.amazonaws\.com$/.exec(
156 url.hostname,
157 )?.[1] ?? (yield* yield* Region.Region);
158 return { credentials, region };
159 }).pipe(Effect.provideContext(services));
160
161 const signed = yield* SigV4.sign({
162 method: request.method,
163 url: url.toString(),
164 headers,
165 body,
166 accessKeyId: Redacted.value(credentials.accessKeyId),
167 secretAccessKey: credentials.secretAccessKey,
168 sessionToken: credentials.sessionToken,
169 service: "aps",
170 region,
171 allHeaders: true,
172 });
173
174 const toError = (status: number) => (cause: unknown) =>
175 new PrometheusApiError({
176 method: request.method,
177 path: request.path,
178 status,
179 body: cause instanceof Error ? cause.message : String(cause),
180 });
181
182 const response = yield* Effect.tryPromise({
183 try: () =>
184 fetch(signed.url, {
185 method: signed.method,
186 headers: signed.headers,
187 body,
188 }),
189 catch: toError(0),
190 });
191 const text = yield* Effect.tryPromise({
192 try: () => response.text(),
193 catch: toError(response.status),
194 });
195
196 if (response.status < 200 || response.status >= 300) {
197 return yield* Effect.fail(
198 new PrometheusApiError({
199 method: request.method,
200 path: request.path,
201 status: response.status,
202 body: text,
203 }),
204 );
205 }
206 if (text.trim() === "") {
207 // remote-write success has no body.
208 return undefined;
209 }
210
211 const envelope = yield* Effect.try({
212 try: () =>
213 JSON.parse(text) as {
214 status?: string;
215 data?: unknown;
216 error?: string;
217 errorType?: string;
218 },
219 catch: toError(response.status),
220 });
221 if (envelope.status !== undefined && envelope.status !== "success") {
222 return yield* Effect.fail(
223 new PrometheusApiError({
224 method: request.method,
225 path: request.path,
226 status: response.status,
227 body: text,
228 }),
229 );
230 }
231 return envelope.data;
232 });
233
234 return options.makeClient(send);
235 });
236 });