@crewhaus/cloud-adapter-render 0.1.4 → 0.1.5

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.
@@ -0,0 +1,115 @@
1
+ import { type TargetShape } from "@crewhaus/docker-images";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+ /**
4
+ * Section 44 — `@crewhaus/cloud-adapter-render`
5
+ *
6
+ * One-click deploy adapter for [Render](https://render.com). Three surfaces:
7
+ *
8
+ * - `renderBlueprintFor({target, serviceName, imageTag, …})` —
9
+ * emits a deterministic `render.yaml` Infrastructure-as-Code blueprint
10
+ * that points Render at the §32 docker-images Dockerfile for the
11
+ * given target shape. Render reads this at the repo root.
12
+ *
13
+ * - `renderDockerfileFor({target, …})` — wraps the §32 docker-images
14
+ * Dockerfile with a Render-tuned final stage: listens on `$PORT`
15
+ * (Render injects this), no fixed EXPOSE, healthcheck honors
16
+ * RENDER's hosted load-balancer probe.
17
+ *
18
+ * - `deployToRender({apiKey, blueprintYaml, …})` — POSTs the blueprint
19
+ * against the Render Deploy API. Live API calls are gated on the
20
+ * `RENDER_API_KEY` env (or explicit `apiKey`) plus an injectable
21
+ * `fetchImpl` test seam.
22
+ *
23
+ * SECURITY (T8 credential-leak guard): the API key never appears in
24
+ * adapter logs / errors / deploy records. Every error path runs through
25
+ * `scrubApiKey()` before surfacing to the caller — same approach as the
26
+ * §37 `exporter-datadog` `scrubApiKey` helper.
27
+ *
28
+ * Targets: daemon-shape bundles (channel, managed, batch, voice, browser).
29
+ * CLI/workflow/eval shapes are one-shot and don't fit Render's
30
+ * always-on service model — passing them throws `RenderAdapterError`.
31
+ */
32
+ export declare class RenderAdapterError extends CrewhausError {
33
+ readonly name = "RenderAdapterError";
34
+ constructor(message: string, cause?: unknown);
35
+ }
36
+ /**
37
+ * Render-deployable target shapes. CLI/workflow/eval are one-shot;
38
+ * Render's "web service" / "background worker" primitives are for
39
+ * long-running daemons. This is the canonical list for §44.
40
+ */
41
+ export declare const RENDER_TARGET_SHAPES: readonly ["channel", "managed", "batch", "voice", "browser"];
42
+ export type RenderTargetShape = (typeof RENDER_TARGET_SHAPES)[number];
43
+ export declare function isRenderTargetShape(value: unknown): value is RenderTargetShape;
44
+ export declare const RENDER_REGIONS: readonly ["oregon", "frankfurt", "singapore", "ohio", "virginia"];
45
+ export type RenderRegion = (typeof RENDER_REGIONS)[number];
46
+ export declare const RENDER_PLANS: readonly ["starter", "standard", "pro", "pro plus", "pro max"];
47
+ export type RenderPlan = (typeof RENDER_PLANS)[number];
48
+ export type RenderBlueprintOptions = {
49
+ readonly target: TargetShape;
50
+ /** Render service id; doubles as DNS prefix. Lowercase letters, digits, hyphens. */
51
+ readonly serviceName: string;
52
+ /** Image registry coordinate or `auto` to let Render build from Dockerfile. */
53
+ readonly imageTag?: string;
54
+ readonly region?: RenderRegion;
55
+ readonly plan?: RenderPlan;
56
+ /** Plain-text env vars merged into the blueprint. Secret values must use `envVarsFromGroup`. */
57
+ readonly envVars?: Readonly<Record<string, string>>;
58
+ /** Reference an existing Render env-var-group for shared secrets. */
59
+ readonly envVarsFromGroup?: ReadonlyArray<string>;
60
+ /** Path to the Dockerfile inside the repo. Defaults to `./Dockerfile.render`. */
61
+ readonly dockerfilePath?: string;
62
+ };
63
+ /**
64
+ * Render-tuned Dockerfile final stage. Wraps the §32 base image with:
65
+ * - `ENV PORT=10000` default; Render overrides at runtime.
66
+ * - No fixed `EXPOSE` (Render reads `$PORT`).
67
+ * - `HEALTHCHECK` curling `localhost:$PORT/healthz` — matches the
68
+ * gateway-server convention used by `channel-bot` / `managed`.
69
+ *
70
+ * The caller is responsible for ensuring the base image (built by
71
+ * `docker-images.buildImage`) exposes a `/healthz` endpoint. The browser
72
+ * shape ships a stub `/healthz` in `target-browser-driver`; batch shapes
73
+ * don't need one because Render's worker type doesn't health-probe.
74
+ */
75
+ export type RenderDockerfileOptions = {
76
+ readonly target: TargetShape;
77
+ /** Base image tag emitted by `docker-images.buildImage` (e.g. `crewhaus/channel:0.1.0`). */
78
+ readonly baseImage: string;
79
+ };
80
+ export declare function renderDockerfileFor(opts: RenderDockerfileOptions): string;
81
+ /**
82
+ * Render Blueprint spec — minimal subset of the [Render Blueprint schema](https://render.com/docs/blueprint-spec).
83
+ * Emitted YAML is deterministic: stable key order, no trailing whitespace.
84
+ */
85
+ export declare function renderBlueprintFor(opts: RenderBlueprintOptions): string;
86
+ export type RenderDeployRecord = {
87
+ readonly serviceId: string;
88
+ readonly deployId: string;
89
+ readonly url?: string;
90
+ readonly status: string;
91
+ };
92
+ export type DeployToRenderOptions = {
93
+ readonly blueprintYaml: string;
94
+ readonly serviceName: string;
95
+ /** Render API key. Defaults to `process.env.RENDER_API_KEY`. */
96
+ readonly apiKey?: string;
97
+ /** Default `https://api.render.com/v1`. Override for VCR-style tests. */
98
+ readonly apiBaseUrl?: string;
99
+ /** Owner id (team or user) the service belongs to. */
100
+ readonly ownerId?: string;
101
+ /** Test seam — override fetch. */
102
+ readonly fetchImpl?: typeof fetch;
103
+ };
104
+ export declare const RENDER_DEFAULT_API_BASE = "https://api.render.com/v1";
105
+ /**
106
+ * Scrub the API key out of an arbitrary string. Mirrors the
107
+ * §37 `exporter-datadog` helper. Keys shorter than 8 chars are not
108
+ * scrubbed (would match too aggressively against ordinary text).
109
+ */
110
+ export declare function scrubApiKey(message: string, apiKey: string | undefined): string;
111
+ /**
112
+ * POST the blueprint at Render and return the deploy record. Throws a
113
+ * `RenderAdapterError` whose `.message` is guaranteed scrubbed.
114
+ */
115
+ export declare function deployToRender(opts: DeployToRenderOptions): Promise<RenderDeployRecord>;
package/dist/index.js ADDED
@@ -0,0 +1,207 @@
1
+ import { isTargetShape } from "@crewhaus/docker-images";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+ /**
4
+ * Section 44 — `@crewhaus/cloud-adapter-render`
5
+ *
6
+ * One-click deploy adapter for [Render](https://render.com). Three surfaces:
7
+ *
8
+ * - `renderBlueprintFor({target, serviceName, imageTag, …})` —
9
+ * emits a deterministic `render.yaml` Infrastructure-as-Code blueprint
10
+ * that points Render at the §32 docker-images Dockerfile for the
11
+ * given target shape. Render reads this at the repo root.
12
+ *
13
+ * - `renderDockerfileFor({target, …})` — wraps the §32 docker-images
14
+ * Dockerfile with a Render-tuned final stage: listens on `$PORT`
15
+ * (Render injects this), no fixed EXPOSE, healthcheck honors
16
+ * RENDER's hosted load-balancer probe.
17
+ *
18
+ * - `deployToRender({apiKey, blueprintYaml, …})` — POSTs the blueprint
19
+ * against the Render Deploy API. Live API calls are gated on the
20
+ * `RENDER_API_KEY` env (or explicit `apiKey`) plus an injectable
21
+ * `fetchImpl` test seam.
22
+ *
23
+ * SECURITY (T8 credential-leak guard): the API key never appears in
24
+ * adapter logs / errors / deploy records. Every error path runs through
25
+ * `scrubApiKey()` before surfacing to the caller — same approach as the
26
+ * §37 `exporter-datadog` `scrubApiKey` helper.
27
+ *
28
+ * Targets: daemon-shape bundles (channel, managed, batch, voice, browser).
29
+ * CLI/workflow/eval shapes are one-shot and don't fit Render's
30
+ * always-on service model — passing them throws `RenderAdapterError`.
31
+ */
32
+ export class RenderAdapterError extends CrewhausError {
33
+ name = "RenderAdapterError";
34
+ constructor(message, cause) {
35
+ super("config", message, cause);
36
+ }
37
+ }
38
+ /**
39
+ * Render-deployable target shapes. CLI/workflow/eval are one-shot;
40
+ * Render's "web service" / "background worker" primitives are for
41
+ * long-running daemons. This is the canonical list for §44.
42
+ */
43
+ export const RENDER_TARGET_SHAPES = [
44
+ "channel",
45
+ "managed",
46
+ "batch",
47
+ "voice",
48
+ "browser",
49
+ ];
50
+ export function isRenderTargetShape(value) {
51
+ return typeof value === "string" && RENDER_TARGET_SHAPES.includes(value);
52
+ }
53
+ export const RENDER_REGIONS = ["oregon", "frankfurt", "singapore", "ohio", "virginia"];
54
+ export const RENDER_PLANS = ["starter", "standard", "pro", "pro plus", "pro max"];
55
+ const SERVICE_TYPE_FOR_TARGET = {
56
+ channel: "web",
57
+ managed: "web",
58
+ voice: "web",
59
+ browser: "web",
60
+ batch: "worker",
61
+ };
62
+ const SERVICE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
63
+ function assertRenderTarget(target) {
64
+ if (!isRenderTargetShape(target)) {
65
+ throw new RenderAdapterError(`target shape "${target}" is not supported by Render. ` +
66
+ `Supported daemon shapes: ${RENDER_TARGET_SHAPES.join(", ")}.`);
67
+ }
68
+ }
69
+ function assertServiceName(name) {
70
+ if (!SERVICE_NAME_PATTERN.test(name)) {
71
+ throw new RenderAdapterError(`service name "${name}" is invalid. Must be lowercase alphanumerics + hyphens, 1-63 chars, no leading/trailing hyphen.`);
72
+ }
73
+ }
74
+ export function renderDockerfileFor(opts) {
75
+ if (!isTargetShape(opts.target)) {
76
+ throw new RenderAdapterError(`unknown target shape "${opts.target}"`);
77
+ }
78
+ assertRenderTarget(opts.target);
79
+ const isWeb = SERVICE_TYPE_FOR_TARGET[opts.target] === "web";
80
+ const lines = [
81
+ `# Render-tuned wrapper around ${opts.baseImage}`,
82
+ `FROM ${opts.baseImage}`,
83
+ "ENV PORT=10000",
84
+ ];
85
+ if (isWeb) {
86
+ lines.push(
87
+ // Use shell form so `$PORT` is expanded against Render's runtime value.
88
+ "HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \\", ' CMD curl --silent --fail "http://localhost:$PORT/healthz" || exit 1');
89
+ }
90
+ return `${lines.join("\n")}\n`;
91
+ }
92
+ /**
93
+ * Render Blueprint spec — minimal subset of the [Render Blueprint schema](https://render.com/docs/blueprint-spec).
94
+ * Emitted YAML is deterministic: stable key order, no trailing whitespace.
95
+ */
96
+ export function renderBlueprintFor(opts) {
97
+ if (!isTargetShape(opts.target)) {
98
+ throw new RenderAdapterError(`unknown target shape "${opts.target}"`);
99
+ }
100
+ assertRenderTarget(opts.target);
101
+ assertServiceName(opts.serviceName);
102
+ const serviceType = SERVICE_TYPE_FOR_TARGET[opts.target];
103
+ const region = opts.region ?? "oregon";
104
+ const plan = opts.plan ?? "starter";
105
+ const dockerfilePath = opts.dockerfilePath ?? "./Dockerfile.render";
106
+ const lines = [
107
+ "# Generated by @crewhaus/cloud-adapter-render — do not edit by hand",
108
+ "services:",
109
+ ];
110
+ lines.push(` - type: ${serviceType}`);
111
+ lines.push(` name: ${opts.serviceName}`);
112
+ lines.push(" runtime: docker");
113
+ lines.push(` dockerfilePath: ${dockerfilePath}`);
114
+ lines.push(` region: ${region}`);
115
+ lines.push(` plan: ${plan}`);
116
+ if (serviceType === "web") {
117
+ lines.push(" healthCheckPath: /healthz");
118
+ }
119
+ if (opts.imageTag !== undefined && opts.imageTag !== "auto") {
120
+ lines.push(` image: ${opts.imageTag}`);
121
+ }
122
+ if (opts.envVars && Object.keys(opts.envVars).length > 0) {
123
+ lines.push(" envVars:");
124
+ for (const key of Object.keys(opts.envVars).sort()) {
125
+ const value = opts.envVars[key];
126
+ if (value === undefined)
127
+ continue;
128
+ lines.push(` - key: ${key}`);
129
+ lines.push(` value: ${JSON.stringify(value)}`);
130
+ }
131
+ }
132
+ if (opts.envVarsFromGroup && opts.envVarsFromGroup.length > 0) {
133
+ if (!opts.envVars || Object.keys(opts.envVars).length === 0) {
134
+ lines.push(" envVars:");
135
+ }
136
+ for (const group of opts.envVarsFromGroup) {
137
+ lines.push(` - fromGroup: ${group}`);
138
+ }
139
+ }
140
+ return `${lines.join("\n")}\n`;
141
+ }
142
+ export const RENDER_DEFAULT_API_BASE = "https://api.render.com/v1";
143
+ function resolveApiKey(provided) {
144
+ const key = provided ?? process.env["RENDER_API_KEY"];
145
+ if (key === undefined || key.length === 0) {
146
+ throw new RenderAdapterError("RENDER_API_KEY is required. Pass `apiKey` explicitly or set the env var.");
147
+ }
148
+ return key;
149
+ }
150
+ /**
151
+ * Scrub the API key out of an arbitrary string. Mirrors the
152
+ * §37 `exporter-datadog` helper. Keys shorter than 8 chars are not
153
+ * scrubbed (would match too aggressively against ordinary text).
154
+ */
155
+ export function scrubApiKey(message, apiKey) {
156
+ if (apiKey === undefined || apiKey.length < 8)
157
+ return message;
158
+ return message.split(apiKey).join("[REDACTED:RENDER_API_KEY]");
159
+ }
160
+ /**
161
+ * POST the blueprint at Render and return the deploy record. Throws a
162
+ * `RenderAdapterError` whose `.message` is guaranteed scrubbed.
163
+ */
164
+ export async function deployToRender(opts) {
165
+ const apiKey = resolveApiKey(opts.apiKey);
166
+ const baseUrl = opts.apiBaseUrl ?? RENDER_DEFAULT_API_BASE;
167
+ const fetchFn = opts.fetchImpl ?? fetch;
168
+ assertServiceName(opts.serviceName);
169
+ let response;
170
+ try {
171
+ response = await fetchFn(`${baseUrl}/services`, {
172
+ method: "POST",
173
+ headers: {
174
+ Authorization: `Bearer ${apiKey}`,
175
+ "Content-Type": "application/yaml",
176
+ Accept: "application/json",
177
+ },
178
+ body: opts.blueprintYaml,
179
+ });
180
+ }
181
+ catch (err) {
182
+ const msg = err instanceof Error ? err.message : String(err);
183
+ throw new RenderAdapterError(`Render API request failed: ${scrubApiKey(msg, apiKey)}`, err);
184
+ }
185
+ const text = await response.text();
186
+ const scrubbed = scrubApiKey(text, apiKey);
187
+ if (!response.ok) {
188
+ throw new RenderAdapterError(`Render API returned ${response.status} ${response.statusText}: ${scrubbed}`);
189
+ }
190
+ let parsed;
191
+ try {
192
+ parsed = JSON.parse(text);
193
+ }
194
+ catch (err) {
195
+ throw new RenderAdapterError(`Render API returned non-JSON body: ${scrubbed}`, err);
196
+ }
197
+ const serviceId = parsed.service?.id ?? parsed.id;
198
+ if (typeof serviceId !== "string") {
199
+ throw new RenderAdapterError(`Render API response missing service id: ${scrubbed}`);
200
+ }
201
+ return {
202
+ serviceId,
203
+ deployId: parsed.deploy?.id ?? "",
204
+ url: parsed.serviceDetails?.url,
205
+ status: parsed.deploy?.status ?? "pending",
206
+ };
207
+ }
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@crewhaus/cloud-adapter-render",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
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",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/docker-images": "0.1.4",
16
- "@crewhaus/errors": "0.1.4"
18
+ "@crewhaus/docker-images": "0.1.5",
19
+ "@crewhaus/errors": "0.1.5"
17
20
  },
18
21
  "license": "Apache-2.0",
19
22
  "author": {
@@ -33,5 +36,5 @@
33
36
  "publishConfig": {
34
37
  "access": "public"
35
38
  },
36
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
39
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
37
40
  }
@@ -1,323 +0,0 @@
1
- // Supplementary branch/edge coverage for @crewhaus/cloud-adapter-render.
2
- // The primary behavioural contract lives in ./index.test.ts; this file
3
- // drives the error, fallback, and combinatorial branches that the happy-path
4
- // suite does not exercise explicitly (network failure, non-JSON bodies,
5
- // response-shape fallbacks, and the env-var/group merge matrix), with a
6
- // strong focus on the T8 credential-leak guarantee on every error surface.
7
-
8
- import { afterEach, describe, expect, test } from "bun:test";
9
- import {
10
- RENDER_DEFAULT_API_BASE,
11
- RenderAdapterError,
12
- deployToRender,
13
- isRenderTargetShape,
14
- renderBlueprintFor,
15
- renderDockerfileFor,
16
- scrubApiKey,
17
- } from "./index";
18
-
19
- const API_KEY_ENV = "RENDER_API_KEY";
20
-
21
- /** Build a fetch test seam returning a fixed body/status. */
22
- function fakeFetch(body: string, init?: ResponseInit): typeof fetch {
23
- return (async () => new Response(body, init)) as unknown as typeof fetch;
24
- }
25
-
26
- describe("renderBlueprintFor — env var branches", () => {
27
- test("merges envVars and envVarsFromGroup under a single envVars block", () => {
28
- const yaml = renderBlueprintFor({
29
- target: "channel",
30
- serviceName: "svc",
31
- envVars: { FOO: "bar" },
32
- envVarsFromGroup: ["secrets-prod", "secrets-shared"],
33
- });
34
- // The `envVars:` header must appear exactly once (not re-emitted for the group).
35
- const headerCount = yaml.split("\n").filter((l) => l === " envVars:").length;
36
- expect(headerCount).toBe(1);
37
- expect(yaml).toContain(" - key: FOO");
38
- expect(yaml).toContain(' value: "bar"');
39
- expect(yaml).toContain(" - fromGroup: secrets-prod");
40
- expect(yaml).toContain(" - fromGroup: secrets-shared");
41
- });
42
-
43
- test("empty envVars object emits no envVars block", () => {
44
- const yaml = renderBlueprintFor({ target: "channel", serviceName: "svc", envVars: {} });
45
- expect(yaml).not.toContain("envVars:");
46
- });
47
-
48
- test("empty envVarsFromGroup array emits no envVars block", () => {
49
- const yaml = renderBlueprintFor({
50
- target: "channel",
51
- serviceName: "svc",
52
- envVarsFromGroup: [],
53
- });
54
- expect(yaml).not.toContain("envVars:");
55
- });
56
-
57
- test("skips env entries whose value is undefined", () => {
58
- // Defends the `if (value === undefined) continue` guard against callers
59
- // who pass a partial record at runtime despite the string-typed signature.
60
- const yaml = renderBlueprintFor({
61
- target: "channel",
62
- serviceName: "svc",
63
- envVars: { KEEP: "yes", DROP: undefined } as unknown as Record<string, string>,
64
- });
65
- expect(yaml).toContain(" - key: KEEP");
66
- expect(yaml).not.toContain("DROP");
67
- });
68
-
69
- test("env values are JSON-escaped (YAML-injection defense)", () => {
70
- const yaml = renderBlueprintFor({
71
- target: "channel",
72
- serviceName: "svc",
73
- envVars: { TOKEN: 'a"b\nc: pwned' },
74
- });
75
- // JSON.stringify keeps the value on one quoted line with escapes — the
76
- // newline and quote cannot break out into a new YAML node.
77
- expect(yaml).toContain(' value: "a\\"b\\nc: pwned"');
78
- expect(yaml).not.toContain("\nc: pwned");
79
- });
80
-
81
- test("explicit undefined imageTag falls through to build-from-Dockerfile", () => {
82
- const yaml = renderBlueprintFor({
83
- target: "channel",
84
- serviceName: "svc",
85
- imageTag: undefined,
86
- });
87
- expect(yaml).not.toContain("\n image:");
88
- });
89
-
90
- test("honors a custom dockerfilePath", () => {
91
- const yaml = renderBlueprintFor({
92
- target: "batch",
93
- serviceName: "svc",
94
- dockerfilePath: "./docker/Dockerfile.batch",
95
- });
96
- expect(yaml).toContain("dockerfilePath: ./docker/Dockerfile.batch");
97
- });
98
-
99
- test("accepts a 63-char service name but rejects a 64-char one", () => {
100
- const name63 = "a".repeat(63);
101
- const name64 = "a".repeat(64);
102
- expect(() => renderBlueprintFor({ target: "channel", serviceName: name63 })).not.toThrow();
103
- expect(() => renderBlueprintFor({ target: "channel", serviceName: name64 })).toThrow(
104
- RenderAdapterError,
105
- );
106
- });
107
- });
108
-
109
- describe("renderDockerfileFor — every web shape", () => {
110
- test("all web-typed shapes emit a HEALTHCHECK", () => {
111
- for (const shape of ["channel", "managed", "voice", "browser"] as const) {
112
- const df = renderDockerfileFor({ target: shape, baseImage: `crewhaus/${shape}:0.1.0` });
113
- expect(df).toContain("HEALTHCHECK");
114
- expect(df).toContain("http://localhost:$PORT/healthz");
115
- }
116
- });
117
-
118
- test("output ends with exactly one trailing newline", () => {
119
- const df = renderDockerfileFor({ target: "batch", baseImage: "crewhaus/batch:0.1.0" });
120
- expect(df.endsWith("\n")).toBe(true);
121
- expect(df.endsWith("\n\n")).toBe(false);
122
- });
123
- });
124
-
125
- describe("scrubApiKey — boundary lengths", () => {
126
- test("scrubs at exactly 8 chars (inclusive lower bound)", () => {
127
- expect(scrubApiKey("x 12345678 y", "12345678")).toBe("x [REDACTED:RENDER_API_KEY] y");
128
- });
129
-
130
- test("does not scrub at 7 chars (just below bound)", () => {
131
- expect(scrubApiKey("x 1234567 y", "1234567")).toBe("x 1234567 y");
132
- });
133
-
134
- test("empty-string key is treated as too short and left untouched", () => {
135
- expect(scrubApiKey("nothing to scrub", "")).toBe("nothing to scrub");
136
- });
137
- });
138
-
139
- describe("isRenderTargetShape — extra negatives", () => {
140
- test("rejects objects and arrays", () => {
141
- expect(isRenderTargetShape({})).toBe(false);
142
- expect(isRenderTargetShape([])).toBe(false);
143
- expect(isRenderTargetShape("")).toBe(false);
144
- });
145
- });
146
-
147
- describe("deployToRender — error & fallback branches", () => {
148
- const ORIGINAL_KEY = process.env[API_KEY_ENV];
149
- afterEach(() => {
150
- if (ORIGINAL_KEY === undefined) delete process.env[API_KEY_ENV];
151
- else process.env[API_KEY_ENV] = ORIGINAL_KEY;
152
- });
153
-
154
- test("wraps a thrown network error and scrubs the key from it", async () => {
155
- const key = "rnd_network_failure_key_long";
156
- const fetchImpl = (async () => {
157
- throw new Error(`connect ECONNREFUSED while using ${key}`);
158
- }) as unknown as typeof fetch;
159
- let caught: RenderAdapterError | undefined;
160
- try {
161
- await deployToRender({
162
- apiKey: key,
163
- blueprintYaml: "services: []\n",
164
- serviceName: "svc",
165
- fetchImpl,
166
- });
167
- } catch (err) {
168
- caught = err as RenderAdapterError;
169
- }
170
- expect(caught).toBeInstanceOf(RenderAdapterError);
171
- expect(caught?.message).toContain("Render API request failed");
172
- expect(caught?.message).not.toContain(key);
173
- expect(caught?.message).toContain("[REDACTED:RENDER_API_KEY]");
174
- });
175
-
176
- test("handles a non-Error thrown value from fetch (String(err) branch)", async () => {
177
- const fetchImpl = (async () => {
178
- // eslint-disable-next-line no-throw-literal
179
- throw "raw string failure";
180
- }) as unknown as typeof fetch;
181
- let caught: RenderAdapterError | undefined;
182
- try {
183
- await deployToRender({
184
- apiKey: "rnd_key_nonerror_throw",
185
- blueprintYaml: "x",
186
- serviceName: "svc",
187
- fetchImpl,
188
- });
189
- } catch (err) {
190
- caught = err as RenderAdapterError;
191
- }
192
- expect(caught).toBeInstanceOf(RenderAdapterError);
193
- expect(caught?.message).toContain("raw string failure");
194
- });
195
-
196
- test("throws scrubbed error on non-2xx response", async () => {
197
- const key = "rnd_status500_key_longenough";
198
- const fetchImpl = fakeFetch(`upstream blew up with ${key}`, {
199
- status: 500,
200
- statusText: "Internal Server Error",
201
- });
202
- let caught: RenderAdapterError | undefined;
203
- try {
204
- await deployToRender({
205
- apiKey: key,
206
- blueprintYaml: "x",
207
- serviceName: "svc",
208
- fetchImpl,
209
- });
210
- } catch (err) {
211
- caught = err as RenderAdapterError;
212
- }
213
- expect(caught?.message).toContain("Render API returned 500 Internal Server Error");
214
- expect(caught?.message).not.toContain(key);
215
- expect(caught?.message).toContain("[REDACTED:RENDER_API_KEY]");
216
- });
217
-
218
- test("throws scrubbed error when a 2xx body is not JSON", async () => {
219
- const key = "rnd_nonjson_body_key_longxx";
220
- const fetchImpl = fakeFetch(`<html>not json ${key}</html>`, { status: 200 });
221
- let caught: RenderAdapterError | undefined;
222
- try {
223
- await deployToRender({
224
- apiKey: key,
225
- blueprintYaml: "x",
226
- serviceName: "svc",
227
- fetchImpl,
228
- });
229
- } catch (err) {
230
- caught = err as RenderAdapterError;
231
- }
232
- expect(caught?.message).toContain("Render API returned non-JSON body");
233
- expect(caught?.message).not.toContain(key);
234
- expect(caught?.message).toContain("[REDACTED:RENDER_API_KEY]");
235
- });
236
-
237
- test("throws scrubbed error when the JSON body has no service id", async () => {
238
- const key = "rnd_missing_id_key_longenough";
239
- const fetchImpl = fakeFetch(JSON.stringify({ deploy: { id: "d-1" }, hint: key }), {
240
- status: 201,
241
- });
242
- let caught: RenderAdapterError | undefined;
243
- try {
244
- await deployToRender({
245
- apiKey: key,
246
- blueprintYaml: "x",
247
- serviceName: "svc",
248
- fetchImpl,
249
- });
250
- } catch (err) {
251
- caught = err as RenderAdapterError;
252
- }
253
- expect(caught?.message).toContain("missing service id");
254
- expect(caught?.message).not.toContain(key);
255
- expect(caught?.message).toContain("[REDACTED:RENDER_API_KEY]");
256
- });
257
-
258
- test("falls back to top-level `id` when `service.id` is absent", async () => {
259
- const fetchImpl = fakeFetch(
260
- JSON.stringify({ id: "srv-top-level", deploy: { id: "d-9", status: "live" } }),
261
- { status: 201 },
262
- );
263
- const result = await deployToRender({
264
- apiKey: "rnd_toplevel_id_key_long",
265
- blueprintYaml: "x",
266
- serviceName: "svc",
267
- fetchImpl,
268
- });
269
- expect(result.serviceId).toBe("srv-top-level");
270
- });
271
-
272
- test("applies default deployId/status/url when those fields are absent", async () => {
273
- const fetchImpl = fakeFetch(JSON.stringify({ service: { id: "srv-min" } }), { status: 201 });
274
- const result = await deployToRender({
275
- apiKey: "rnd_defaults_key_longenough",
276
- blueprintYaml: "x",
277
- serviceName: "svc",
278
- fetchImpl,
279
- });
280
- expect(result.serviceId).toBe("srv-min");
281
- expect(result.deployId).toBe("");
282
- expect(result.status).toBe("pending");
283
- expect(result.url).toBeUndefined();
284
- });
285
-
286
- test("surfaces serviceDetails.url when present", async () => {
287
- const fetchImpl = fakeFetch(
288
- JSON.stringify({
289
- service: { id: "srv-u" },
290
- deploy: { id: "d-u", status: "live" },
291
- serviceDetails: { url: "https://svc-u.onrender.com" },
292
- }),
293
- { status: 201 },
294
- );
295
- const result = await deployToRender({
296
- apiKey: "rnd_url_present_key_long",
297
- blueprintYaml: "x",
298
- serviceName: "svc",
299
- fetchImpl,
300
- });
301
- expect(result.url).toBe("https://svc-u.onrender.com");
302
- });
303
-
304
- test("honors a custom apiBaseUrl (VCR-style override)", async () => {
305
- let observedUrl: string | undefined;
306
- const fetchImpl = (async (url: string | URL) => {
307
- observedUrl = String(url);
308
- return new Response(
309
- JSON.stringify({ service: { id: "srv-vcr" }, deploy: { id: "d", status: "ok" } }),
310
- { status: 201 },
311
- );
312
- }) as unknown as typeof fetch;
313
- await deployToRender({
314
- apiKey: "rnd_custom_base_key_long",
315
- blueprintYaml: "x",
316
- serviceName: "svc",
317
- apiBaseUrl: "http://localhost:9999/mock",
318
- fetchImpl,
319
- });
320
- expect(observedUrl).toBe("http://localhost:9999/mock/services");
321
- expect(observedUrl).not.toBe(`${RENDER_DEFAULT_API_BASE}/services`);
322
- });
323
- });
package/src/index.test.ts DELETED
@@ -1,284 +0,0 @@
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 DELETED
@@ -1,298 +0,0 @@
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
- }