@crewhaus/cloud-adapter-render 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@crewhaus/cloud-adapter-render",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "One-click deploy adapter for Render — generates a render.yaml blueprint + a Render-tuned Dockerfile and wraps the Render Deploy API for daemon target shapes (Section 44)",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/docker-images": "0.0.0",
16
+ "@crewhaus/errors": "0.0.0"
17
+ },
18
+ "license": "Apache-2.0",
19
+ "author": {
20
+ "name": "Max Meier",
21
+ "email": "max@studiomax.io",
22
+ "url": "https://studiomax.io"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/crewhaus/factory.git",
27
+ "directory": "packages/cloud-adapter-render"
28
+ },
29
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/cloud-adapter-render#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/crewhaus/factory/issues"
32
+ },
33
+ "publishConfig": {
34
+ "access": "restricted"
35
+ },
36
+ "files": [
37
+ "src",
38
+ "README.md",
39
+ "LICENSE",
40
+ "NOTICE"
41
+ ]
42
+ }
@@ -0,0 +1,284 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ RENDER_DEFAULT_API_BASE,
4
+ RENDER_TARGET_SHAPES,
5
+ RenderAdapterError,
6
+ deployToRender,
7
+ isRenderTargetShape,
8
+ renderBlueprintFor,
9
+ renderDockerfileFor,
10
+ scrubApiKey,
11
+ } from "./index";
12
+
13
+ describe("isRenderTargetShape", () => {
14
+ test("accepts the daemon shapes", () => {
15
+ for (const shape of RENDER_TARGET_SHAPES) {
16
+ expect(isRenderTargetShape(shape)).toBe(true);
17
+ }
18
+ });
19
+ test("rejects one-shot shapes", () => {
20
+ for (const shape of ["cli", "workflow", "eval", "graph", "pipeline", "crew", "research"]) {
21
+ expect(isRenderTargetShape(shape)).toBe(false);
22
+ }
23
+ });
24
+ test("rejects non-strings", () => {
25
+ expect(isRenderTargetShape(42)).toBe(false);
26
+ expect(isRenderTargetShape(undefined)).toBe(false);
27
+ expect(isRenderTargetShape(null)).toBe(false);
28
+ });
29
+ });
30
+
31
+ describe("renderBlueprintFor", () => {
32
+ test("emits a web-service blueprint for channel", () => {
33
+ const yaml = renderBlueprintFor({ target: "channel", serviceName: "my-bot" });
34
+ expect(yaml).toContain("services:");
35
+ expect(yaml).toContain("- type: web");
36
+ expect(yaml).toContain("name: my-bot");
37
+ expect(yaml).toContain("runtime: docker");
38
+ expect(yaml).toContain("dockerfilePath: ./Dockerfile.render");
39
+ expect(yaml).toContain("region: oregon");
40
+ expect(yaml).toContain("plan: starter");
41
+ expect(yaml).toContain("healthCheckPath: /healthz");
42
+ });
43
+
44
+ test("emits a worker blueprint for batch (no healthCheckPath)", () => {
45
+ const yaml = renderBlueprintFor({ target: "batch", serviceName: "my-worker" });
46
+ expect(yaml).toContain("- type: worker");
47
+ expect(yaml).not.toContain("healthCheckPath");
48
+ });
49
+
50
+ test("env vars are sorted deterministically", () => {
51
+ const a = renderBlueprintFor({
52
+ target: "channel",
53
+ serviceName: "svc",
54
+ envVars: { ZETA: "1", ALPHA: "2", MIDDLE: "3" },
55
+ });
56
+ const b = renderBlueprintFor({
57
+ target: "channel",
58
+ serviceName: "svc",
59
+ envVars: { MIDDLE: "3", ZETA: "1", ALPHA: "2" },
60
+ });
61
+ expect(a).toBe(b);
62
+ const alpha = a.indexOf("key: ALPHA");
63
+ const middle = a.indexOf("key: MIDDLE");
64
+ const zeta = a.indexOf("key: ZETA");
65
+ expect(alpha).toBeLessThan(middle);
66
+ expect(middle).toBeLessThan(zeta);
67
+ });
68
+
69
+ test("renders envVarsFromGroup references", () => {
70
+ const yaml = renderBlueprintFor({
71
+ target: "managed",
72
+ serviceName: "svc",
73
+ envVarsFromGroup: ["secrets-prod"],
74
+ });
75
+ expect(yaml).toContain("- fromGroup: secrets-prod");
76
+ });
77
+
78
+ test("respects region + plan overrides", () => {
79
+ const yaml = renderBlueprintFor({
80
+ target: "channel",
81
+ serviceName: "svc",
82
+ region: "frankfurt",
83
+ plan: "standard",
84
+ });
85
+ expect(yaml).toContain("region: frankfurt");
86
+ expect(yaml).toContain("plan: standard");
87
+ });
88
+
89
+ test("includes image tag when provided", () => {
90
+ const yaml = renderBlueprintFor({
91
+ target: "channel",
92
+ serviceName: "svc",
93
+ imageTag: "ghcr.io/me/img:0.1.0",
94
+ });
95
+ expect(yaml).toContain("image: ghcr.io/me/img:0.1.0");
96
+ });
97
+
98
+ test("omits image when imageTag is 'auto' (build from Dockerfile)", () => {
99
+ const yaml = renderBlueprintFor({ target: "channel", serviceName: "svc", imageTag: "auto" });
100
+ expect(yaml).not.toContain("\n image:");
101
+ });
102
+
103
+ test("rejects invalid service names", () => {
104
+ expect(() => renderBlueprintFor({ target: "channel", serviceName: "MyBot" })).toThrow(
105
+ RenderAdapterError,
106
+ );
107
+ expect(() => renderBlueprintFor({ target: "channel", serviceName: "-leading" })).toThrow(
108
+ RenderAdapterError,
109
+ );
110
+ expect(() => renderBlueprintFor({ target: "channel", serviceName: "trailing-" })).toThrow(
111
+ RenderAdapterError,
112
+ );
113
+ expect(() => renderBlueprintFor({ target: "channel", serviceName: "" })).toThrow(
114
+ RenderAdapterError,
115
+ );
116
+ });
117
+
118
+ test("rejects unsupported target shapes", () => {
119
+ expect(() => renderBlueprintFor({ target: "cli", serviceName: "svc" })).toThrow(
120
+ RenderAdapterError,
121
+ );
122
+ expect(() => renderBlueprintFor({ target: "workflow", serviceName: "svc" })).toThrow(
123
+ RenderAdapterError,
124
+ );
125
+ expect(() => renderBlueprintFor({ target: "eval", serviceName: "svc" })).toThrow(
126
+ RenderAdapterError,
127
+ );
128
+ });
129
+ });
130
+
131
+ describe("renderDockerfileFor", () => {
132
+ test("web shape gets healthcheck against \\$PORT", () => {
133
+ const df = renderDockerfileFor({ target: "channel", baseImage: "crewhaus/channel:0.1.0" });
134
+ expect(df).toContain("FROM crewhaus/channel:0.1.0");
135
+ expect(df).toContain("ENV PORT=10000");
136
+ expect(df).toContain("HEALTHCHECK");
137
+ expect(df).toContain("http://localhost:$PORT/healthz");
138
+ });
139
+
140
+ test("worker shape skips HEALTHCHECK", () => {
141
+ const df = renderDockerfileFor({ target: "batch", baseImage: "crewhaus/batch:0.1.0" });
142
+ expect(df).toContain("FROM crewhaus/batch:0.1.0");
143
+ expect(df).not.toContain("HEALTHCHECK");
144
+ });
145
+
146
+ test("rejects one-shot target shapes", () => {
147
+ expect(() => renderDockerfileFor({ target: "cli", baseImage: "crewhaus/cli:0.1.0" })).toThrow(
148
+ RenderAdapterError,
149
+ );
150
+ });
151
+
152
+ test("rejects unknown target shapes", () => {
153
+ expect(() => renderDockerfileFor({ target: "mystery" as never, baseImage: "x" })).toThrow(
154
+ RenderAdapterError,
155
+ );
156
+ });
157
+ });
158
+
159
+ describe("scrubApiKey", () => {
160
+ test("redacts the key", () => {
161
+ const out = scrubApiKey("Auth failed for key rnd_abcdef1234567890", "rnd_abcdef1234567890");
162
+ expect(out).toBe("Auth failed for key [REDACTED:RENDER_API_KEY]");
163
+ });
164
+
165
+ test("redacts multiple occurrences", () => {
166
+ const out = scrubApiKey("key rnd_xyzlong used twice rnd_xyzlong", "rnd_xyzlong");
167
+ expect(out).toBe("key [REDACTED:RENDER_API_KEY] used twice [REDACTED:RENDER_API_KEY]");
168
+ });
169
+
170
+ test("returns input unchanged when key is short", () => {
171
+ expect(scrubApiKey("foo bar baz", "abc")).toBe("foo bar baz");
172
+ });
173
+
174
+ test("returns input unchanged when key is undefined", () => {
175
+ expect(scrubApiKey("foo bar baz", undefined)).toBe("foo bar baz");
176
+ });
177
+ });
178
+
179
+ const API_KEY_ENV = "RENDER_API_KEY";
180
+
181
+ describe("deployToRender", () => {
182
+ const ORIGINAL_KEY = process.env[API_KEY_ENV];
183
+ afterEach(() => {
184
+ if (ORIGINAL_KEY === undefined) delete process.env[API_KEY_ENV];
185
+ else process.env[API_KEY_ENV] = ORIGINAL_KEY;
186
+ });
187
+
188
+ test("posts to the default Render base URL with Bearer auth", async () => {
189
+ let observed: { url: string; init: RequestInit } | undefined;
190
+ const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
191
+ observed = { url: String(url), init: init ?? {} };
192
+ return new Response(
193
+ JSON.stringify({
194
+ service: { id: "srv-123" },
195
+ deploy: { id: "dep-456", status: "created" },
196
+ }),
197
+ { status: 201 },
198
+ );
199
+ }) as unknown as typeof fetch;
200
+ const result = await deployToRender({
201
+ apiKey: "rnd_testkey_abcdef",
202
+ blueprintYaml: "services: []\n",
203
+ serviceName: "svc",
204
+ fetchImpl,
205
+ });
206
+ expect(observed?.url).toBe(`${RENDER_DEFAULT_API_BASE}/services`);
207
+ expect(observed?.init.method).toBe("POST");
208
+ const headers = observed?.init.headers as Record<string, string>;
209
+ expect(headers["Authorization"]).toBe("Bearer rnd_testkey_abcdef");
210
+ expect(headers["Content-Type"]).toBe("application/yaml");
211
+ expect(result.serviceId).toBe("srv-123");
212
+ expect(result.deployId).toBe("dep-456");
213
+ expect(result.status).toBe("created");
214
+ });
215
+
216
+ test("scrubs the API key out of error messages", async () => {
217
+ const fetchImpl = (async () =>
218
+ new Response("forbidden — key rnd_leakingkey_full was rejected", {
219
+ status: 403,
220
+ statusText: "Forbidden",
221
+ })) as unknown as typeof fetch;
222
+ let caught: Error | undefined;
223
+ try {
224
+ await deployToRender({
225
+ apiKey: "rnd_leakingkey_full",
226
+ blueprintYaml: "services: []\n",
227
+ serviceName: "svc",
228
+ fetchImpl,
229
+ });
230
+ } catch (err) {
231
+ caught = err as Error;
232
+ }
233
+ expect(caught).toBeInstanceOf(RenderAdapterError);
234
+ expect(caught?.message).not.toContain("rnd_leakingkey_full");
235
+ expect(caught?.message).toContain("[REDACTED:RENDER_API_KEY]");
236
+ });
237
+
238
+ test("throws clearly when no API key is configured", async () => {
239
+ delete process.env[API_KEY_ENV];
240
+ let caught: Error | undefined;
241
+ try {
242
+ await deployToRender({ blueprintYaml: "x", serviceName: "svc" });
243
+ } catch (err) {
244
+ caught = err as Error;
245
+ }
246
+ expect(caught).toBeInstanceOf(RenderAdapterError);
247
+ expect(caught?.message).toContain("RENDER_API_KEY is required");
248
+ });
249
+
250
+ test("resolves the API key from the env var when apiKey omitted", async () => {
251
+ process.env[API_KEY_ENV] = "rnd_env_provided_key_long";
252
+ let seen: string | undefined;
253
+ const fetchImpl = (async (_url: string | URL, init?: RequestInit) => {
254
+ seen = (init?.headers as Record<string, string>)["Authorization"];
255
+ return new Response(
256
+ JSON.stringify({ service: { id: "srv-x" }, deploy: { id: "d-x", status: "live" } }),
257
+ { status: 201 },
258
+ );
259
+ }) as unknown as typeof fetch;
260
+ await deployToRender({ blueprintYaml: "services: []\n", serviceName: "svc", fetchImpl });
261
+ expect(seen).toBe("Bearer rnd_env_provided_key_long");
262
+ });
263
+
264
+ test("validates service name before the network call", async () => {
265
+ let called = false;
266
+ const fetchImpl = (async () => {
267
+ called = true;
268
+ return new Response("", { status: 200 });
269
+ }) as unknown as typeof fetch;
270
+ let caught: Error | undefined;
271
+ try {
272
+ await deployToRender({
273
+ apiKey: "rnd_key_for_test_only",
274
+ blueprintYaml: "x",
275
+ serviceName: "BadCaps",
276
+ fetchImpl,
277
+ });
278
+ } catch (err) {
279
+ caught = err as Error;
280
+ }
281
+ expect(caught).toBeInstanceOf(RenderAdapterError);
282
+ expect(called).toBe(false);
283
+ });
284
+ });
package/src/index.ts ADDED
@@ -0,0 +1,298 @@
1
+ import { type TargetShape, isTargetShape } from "@crewhaus/docker-images";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+
4
+ /**
5
+ * Section 44 — `@crewhaus/cloud-adapter-render`
6
+ *
7
+ * One-click deploy adapter for [Render](https://render.com). Three surfaces:
8
+ *
9
+ * - `renderBlueprintFor({target, serviceName, imageTag, …})` —
10
+ * emits a deterministic `render.yaml` Infrastructure-as-Code blueprint
11
+ * that points Render at the §32 docker-images Dockerfile for the
12
+ * given target shape. Render reads this at the repo root.
13
+ *
14
+ * - `renderDockerfileFor({target, …})` — wraps the §32 docker-images
15
+ * Dockerfile with a Render-tuned final stage: listens on `$PORT`
16
+ * (Render injects this), no fixed EXPOSE, healthcheck honors
17
+ * RENDER's hosted load-balancer probe.
18
+ *
19
+ * - `deployToRender({apiKey, blueprintYaml, …})` — POSTs the blueprint
20
+ * against the Render Deploy API. Live API calls are gated on the
21
+ * `RENDER_API_KEY` env (or explicit `apiKey`) plus an injectable
22
+ * `fetchImpl` test seam.
23
+ *
24
+ * SECURITY (T8 credential-leak guard): the API key never appears in
25
+ * adapter logs / errors / deploy records. Every error path runs through
26
+ * `scrubApiKey()` before surfacing to the caller — same approach as the
27
+ * §37 `exporter-datadog` `scrubApiKey` helper.
28
+ *
29
+ * Targets: daemon-shape bundles (channel, managed, batch, voice, browser).
30
+ * CLI/workflow/eval shapes are one-shot and don't fit Render's
31
+ * always-on service model — passing them throws `RenderAdapterError`.
32
+ */
33
+
34
+ export class RenderAdapterError extends CrewhausError {
35
+ override readonly name = "RenderAdapterError";
36
+ constructor(message: string, cause?: unknown) {
37
+ super("config", message, cause);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Render-deployable target shapes. CLI/workflow/eval are one-shot;
43
+ * Render's "web service" / "background worker" primitives are for
44
+ * long-running daemons. This is the canonical list for §44.
45
+ */
46
+ export const RENDER_TARGET_SHAPES = [
47
+ "channel",
48
+ "managed",
49
+ "batch",
50
+ "voice",
51
+ "browser",
52
+ ] as const satisfies readonly TargetShape[];
53
+
54
+ export type RenderTargetShape = (typeof RENDER_TARGET_SHAPES)[number];
55
+
56
+ export function isRenderTargetShape(value: unknown): value is RenderTargetShape {
57
+ return typeof value === "string" && (RENDER_TARGET_SHAPES as readonly string[]).includes(value);
58
+ }
59
+
60
+ export const RENDER_REGIONS = ["oregon", "frankfurt", "singapore", "ohio", "virginia"] as const;
61
+ export type RenderRegion = (typeof RENDER_REGIONS)[number];
62
+
63
+ export const RENDER_PLANS = ["starter", "standard", "pro", "pro plus", "pro max"] as const;
64
+ export type RenderPlan = (typeof RENDER_PLANS)[number];
65
+
66
+ export type RenderBlueprintOptions = {
67
+ readonly target: TargetShape;
68
+ /** Render service id; doubles as DNS prefix. Lowercase letters, digits, hyphens. */
69
+ readonly serviceName: string;
70
+ /** Image registry coordinate or `auto` to let Render build from Dockerfile. */
71
+ readonly imageTag?: string;
72
+ readonly region?: RenderRegion;
73
+ readonly plan?: RenderPlan;
74
+ /** Plain-text env vars merged into the blueprint. Secret values must use `envVarsFromGroup`. */
75
+ readonly envVars?: Readonly<Record<string, string>>;
76
+ /** Reference an existing Render env-var-group for shared secrets. */
77
+ readonly envVarsFromGroup?: ReadonlyArray<string>;
78
+ /** Path to the Dockerfile inside the repo. Defaults to `./Dockerfile.render`. */
79
+ readonly dockerfilePath?: string;
80
+ };
81
+
82
+ const SERVICE_TYPE_FOR_TARGET: Readonly<Record<RenderTargetShape, "web" | "worker">> = {
83
+ channel: "web",
84
+ managed: "web",
85
+ voice: "web",
86
+ browser: "web",
87
+ batch: "worker",
88
+ };
89
+
90
+ const SERVICE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
91
+
92
+ function assertRenderTarget(target: TargetShape): asserts target is RenderTargetShape {
93
+ if (!isRenderTargetShape(target)) {
94
+ throw new RenderAdapterError(
95
+ `target shape "${target}" is not supported by Render. ` +
96
+ `Supported daemon shapes: ${RENDER_TARGET_SHAPES.join(", ")}.`,
97
+ );
98
+ }
99
+ }
100
+
101
+ function assertServiceName(name: string): void {
102
+ if (!SERVICE_NAME_PATTERN.test(name)) {
103
+ throw new RenderAdapterError(
104
+ `service name "${name}" is invalid. Must be lowercase alphanumerics + hyphens, 1-63 chars, no leading/trailing hyphen.`,
105
+ );
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Render-tuned Dockerfile final stage. Wraps the §32 base image with:
111
+ * - `ENV PORT=10000` default; Render overrides at runtime.
112
+ * - No fixed `EXPOSE` (Render reads `$PORT`).
113
+ * - `HEALTHCHECK` curling `localhost:$PORT/healthz` — matches the
114
+ * gateway-server convention used by `channel-bot` / `managed`.
115
+ *
116
+ * The caller is responsible for ensuring the base image (built by
117
+ * `docker-images.buildImage`) exposes a `/healthz` endpoint. The browser
118
+ * shape ships a stub `/healthz` in `target-browser-driver`; batch shapes
119
+ * don't need one because Render's worker type doesn't health-probe.
120
+ */
121
+ export type RenderDockerfileOptions = {
122
+ readonly target: TargetShape;
123
+ /** Base image tag emitted by `docker-images.buildImage` (e.g. `crewhaus/channel:0.1.0`). */
124
+ readonly baseImage: string;
125
+ };
126
+
127
+ export function renderDockerfileFor(opts: RenderDockerfileOptions): string {
128
+ if (!isTargetShape(opts.target)) {
129
+ throw new RenderAdapterError(`unknown target shape "${opts.target}"`);
130
+ }
131
+ assertRenderTarget(opts.target);
132
+ const isWeb = SERVICE_TYPE_FOR_TARGET[opts.target] === "web";
133
+ const lines = [
134
+ `# Render-tuned wrapper around ${opts.baseImage}`,
135
+ `FROM ${opts.baseImage}`,
136
+ "ENV PORT=10000",
137
+ ];
138
+ if (isWeb) {
139
+ lines.push(
140
+ // Use shell form so `$PORT` is expanded against Render's runtime value.
141
+ "HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \\",
142
+ ' CMD curl --silent --fail "http://localhost:$PORT/healthz" || exit 1',
143
+ );
144
+ }
145
+ return `${lines.join("\n")}\n`;
146
+ }
147
+
148
+ /**
149
+ * Render Blueprint spec — minimal subset of the [Render Blueprint schema](https://render.com/docs/blueprint-spec).
150
+ * Emitted YAML is deterministic: stable key order, no trailing whitespace.
151
+ */
152
+ export function renderBlueprintFor(opts: RenderBlueprintOptions): string {
153
+ if (!isTargetShape(opts.target)) {
154
+ throw new RenderAdapterError(`unknown target shape "${opts.target}"`);
155
+ }
156
+ assertRenderTarget(opts.target);
157
+ assertServiceName(opts.serviceName);
158
+
159
+ const serviceType = SERVICE_TYPE_FOR_TARGET[opts.target];
160
+ const region = opts.region ?? "oregon";
161
+ const plan = opts.plan ?? "starter";
162
+ const dockerfilePath = opts.dockerfilePath ?? "./Dockerfile.render";
163
+
164
+ const lines: string[] = [
165
+ "# Generated by @crewhaus/cloud-adapter-render — do not edit by hand",
166
+ "services:",
167
+ ];
168
+ lines.push(` - type: ${serviceType}`);
169
+ lines.push(` name: ${opts.serviceName}`);
170
+ lines.push(" runtime: docker");
171
+ lines.push(` dockerfilePath: ${dockerfilePath}`);
172
+ lines.push(` region: ${region}`);
173
+ lines.push(` plan: ${plan}`);
174
+ if (serviceType === "web") {
175
+ lines.push(" healthCheckPath: /healthz");
176
+ }
177
+ if (opts.imageTag !== undefined && opts.imageTag !== "auto") {
178
+ lines.push(` image: ${opts.imageTag}`);
179
+ }
180
+ if (opts.envVars && Object.keys(opts.envVars).length > 0) {
181
+ lines.push(" envVars:");
182
+ for (const key of Object.keys(opts.envVars).sort()) {
183
+ const value = opts.envVars[key];
184
+ if (value === undefined) continue;
185
+ lines.push(` - key: ${key}`);
186
+ lines.push(` value: ${JSON.stringify(value)}`);
187
+ }
188
+ }
189
+ if (opts.envVarsFromGroup && opts.envVarsFromGroup.length > 0) {
190
+ if (!opts.envVars || Object.keys(opts.envVars).length === 0) {
191
+ lines.push(" envVars:");
192
+ }
193
+ for (const group of opts.envVarsFromGroup) {
194
+ lines.push(` - fromGroup: ${group}`);
195
+ }
196
+ }
197
+ return `${lines.join("\n")}\n`;
198
+ }
199
+
200
+ export type RenderDeployRecord = {
201
+ readonly serviceId: string;
202
+ readonly deployId: string;
203
+ readonly url?: string;
204
+ readonly status: string;
205
+ };
206
+
207
+ export type DeployToRenderOptions = {
208
+ readonly blueprintYaml: string;
209
+ readonly serviceName: string;
210
+ /** Render API key. Defaults to `process.env.RENDER_API_KEY`. */
211
+ readonly apiKey?: string;
212
+ /** Default `https://api.render.com/v1`. Override for VCR-style tests. */
213
+ readonly apiBaseUrl?: string;
214
+ /** Owner id (team or user) the service belongs to. */
215
+ readonly ownerId?: string;
216
+ /** Test seam — override fetch. */
217
+ readonly fetchImpl?: typeof fetch;
218
+ };
219
+
220
+ export const RENDER_DEFAULT_API_BASE = "https://api.render.com/v1";
221
+
222
+ function resolveApiKey(provided: string | undefined): string {
223
+ const key = provided ?? process.env["RENDER_API_KEY"];
224
+ if (key === undefined || key.length === 0) {
225
+ throw new RenderAdapterError(
226
+ "RENDER_API_KEY is required. Pass `apiKey` explicitly or set the env var.",
227
+ );
228
+ }
229
+ return key;
230
+ }
231
+
232
+ /**
233
+ * Scrub the API key out of an arbitrary string. Mirrors the
234
+ * §37 `exporter-datadog` helper. Keys shorter than 8 chars are not
235
+ * scrubbed (would match too aggressively against ordinary text).
236
+ */
237
+ export function scrubApiKey(message: string, apiKey: string | undefined): string {
238
+ if (apiKey === undefined || apiKey.length < 8) return message;
239
+ return message.split(apiKey).join("[REDACTED:RENDER_API_KEY]");
240
+ }
241
+
242
+ /**
243
+ * POST the blueprint at Render and return the deploy record. Throws a
244
+ * `RenderAdapterError` whose `.message` is guaranteed scrubbed.
245
+ */
246
+ export async function deployToRender(opts: DeployToRenderOptions): Promise<RenderDeployRecord> {
247
+ const apiKey = resolveApiKey(opts.apiKey);
248
+ const baseUrl = opts.apiBaseUrl ?? RENDER_DEFAULT_API_BASE;
249
+ const fetchFn = opts.fetchImpl ?? fetch;
250
+ assertServiceName(opts.serviceName);
251
+
252
+ let response: Response;
253
+ try {
254
+ response = await fetchFn(`${baseUrl}/services`, {
255
+ method: "POST",
256
+ headers: {
257
+ Authorization: `Bearer ${apiKey}`,
258
+ "Content-Type": "application/yaml",
259
+ Accept: "application/json",
260
+ },
261
+ body: opts.blueprintYaml,
262
+ });
263
+ } catch (err) {
264
+ const msg = err instanceof Error ? err.message : String(err);
265
+ throw new RenderAdapterError(`Render API request failed: ${scrubApiKey(msg, apiKey)}`, err);
266
+ }
267
+
268
+ const text = await response.text();
269
+ const scrubbed = scrubApiKey(text, apiKey);
270
+ if (!response.ok) {
271
+ throw new RenderAdapterError(
272
+ `Render API returned ${response.status} ${response.statusText}: ${scrubbed}`,
273
+ );
274
+ }
275
+
276
+ let parsed: {
277
+ id?: string;
278
+ service?: { id?: string };
279
+ deploy?: { id?: string; status?: string };
280
+ serviceDetails?: { url?: string };
281
+ };
282
+ try {
283
+ parsed = JSON.parse(text);
284
+ } catch (err) {
285
+ throw new RenderAdapterError(`Render API returned non-JSON body: ${scrubbed}`, err);
286
+ }
287
+
288
+ const serviceId = parsed.service?.id ?? parsed.id;
289
+ if (typeof serviceId !== "string") {
290
+ throw new RenderAdapterError(`Render API response missing service id: ${scrubbed}`);
291
+ }
292
+ return {
293
+ serviceId,
294
+ deployId: parsed.deploy?.id ?? "",
295
+ url: parsed.serviceDetails?.url,
296
+ status: parsed.deploy?.status ?? "pending",
297
+ };
298
+ }