@intentius/chant-lexicon-render 0.124.31 → 0.124.33

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.
Files changed (49) hide show
  1. package/dist/catalog.js +221 -0
  2. package/dist/codegen/docs-cli.js +6 -0
  3. package/dist/codegen/docs.js +110 -0
  4. package/dist/codegen/generate-cli.js +26 -0
  5. package/dist/codegen/generate-lexicon.js +38 -0
  6. package/dist/codegen/generate-typescript.js +46 -0
  7. package/dist/codegen/generate.js +82 -0
  8. package/dist/codegen/naming.js +43 -0
  9. package/dist/codegen/package.js +27 -0
  10. package/dist/composites/render-deploy.js +71 -0
  11. package/dist/coverage.js +29 -0
  12. package/dist/describe-resources.js +325 -0
  13. package/dist/detect.js +27 -0
  14. package/dist/generated/index.js +40 -0
  15. package/dist/generated/runtime.js +4 -0
  16. package/dist/index.js +21 -0
  17. package/dist/integrity.json +2 -2
  18. package/dist/lint/audit-catalog.js +23 -0
  19. package/dist/lint/audit-lineage.js +8 -0
  20. package/dist/lint/post-synth/index.js +8 -0
  21. package/dist/lint/post-synth/ren010-native-runtime-requires-commands.js +43 -0
  22. package/dist/lint/post-synth/ren011-service-requires-source.js +45 -0
  23. package/dist/lint/post-synth/ren012-free-plan-cannot-scale.js +41 -0
  24. package/dist/lint/post-synth/render-helpers.js +34 -0
  25. package/dist/lint/rules/index.js +8 -0
  26. package/dist/lint/rules/no-secret-literals.js +68 -0
  27. package/dist/lint/rules/valid-cron-schedule.js +44 -0
  28. package/dist/lint/rules/valid-region.js +43 -0
  29. package/dist/lsp/completions.d.ts.map +1 -1
  30. package/dist/lsp/completions.js +20 -0
  31. package/dist/lsp/hover.d.ts.map +1 -1
  32. package/dist/lsp/hover.js +49 -0
  33. package/dist/manifest.json +1 -1
  34. package/dist/op/activities/fake-render.js +271 -0
  35. package/dist/op/activities/index.js +8 -0
  36. package/dist/op/activities/render-apply.js +873 -0
  37. package/dist/ownership.js +38 -0
  38. package/dist/package-cli.js +17 -0
  39. package/dist/plugin.js +229 -0
  40. package/dist/pseudo.js +23 -0
  41. package/dist/serializer.js +238 -0
  42. package/dist/spec/fetch.js +30 -0
  43. package/dist/spec/parse.js +600 -0
  44. package/dist/validate-cli.js +4 -0
  45. package/dist/validate.js +41 -0
  46. package/package.json +7 -7
  47. package/src/codegen/package.ts +2 -2
  48. package/src/lsp/completions.ts +2 -1
  49. package/src/lsp/hover.ts +2 -1
@@ -0,0 +1,873 @@
1
+ /**
2
+ * Render native applier.
3
+ *
4
+ * Peer of `flyApply`: a direct-REST applier that does its own diff, owned-only
5
+ * prune, and mutation waiting. Render has no server-side declarative apply
6
+ * (Blueprints are repo-driven, not API-driven), so this loop is hand-rolled —
7
+ * find-by-name, then create or PATCH per resource, resolve cross-resource ids
8
+ * as they become known, wait each service's deploy to `live`, and prune only
9
+ * what chant owns.
10
+ *
11
+ * Input is the serializer's output: a JSON object keyed by entity name, each
12
+ * value a single Render create request `{ kind, entityType, endpoint, method,
13
+ * name, body, pathParams? }`. Bodies may carry three markers the applier
14
+ * resolves at apply time:
15
+ * - `{ $ref: "<entity>" }` → the live id of a declared entity (must apply first)
16
+ * - `{ $attr: { entity, attribute } }` → an attribute of a live entity
17
+ * (`id`, `dashboardUrl`, or a datastore's connection string, read from the
18
+ * `/connection-info` side endpoint)
19
+ * - `{ $owner: true }` → the workspace id, when neither the author nor
20
+ * `RENDER_OWNER_ID` named one; resolved once from `GET /owners`
21
+ *
22
+ * Identity. Render assigns opaque ids on create; the *name* is the reconcile
23
+ * key. Services are unique-by-name per workspace (Render enforces it); for the
24
+ * other kinds chant treats the name as unique within its scope (owner, or the
25
+ * parent project/service) and takes the first exact match.
26
+ *
27
+ * Endpoint override via `RENDER_API_BASE_URL` (or an explicit `endpoint` arg)
28
+ * so the same code points at api.render.com or a local stand-in; auth is a
29
+ * bearer token from `RENDER_API_KEY` (or `token`).
30
+ */
31
+ import { readFileSync } from "node:fs";
32
+ import { sleep } from "@intentius/chant/op";
33
+ import { hasOwnershipMarker, readOwnership } from "@intentius/chant/ownership";
34
+ import { applyResult, } from "@intentius/chant/apply";
35
+ import { RENDER_ENV_OWNERSHIP_KEYS } from "../../ownership.js";
36
+ import { CATALOG, ENTITY_TYPES, ENTITY_TYPE_OF_SERVICE, catalogEntry, isServiceEntityType, } from "../../catalog.js";
37
+ import { isAttrMarker, isOwnerMarker, isRefMarker, } from "../../serializer.js";
38
+ /** Default API host when neither an `endpoint` arg nor `RENDER_API_BASE_URL` is set. */
39
+ export const DEFAULT_RENDER_BASE_URL = "https://api.render.com/v1";
40
+ /** Render's list page cap. */
41
+ const PAGE_LIMIT = 100;
42
+ /** Deploy states that mean the deploy is finished. */
43
+ const DEPLOY_LIVE = new Set(["live"]);
44
+ const DEPLOY_FAILED = new Set(["build_failed", "update_failed", "canceled", "pre_deploy_failed", "deactivated"]);
45
+ // ── Pure helpers (unit-testable without http) ─────────────────────────────────
46
+ /**
47
+ * Resolve the API base URL: an explicit `endpoint` arg wins, then
48
+ * `RENDER_API_BASE_URL`, then api.render.com. Trailing slash stripped. Pure.
49
+ */
50
+ export function resolveEndpoint(args = {}, env = process.env) {
51
+ const base = args.endpoint || env.RENDER_API_BASE_URL || DEFAULT_RENDER_BASE_URL;
52
+ return base.replace(/\/$/, "");
53
+ }
54
+ /** Parse the serializer's JSON output into a plan. Pure. */
55
+ export function parsePlan(content) {
56
+ return JSON.parse(content);
57
+ }
58
+ /** Collect the entity names a value references through `$ref`/`$attr` markers. Pure. */
59
+ export function collectDependencies(value, acc = new Set()) {
60
+ if (isRefMarker(value)) {
61
+ acc.add(value.$ref);
62
+ return acc;
63
+ }
64
+ if (isAttrMarker(value)) {
65
+ acc.add(value.$attr.entity);
66
+ return acc;
67
+ }
68
+ if (Array.isArray(value)) {
69
+ for (const v of value)
70
+ collectDependencies(v, acc);
71
+ }
72
+ else if (value && typeof value === "object") {
73
+ for (const v of Object.values(value))
74
+ collectDependencies(v, acc);
75
+ }
76
+ return acc;
77
+ }
78
+ /**
79
+ * Order the plan for apply: by catalog order (projects → environments →
80
+ * datastores/env groups → services → disks/domains → webhooks), and within
81
+ * that so every `$ref`/`$attr` target precedes its referrer. Throws on a cycle
82
+ * or a dangling reference — both are authoring errors worth naming. Pure.
83
+ */
84
+ export function orderPlan(plan) {
85
+ const entries = Object.entries(plan);
86
+ const byName = new Map(entries);
87
+ const deps = new Map();
88
+ for (const [name, req] of entries) {
89
+ const d = collectDependencies({ body: req.body, pathParams: req.pathParams });
90
+ for (const dep of d) {
91
+ if (!byName.has(dep)) {
92
+ throw new Error(`render plan entry "${name}" references "${dep}", which the plan does not declare`);
93
+ }
94
+ }
95
+ d.delete(name);
96
+ deps.set(name, d);
97
+ }
98
+ const rank = (name) => catalogEntry(byName.get(name).entityType).order;
99
+ const sortedNames = entries.map(([n]) => n).sort((a, b) => rank(a) - rank(b));
100
+ // Take the first ready entry in catalog order each time, so a resource is
101
+ // only pulled ahead of its rank when something before it still waits on a
102
+ // dependency (never behind a lower-ranked one that happens to be ready).
103
+ const out = [];
104
+ const done = new Set();
105
+ const remaining = [...sortedNames];
106
+ while (remaining.length > 0) {
107
+ const idx = remaining.findIndex((n) => [...deps.get(n)].every((d) => done.has(d)));
108
+ if (idx < 0) {
109
+ throw new Error(`render plan has a reference cycle among: ${remaining.join(", ")}`);
110
+ }
111
+ const [n] = remaining.splice(idx, 1);
112
+ out.push([n, byName.get(n)]);
113
+ done.add(n);
114
+ }
115
+ return out;
116
+ }
117
+ /** Structural equality over two values, key-order-insensitive. Pure. */
118
+ export function configEqual(a, b) {
119
+ return canonical(a) === canonical(b);
120
+ }
121
+ function canonical(v) {
122
+ return JSON.stringify(sortKeys(v));
123
+ }
124
+ function sortKeys(v) {
125
+ if (Array.isArray(v))
126
+ return v.map(sortKeys);
127
+ if (v && typeof v === "object") {
128
+ const out = {};
129
+ for (const k of Object.keys(v).sort()) {
130
+ out[k] = sortKeys(v[k]);
131
+ }
132
+ return out;
133
+ }
134
+ return v;
135
+ }
136
+ /**
137
+ * The PATCH body for a live resource: the declared values of the catalog's
138
+ * patchable fields that differ from what is live. Only fields the author set
139
+ * are compared — an omitted field is "leave as is", never "reset to default".
140
+ * Returns `undefined` when nothing differs. Pure.
141
+ */
142
+ export function diffForPatch(entry, desired, live) {
143
+ const patch = {};
144
+ for (const field of entry.patchFields) {
145
+ if (!(field in desired) || desired[field] === undefined)
146
+ continue;
147
+ const want = desired[field];
148
+ const have = live[field];
149
+ if (want && typeof want === "object" && !Array.isArray(want) && have && typeof have === "object") {
150
+ // Nested objects (serviceDetails, image): compare only the declared keys.
151
+ const sub = subsetDiff(want, have);
152
+ if (sub !== undefined)
153
+ patch[field] = want;
154
+ }
155
+ else if (!configEqual(want, have)) {
156
+ patch[field] = want;
157
+ }
158
+ }
159
+ return Object.keys(patch).length > 0 ? patch : undefined;
160
+ }
161
+ /** Recursive "declared keys differ" check; undefined when the declared subset matches. */
162
+ function subsetDiff(want, have) {
163
+ for (const [k, v] of Object.entries(want)) {
164
+ if (v === undefined)
165
+ continue;
166
+ const h = have[k];
167
+ if (v && typeof v === "object" && !Array.isArray(v) && h && typeof h === "object" && !Array.isArray(h)) {
168
+ if (subsetDiff(v, h))
169
+ return true;
170
+ }
171
+ else if (!configEqual(v, h)) {
172
+ return true;
173
+ }
174
+ }
175
+ return undefined;
176
+ }
177
+ /** Live env-var list (`[{ envVar: {key, value}, cursor }]` or bare) → key/value map. Pure. */
178
+ export function envVarsToMap(list) {
179
+ const out = {};
180
+ if (!Array.isArray(list))
181
+ return out;
182
+ for (const item of list) {
183
+ const ev = item && typeof item === "object" && "envVar" in item ? item.envVar : item;
184
+ if (ev && typeof ev === "object") {
185
+ const { key, value } = ev;
186
+ if (typeof key === "string")
187
+ out[key] = typeof value === "string" ? value : "";
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+ /** Declared env-var list (`[{key, value} | {key, generateValue}]`) → key/value map (generated values as `undefined`). Pure. */
193
+ export function declaredEnvVarsToMap(list) {
194
+ const out = {};
195
+ if (!Array.isArray(list))
196
+ return out;
197
+ for (const item of list) {
198
+ if (item && typeof item === "object") {
199
+ const { key, value } = item;
200
+ if (typeof key === "string")
201
+ out[key] = typeof value === "string" ? value : undefined;
202
+ }
203
+ }
204
+ return out;
205
+ }
206
+ /**
207
+ * True when a live env-var map carries chant's ownership marker. Reads the key
208
+ * convention the render lexicon declares (`RENDER_ENV_OWNERSHIP_KEYS`) through
209
+ * core's `hasOwnershipMarker`, so the prune filter and the serializer's stamp
210
+ * can never drift apart. Pure.
211
+ */
212
+ export function isChantOwned(envVars) {
213
+ return hasOwnershipMarker(envVars ?? undefined, RENDER_ENV_OWNERSHIP_KEYS);
214
+ }
215
+ /** The stack/env identity a live env-var map carries, if marked. Pure. */
216
+ export function ownershipOf(envVars) {
217
+ return readOwnership(envVars ?? undefined, RENDER_ENV_OWNERSHIP_KEYS);
218
+ }
219
+ /**
220
+ * Whether a declared env-var set and a live one differ. Generated values
221
+ * (`generateValue: true`) match any live value — Render made it up once and
222
+ * chant must not regenerate it every run. Pure.
223
+ */
224
+ export function envVarsDiffer(declared, live) {
225
+ const want = declaredEnvVarsToMap(declared);
226
+ const wantKeys = Object.keys(want).sort();
227
+ const haveKeys = Object.keys(live).sort();
228
+ if (wantKeys.join("") !== haveKeys.join(""))
229
+ return true;
230
+ for (const k of wantKeys) {
231
+ if (want[k] !== undefined && want[k] !== live[k])
232
+ return true;
233
+ }
234
+ return false;
235
+ }
236
+ /** The plan's ownership marker (from the first marked body), for scoping prune. Pure. */
237
+ export function planOwnership(plan) {
238
+ for (const req of Object.values(plan)) {
239
+ const entry = CATALOG[req.entityType];
240
+ if (!entry?.marked)
241
+ continue;
242
+ const map = declaredEnvVarsToMap(req.body.envVars);
243
+ const managed = map[RENDER_ENV_OWNERSHIP_KEYS.managedBy];
244
+ if (managed !== "chant")
245
+ continue;
246
+ return {
247
+ stack: map[RENDER_ENV_OWNERSHIP_KEYS.stack] ?? "",
248
+ env: map[RENDER_ENV_OWNERSHIP_KEYS.env],
249
+ };
250
+ }
251
+ return undefined;
252
+ }
253
+ /**
254
+ * Whether a live marked resource belongs to this plan's stack: same stack
255
+ * (and env, when the plan names one). A plan with no stack identity (bare
256
+ * `managed-by` only) owns every chant-marked resource in the workspace, which
257
+ * is the pre-#119 single-stack behaviour and is what an unconfigured project
258
+ * gets. Pure.
259
+ */
260
+ export function inStack(live, plan) {
261
+ if (!live)
262
+ return false;
263
+ if (!plan || !plan.stack)
264
+ return true;
265
+ if (live.stack !== plan.stack)
266
+ return false;
267
+ if (plan.env && live.env !== plan.env)
268
+ return false;
269
+ return true;
270
+ }
271
+ /**
272
+ * Default `fetch`-based client. Sends `Authorization: Bearer <token>`; the
273
+ * token defaults to `RENDER_API_KEY` at call time. Retries 429 (Render rate
274
+ * limits at ~ 400 req/min) honouring `retry-after`, capped.
275
+ */
276
+ export function defaultRenderHttp(token) {
277
+ return async (method, url, body, headers, signal) => {
278
+ const h = { accept: "application/json", ...headers };
279
+ if (body !== undefined)
280
+ h["content-type"] = "application/json";
281
+ const tok = token ?? process.env.RENDER_API_KEY;
282
+ if (tok)
283
+ h["authorization"] = `Bearer ${tok}`;
284
+ for (let attempt = 0;; attempt++) {
285
+ const res = await fetch(url, {
286
+ method,
287
+ headers: h,
288
+ body: body === undefined ? undefined : JSON.stringify(body),
289
+ signal,
290
+ });
291
+ if (res.status === 429 && attempt < 5) {
292
+ const ra = Number(res.headers.get("retry-after"));
293
+ await sleep(Number.isFinite(ra) && ra > 0 ? ra * 1000 : 2000 * (attempt + 1), signal);
294
+ continue;
295
+ }
296
+ return { status: res.status, text: await res.text() };
297
+ }
298
+ };
299
+ }
300
+ function parseJson(text) {
301
+ if (!text)
302
+ return undefined;
303
+ try {
304
+ return JSON.parse(text);
305
+ }
306
+ catch {
307
+ return undefined;
308
+ }
309
+ }
310
+ function fail(what, status, text) {
311
+ throw new Error(`render: ${what} failed (HTTP ${status}): ${text.slice(0, 500)}`);
312
+ }
313
+ // ── Reads ─────────────────────────────────────────────────────────────────────
314
+ /** Unwrap a list envelope element (`{ service: {...}, cursor }`) to the resource. */
315
+ function unwrap(item, listKey) {
316
+ if (!item || typeof item !== "object")
317
+ return undefined;
318
+ if (listKey && listKey in item) {
319
+ const inner = item[listKey];
320
+ return inner && typeof inner === "object" ? inner : undefined;
321
+ }
322
+ return item;
323
+ }
324
+ /** GET every page of a list endpoint, following the cursor. */
325
+ export async function listAll(ctx, path, query, listKey, http, signal) {
326
+ const out = [];
327
+ let cursor;
328
+ for (let page = 0; page < 50; page++) {
329
+ const params = new URLSearchParams();
330
+ for (const [k, v] of Object.entries(query))
331
+ if (v !== undefined && v !== "")
332
+ params.set(k, v);
333
+ params.set("limit", String(PAGE_LIMIT));
334
+ if (cursor)
335
+ params.set("cursor", cursor);
336
+ const res = await http("GET", `${ctx.base}${path}?${params.toString()}`, undefined, undefined, signal);
337
+ if (res.status === 404 && page === 0)
338
+ return out;
339
+ if (res.status < 200 || res.status >= 300)
340
+ fail(`GET ${path}`, res.status, res.text);
341
+ const items = parseJson(res.text);
342
+ if (!Array.isArray(items) || items.length === 0)
343
+ break;
344
+ for (const item of items) {
345
+ const r = unwrap(item, listKey);
346
+ if (r)
347
+ out.push(r);
348
+ }
349
+ const last = items[items.length - 1];
350
+ cursor = typeof last?.cursor === "string" ? last.cursor : undefined;
351
+ if (items.length < PAGE_LIMIT || !cursor)
352
+ break;
353
+ }
354
+ return out;
355
+ }
356
+ /** GET one resource by id. */
357
+ export async function getOne(ctx, path, http, signal) {
358
+ const res = await http("GET", `${ctx.base}${path}`, undefined, undefined, signal);
359
+ if (res.status === 404)
360
+ return undefined;
361
+ if (res.status < 200 || res.status >= 300)
362
+ fail(`GET ${path}`, res.status, res.text);
363
+ const v = parseJson(res.text);
364
+ return v && typeof v === "object" ? v : undefined;
365
+ }
366
+ /**
367
+ * Resolve the workspace id: an explicit arg, then `RENDER_OWNER_ID`, then the
368
+ * sole owner the token can see. Throws when the token sees several — picking
369
+ * one silently would deploy into the wrong team.
370
+ */
371
+ export async function resolveOwner(ctx, args, http, signal, env = process.env) {
372
+ if (ctx.ownerId)
373
+ return ctx.ownerId;
374
+ const explicit = args.ownerId || env.RENDER_OWNER_ID;
375
+ if (explicit) {
376
+ ctx.ownerId = explicit;
377
+ return explicit;
378
+ }
379
+ const owners = await listAll(ctx, "/owners", {}, "owner", http, signal);
380
+ const ids = owners.map((o) => o.id).filter((id) => typeof id === "string");
381
+ if (ids.length === 1) {
382
+ ctx.ownerId = ids[0];
383
+ return ids[0];
384
+ }
385
+ if (ids.length === 0)
386
+ throw new Error("render: the API key sees no workspaces — set RENDER_OWNER_ID");
387
+ throw new Error(`render: the API key sees ${ids.length} workspaces (${ids.join(", ")}) — set RENDER_OWNER_ID or pass ownerId to choose one`);
388
+ }
389
+ /**
390
+ * Find the live resource a request declares, by name within its scope.
391
+ * Services also match on `type`; environments on `projectId`; disks on
392
+ * `serviceId`; custom domains list under their service.
393
+ */
394
+ export async function findExisting(ctx, req, entry, endpoint, http, signal) {
395
+ const query = {};
396
+ if (entry.filters.name)
397
+ query.name = req.name;
398
+ if (entry.filters.ownerId)
399
+ query.ownerId = typeof req.body.ownerId === "string" ? req.body.ownerId : ctx.ownerId;
400
+ if (req.entityType === ENTITY_TYPES.environment && typeof req.body.projectId === "string") {
401
+ query.projectId = req.body.projectId;
402
+ }
403
+ if (req.entityType === ENTITY_TYPES.disk && typeof req.body.serviceId === "string") {
404
+ query.serviceId = req.body.serviceId;
405
+ }
406
+ const listPath = req.entityType === ENTITY_TYPES.customDomain ? endpoint : entry.collection;
407
+ const candidates = await listAll(ctx, listPath, query, entry.listKey, http, signal);
408
+ return candidates.find((c) => {
409
+ if (c.name !== req.name)
410
+ return false;
411
+ if (isServiceEntityType(req.entityType) && c.type !== req.body.type)
412
+ return false;
413
+ if (req.entityType === ENTITY_TYPES.environment && typeof req.body.projectId === "string" && c.projectId !== req.body.projectId)
414
+ return false;
415
+ if (req.entityType === ENTITY_TYPES.disk && typeof req.body.serviceId === "string" && c.serviceId !== req.body.serviceId)
416
+ return false;
417
+ return true;
418
+ });
419
+ }
420
+ /** Datastore attributes served by the `/connection-info` side endpoint. */
421
+ const CONNECTION_INFO_ATTRS = new Set([
422
+ "internalConnectionString",
423
+ "externalConnectionString",
424
+ "psqlCommand",
425
+ "cliCommand",
426
+ "internalConnectionPoolString",
427
+ "externalConnectionPoolString",
428
+ "password",
429
+ ]);
430
+ /**
431
+ * Resolve an attribute of a live entity: `id`, any field of the live resource,
432
+ * or a datastore connection-info field (fetched once per entity).
433
+ */
434
+ export async function resolveAttribute(ctx, live, attribute, http, cache, signal) {
435
+ if (attribute === "id")
436
+ return live.id;
437
+ if (attribute in live.resource)
438
+ return live.resource[attribute];
439
+ if (CONNECTION_INFO_ATTRS.has(attribute)) {
440
+ const entry = catalogEntry(live.entityType);
441
+ const key = `${entry.collection}/${live.id}`;
442
+ let info = cache.get(key);
443
+ if (!info) {
444
+ info = (await getOne(ctx, `${entry.collection}/${live.id}/connection-info`, http, signal)) ?? {};
445
+ cache.set(key, info);
446
+ }
447
+ if (attribute in info)
448
+ return info[attribute];
449
+ }
450
+ throw new Error(`render: attribute "${attribute}" is not available on live ${live.entityType} ${live.id}`);
451
+ }
452
+ /** Replace every marker in a value with its live resolution. */
453
+ export async function resolveMarkers(ctx, value, lives, http, cache, ownerId, signal) {
454
+ if (isOwnerMarker(value))
455
+ return ownerId();
456
+ if (isRefMarker(value)) {
457
+ const live = lives.get(value.$ref);
458
+ if (!live)
459
+ throw new Error(`render: reference to "${value.$ref}" cannot be resolved — it has not been applied`);
460
+ return live.id;
461
+ }
462
+ if (isAttrMarker(value)) {
463
+ const live = lives.get(value.$attr.entity);
464
+ if (!live)
465
+ throw new Error(`render: attribute read on "${value.$attr.entity}" cannot be resolved — it has not been applied`);
466
+ return resolveAttribute(ctx, live, value.$attr.attribute, http, cache, signal);
467
+ }
468
+ if (Array.isArray(value)) {
469
+ const out = [];
470
+ for (const v of value)
471
+ out.push(await resolveMarkers(ctx, v, lives, http, cache, ownerId, signal));
472
+ return out;
473
+ }
474
+ if (value && typeof value === "object") {
475
+ const out = {};
476
+ for (const [k, v] of Object.entries(value)) {
477
+ out[k] = await resolveMarkers(ctx, v, lives, http, cache, ownerId, signal);
478
+ }
479
+ return out;
480
+ }
481
+ return value;
482
+ }
483
+ // ── Mutations ─────────────────────────────────────────────────────────────────
484
+ /** POST a create body; returns the created resource (services: unwrapped from `{ service, deployId }`). */
485
+ export async function create(ctx, endpoint, body, http, signal) {
486
+ const res = await http("POST", `${ctx.base}${endpoint}`, body, undefined, signal);
487
+ if (res.status < 200 || res.status >= 300)
488
+ fail(`POST ${endpoint}`, res.status, res.text);
489
+ const parsed = parseJson(res.text);
490
+ // Custom domains answer with an array of the domains created.
491
+ const first = Array.isArray(parsed) ? parsed[0] : parsed;
492
+ if (!first || typeof first !== "object")
493
+ return { resource: {} };
494
+ const rec = first;
495
+ if (rec.service && typeof rec.service === "object") {
496
+ return { resource: rec.service, deployId: typeof rec.deployId === "string" ? rec.deployId : undefined };
497
+ }
498
+ return { resource: rec };
499
+ }
500
+ /** PATCH a live resource; returns the updated resource. */
501
+ export async function patch(ctx, path, body, http, signal) {
502
+ const res = await http("PATCH", `${ctx.base}${path}`, body, undefined, signal);
503
+ if (res.status < 200 || res.status >= 300)
504
+ fail(`PATCH ${path}`, res.status, res.text);
505
+ const v = parseJson(res.text);
506
+ return v && typeof v === "object" ? v : {};
507
+ }
508
+ /** DELETE a live resource; false when it was already gone. */
509
+ export async function remove(ctx, path, http, signal) {
510
+ const res = await http("DELETE", `${ctx.base}${path}`, undefined, undefined, signal);
511
+ if (res.status === 404)
512
+ return false;
513
+ if (res.status < 200 || res.status >= 300)
514
+ fail(`DELETE ${path}`, res.status, res.text);
515
+ return true;
516
+ }
517
+ /** Live env vars of a service (`GET /services/{id}/env-vars`), as a map. */
518
+ export async function readServiceEnvVars(ctx, serviceId, http, signal) {
519
+ const items = await listAll(ctx, `/services/${serviceId}/env-vars`, {}, "envVar", http, signal);
520
+ return envVarsToMap(items);
521
+ }
522
+ /** Replace a service's env vars wholesale (`PUT /services/{id}/env-vars`). */
523
+ export async function putServiceEnvVars(ctx, serviceId, envVars, http, signal) {
524
+ const res = await http("PUT", `${ctx.base}/services/${serviceId}/env-vars`, envVars, undefined, signal);
525
+ if (res.status < 200 || res.status >= 300)
526
+ fail(`PUT /services/${serviceId}/env-vars`, res.status, res.text);
527
+ }
528
+ /**
529
+ * Reconcile an env group's env vars: Render's group PATCH takes only `name`,
530
+ * so each var is PUT by key and removed keys are DELETEd. Returns whether
531
+ * anything changed.
532
+ */
533
+ export async function reconcileEnvGroupVars(ctx, groupId, declared, live, http, signal) {
534
+ const want = declaredEnvVarsToMap(declared);
535
+ let changed = false;
536
+ for (const [key, value] of Object.entries(want)) {
537
+ if (value === undefined) {
538
+ if (key in live)
539
+ continue; // generated once; keep
540
+ const res = await http("PUT", `${ctx.base}/env-groups/${groupId}/env-vars/${encodeURIComponent(key)}`, { generateValue: true }, undefined, signal);
541
+ if (res.status < 200 || res.status >= 300)
542
+ fail(`PUT env-group var ${key}`, res.status, res.text);
543
+ changed = true;
544
+ continue;
545
+ }
546
+ if (live[key] === value)
547
+ continue;
548
+ const res = await http("PUT", `${ctx.base}/env-groups/${groupId}/env-vars/${encodeURIComponent(key)}`, { value }, undefined, signal);
549
+ if (res.status < 200 || res.status >= 300)
550
+ fail(`PUT env-group var ${key}`, res.status, res.text);
551
+ changed = true;
552
+ }
553
+ for (const key of Object.keys(live)) {
554
+ if (key in want)
555
+ continue;
556
+ const res = await http("DELETE", `${ctx.base}/env-groups/${groupId}/env-vars/${encodeURIComponent(key)}`, undefined, undefined, signal);
557
+ if (res.status !== 404 && (res.status < 200 || res.status >= 300))
558
+ fail(`DELETE env-group var ${key}`, res.status, res.text);
559
+ changed = true;
560
+ }
561
+ return changed;
562
+ }
563
+ /** Link the declared services to an env group (`POST /env-groups/{id}/services/{serviceId}`). */
564
+ export async function linkEnvGroupServices(ctx, groupId, serviceIds, live, http, signal) {
565
+ const linked = new Set();
566
+ const links = live.serviceLinks;
567
+ if (Array.isArray(links)) {
568
+ for (const l of links) {
569
+ const id = l?.id;
570
+ if (typeof id === "string")
571
+ linked.add(id);
572
+ }
573
+ }
574
+ let changed = false;
575
+ for (const sid of serviceIds) {
576
+ if (linked.has(sid))
577
+ continue;
578
+ const res = await http("POST", `${ctx.base}/env-groups/${groupId}/services/${sid}`, undefined, undefined, signal);
579
+ if (res.status < 200 || res.status >= 300)
580
+ fail(`POST env-group link ${sid}`, res.status, res.text);
581
+ changed = true;
582
+ }
583
+ return changed;
584
+ }
585
+ /** The latest deploy of a service, if any. */
586
+ export async function latestDeploy(ctx, serviceId, http, signal) {
587
+ const res = await http("GET", `${ctx.base}/services/${serviceId}/deploys?limit=1`, undefined, undefined, signal);
588
+ if (res.status < 200 || res.status >= 300)
589
+ fail(`GET deploys ${serviceId}`, res.status, res.text);
590
+ const items = parseJson(res.text);
591
+ if (!Array.isArray(items) || items.length === 0)
592
+ return undefined;
593
+ const d = unwrap(items[0], "deploy");
594
+ if (!d || typeof d.id !== "string")
595
+ return undefined;
596
+ return { id: d.id, status: typeof d.status === "string" ? d.status : "" };
597
+ }
598
+ /**
599
+ * Poll a service's deploy until it is `live`. Throws on a failed/canceled
600
+ * deploy or when the deadline passes. When `deployId` is unknown, the latest
601
+ * deploy is watched.
602
+ */
603
+ export async function waitForDeploy(ctx, serviceId, deployId, opts, http, signal, now = Date.now) {
604
+ const interval = opts.intervalMs ?? 5000;
605
+ const deadline = now() + (opts.deadlineMs ?? 15 * 60 * 1000);
606
+ for (;;) {
607
+ let status;
608
+ let id = deployId;
609
+ if (id) {
610
+ const d = await getOne(ctx, `/services/${serviceId}/deploys/${id}`, http, signal);
611
+ status = typeof d?.status === "string" ? d.status : "";
612
+ }
613
+ else {
614
+ const d = await latestDeploy(ctx, serviceId, http, signal);
615
+ if (!d)
616
+ return "no-deploy";
617
+ id = d.id;
618
+ status = d.status;
619
+ }
620
+ if (DEPLOY_LIVE.has(status))
621
+ return status;
622
+ if (DEPLOY_FAILED.has(status)) {
623
+ throw new Error(`render: deploy ${id} of service ${serviceId} ended ${status}`);
624
+ }
625
+ if (now() >= deadline) {
626
+ throw new Error(`render: deploy ${id} of service ${serviceId} still ${status || "pending"} after deadline`);
627
+ }
628
+ await sleep(interval, signal);
629
+ }
630
+ }
631
+ /** Map an outcome onto core's versioned apply envelope. Pure. */
632
+ export function toApplyResult(outcome) {
633
+ const applied = outcome.applied.map((a) => ({
634
+ kind: a.kind,
635
+ name: a.name,
636
+ action: a.action,
637
+ ...(a.id ? { physicalId: a.id } : {}),
638
+ }));
639
+ const pruned = outcome.pruned.map((p) => ({ kind: p.kind, name: p.name, deleted: p.deleted }));
640
+ return applyResult(applied, pruned, outcome.notAttempted);
641
+ }
642
+ /**
643
+ * Apply a serialized Render plan: create or update every declared resource in
644
+ * dependency order, resolving `$ref`/`$attr`/`$owner` markers as their targets
645
+ * go live; wait each created service's deploy to `live`; prune owned orphans
646
+ * when asked. Returns the versioned {@link ApplyResult}.
647
+ */
648
+ export async function renderApply(args, signal, http = defaultRenderHttp(args.token)) {
649
+ const outcome = await renderApplyDetailed(args, signal, http);
650
+ return toApplyResult(outcome);
651
+ }
652
+ export async function renderApplyDetailed(args, signal, http = defaultRenderHttp(args.token)) {
653
+ const plan = parsePlan(readFileSync(args.planPath, "utf8"));
654
+ const ctx = { base: resolveEndpoint(args) };
655
+ const wait = args.wait ?? {};
656
+ const ordered = orderPlan(plan);
657
+ const lives = new Map();
658
+ const infoCache = new Map();
659
+ const owner = () => resolveOwner(ctx, args, http, signal);
660
+ const outcome = { applied: [], pruned: [], notAttempted: [] };
661
+ for (const [entityName, req] of ordered) {
662
+ const entry = catalogEntry(req.entityType);
663
+ // Resolve markers now that every dependency is live.
664
+ const body = (await resolveMarkers(ctx, req.body, lives, http, infoCache, owner, signal));
665
+ const pathParams = (await resolveMarkers(ctx, req.pathParams ?? {}, lives, http, infoCache, owner, signal));
666
+ let endpoint = req.endpoint;
667
+ for (const [k, v] of Object.entries(pathParams)) {
668
+ if (typeof v === "string")
669
+ endpoint = endpoint.replace(`{${k}}`, encodeURIComponent(v));
670
+ }
671
+ if (entry.filters.ownerId && typeof body.ownerId !== "string") {
672
+ // A list filter needs the owner even when the body does not carry it.
673
+ ctx.ownerId = ctx.ownerId ?? (await owner());
674
+ }
675
+ const existing = await findExisting(ctx, req, entry, endpoint, http, signal);
676
+ if (!existing) {
677
+ const { resource, deployId } = await create(ctx, endpoint, body, http, signal);
678
+ const id = typeof resource.id === "string" ? resource.id : "";
679
+ lives.set(entityName, { id, entityType: req.entityType, resource });
680
+ console.log(`created: ${entry.kind}/${req.name}${id ? ` (${id})` : ""}`);
681
+ // Env-group service links are not part of the create body.
682
+ if (req.entityType === ENTITY_TYPES.envGroup && Array.isArray(body.serviceIds) && id) {
683
+ await linkEnvGroupServices(ctx, id, body.serviceIds.filter((s) => typeof s === "string"), resource, http, signal);
684
+ }
685
+ if (isServiceEntityType(req.entityType) && id && wait.deploys !== false) {
686
+ await waitForDeploy(ctx, id, deployId, wait, http, signal);
687
+ }
688
+ outcome.applied.push({ kind: entry.kind, name: req.name, action: "created", id, entity: entityName });
689
+ continue;
690
+ }
691
+ const id = typeof existing.id === "string" ? existing.id : "";
692
+ lives.set(entityName, { id, entityType: req.entityType, resource: existing });
693
+ let changed = false;
694
+ // Body PATCH — the catalog's patchable fields that differ.
695
+ const diff = entry.patchable ? diffForPatch(entry, body, existing) : undefined;
696
+ if (diff && id) {
697
+ const updated = await patch(ctx, `${entry.collection}/${id}`, diff, http, signal);
698
+ lives.set(entityName, { id, entityType: req.entityType, resource: { ...existing, ...updated } });
699
+ changed = true;
700
+ }
701
+ // Env vars: services replace wholesale via PUT; env groups per key.
702
+ if (entry.marked && id) {
703
+ if (isServiceEntityType(req.entityType)) {
704
+ const live = await readServiceEnvVars(ctx, id, http, signal);
705
+ if (envVarsDiffer(body.envVars, live)) {
706
+ // Keep live values for generated keys so Render's random values survive.
707
+ const desired = (Array.isArray(body.envVars) ? body.envVars : []).map((e) => {
708
+ const item = e;
709
+ if (item.generateValue && item.key in live)
710
+ return { key: item.key, value: live[item.key] };
711
+ return item;
712
+ });
713
+ await putServiceEnvVars(ctx, id, desired, http, signal);
714
+ changed = true;
715
+ }
716
+ }
717
+ else if (req.entityType === ENTITY_TYPES.envGroup) {
718
+ const live = envVarsToMap(existing.envVars);
719
+ if (await reconcileEnvGroupVars(ctx, id, body.envVars, live, http, signal))
720
+ changed = true;
721
+ if (Array.isArray(body.serviceIds)) {
722
+ const linked = await linkEnvGroupServices(ctx, id, body.serviceIds.filter((s) => typeof s === "string"), existing, http, signal);
723
+ if (linked)
724
+ changed = true;
725
+ }
726
+ }
727
+ }
728
+ console.log(`${changed ? "updated" : "unchanged"}: ${entry.kind}/${req.name} (${id})`);
729
+ outcome.applied.push({ kind: entry.kind, name: req.name, action: changed ? "updated" : "unchanged", id, entity: entityName });
730
+ }
731
+ // Prune: owned services and env groups (marked, this stack) not in the plan,
732
+ // and — at the service boundary — the disks and custom domains under an
733
+ // owned service that the plan does not declare.
734
+ if (args.prune) {
735
+ const ownerId = await owner();
736
+ const marker = planOwnership(plan);
737
+ const declaredServices = new Set(ordered.filter(([, r]) => isServiceEntityType(r.entityType)).map(([, r]) => `${r.body.type}:${r.name}`));
738
+ const declaredGroups = new Set(ordered.filter(([, r]) => r.entityType === ENTITY_TYPES.envGroup).map(([, r]) => r.name));
739
+ // Declared children by `${serviceId}:${name}`, with the parent's live id.
740
+ const declaredChildren = new Set();
741
+ for (const [, r] of ordered) {
742
+ const entry = catalogEntry(r.entityType);
743
+ if (entry.boundary !== "service")
744
+ continue;
745
+ const raw = r.entityType === ENTITY_TYPES.customDomain ? r.pathParams?.serviceId : r.body.serviceId;
746
+ const parentId = isRefMarker(raw) ? lives.get(raw.$ref)?.id : typeof raw === "string" ? raw : undefined;
747
+ if (parentId)
748
+ declaredChildren.add(`${parentId}:${r.name}`);
749
+ }
750
+ // Owned services first (declared or not): their undeclared children go,
751
+ // then the undeclared services themselves.
752
+ const staleServices = [];
753
+ for (const svc of await listAll(ctx, "/services", { ownerId }, "service", http, signal)) {
754
+ const id = typeof svc.id === "string" ? svc.id : undefined;
755
+ const name = typeof svc.name === "string" ? svc.name : "";
756
+ const type = typeof svc.type === "string" ? svc.type : "";
757
+ if (!id)
758
+ continue;
759
+ const env = await readServiceEnvVars(ctx, id, http, signal);
760
+ if (!isChantOwned(env) || !inStack(ownershipOf(env), marker))
761
+ continue;
762
+ const declared = declaredServices.has(`${type}:${name}`);
763
+ if (!declared)
764
+ staleServices.push({ id, name, type });
765
+ // Service boundary: disks and custom domains under this owned service.
766
+ // A stale service takes its children with it, so only a declared parent
767
+ // needs its children reconciled one by one.
768
+ if (!declared)
769
+ continue;
770
+ for (const disk of await listAll(ctx, "/disks", { serviceId: id, ownerId }, "disk", http, signal)) {
771
+ const diskId = typeof disk.id === "string" ? disk.id : undefined;
772
+ const diskName = typeof disk.name === "string" ? disk.name : "";
773
+ if (!diskId || disk.serviceId !== id || declaredChildren.has(`${id}:${diskName}`))
774
+ continue;
775
+ const deleted = await remove(ctx, `/disks/${diskId}`, http, signal);
776
+ console.log(`pruned: Disk/${diskName} (${diskId}) under ${type}/${name}`);
777
+ outcome.pruned.push({ kind: "Disk", name: diskName, id: diskId, deleted });
778
+ }
779
+ for (const domain of await listAll(ctx, `/services/${id}/custom-domains`, {}, "customDomain", http, signal)) {
780
+ const domainId = typeof domain.id === "string" ? domain.id : undefined;
781
+ const domainName = typeof domain.name === "string" ? domain.name : "";
782
+ if (!domainId || declaredChildren.has(`${id}:${domainName}`))
783
+ continue;
784
+ const deleted = await remove(ctx, `/services/${id}/custom-domains/${encodeURIComponent(domainId)}`, http, signal);
785
+ console.log(`pruned: CustomDomain/${domainName} (${domainId}) under ${type}/${name}`);
786
+ outcome.pruned.push({ kind: "CustomDomain", name: domainName, id: domainId, deleted });
787
+ }
788
+ }
789
+ for (const { id, name, type } of staleServices) {
790
+ const kind = catalogEntry(ENTITY_TYPE_OF_SERVICE[type] ?? ENTITY_TYPES.webService).kind;
791
+ const deleted = await remove(ctx, `/services/${id}`, http, signal);
792
+ console.log(`pruned: ${kind}/${name} (${id})`);
793
+ outcome.pruned.push({ kind, name, id, deleted });
794
+ }
795
+ for (const grp of await listAll(ctx, "/env-groups", { ownerId }, "envGroup", http, signal)) {
796
+ const id = typeof grp.id === "string" ? grp.id : undefined;
797
+ const name = typeof grp.name === "string" ? grp.name : "";
798
+ if (!id || declaredGroups.has(name))
799
+ continue;
800
+ const full = (await getOne(ctx, `/env-groups/${id}`, http, signal)) ?? grp;
801
+ const env = envVarsToMap(full.envVars);
802
+ if (!isChantOwned(env) || !inStack(ownershipOf(env), marker))
803
+ continue;
804
+ const deleted = await remove(ctx, `/env-groups/${id}`, http, signal);
805
+ console.log(`pruned: EnvGroup/${name} (${id})`);
806
+ outcome.pruned.push({ kind: "EnvGroup", name, id, deleted });
807
+ }
808
+ }
809
+ return outcome;
810
+ }
811
+ /**
812
+ * Delete every resource a plan declares, in reverse apply order — the
813
+ * teardown twin of {@link renderApply}. Looks each up by name; a resource that
814
+ * is already gone is reported `deleted: false`. Only what the plan names is
815
+ * touched.
816
+ */
817
+ export async function renderDelete(args, signal, http = defaultRenderHttp(args.token)) {
818
+ const plan = parsePlan(readFileSync(args.planPath, "utf8"));
819
+ const ctx = { base: resolveEndpoint(args) };
820
+ const ordered = orderPlan(plan).reverse();
821
+ const owner = () => resolveOwner(ctx, args, http, signal);
822
+ const pruned = [];
823
+ const notAttempted = [];
824
+ // Ids of entities we find, so a child endpoint's `{serviceId}` can resolve
825
+ // even though we walk in reverse: resolve parents lazily by name.
826
+ const idByEntity = new Map();
827
+ const findId = async (entityName) => {
828
+ if (idByEntity.has(entityName))
829
+ return idByEntity.get(entityName);
830
+ const req = plan[entityName];
831
+ if (!req)
832
+ return undefined;
833
+ const entry = catalogEntry(req.entityType);
834
+ if (entry.filters.ownerId)
835
+ ctx.ownerId = ctx.ownerId ?? (await owner());
836
+ const endpoint = await resolveEndpointFor(req);
837
+ if (!endpoint)
838
+ return undefined;
839
+ const body = { ...req.body };
840
+ if (isOwnerMarker(body.ownerId))
841
+ body.ownerId = await owner();
842
+ const found = await findExisting(ctx, { ...req, body }, entry, endpoint, http, signal);
843
+ const id = typeof found?.id === "string" ? found.id : undefined;
844
+ if (id)
845
+ idByEntity.set(entityName, id);
846
+ return id;
847
+ };
848
+ const resolveEndpointFor = async (req) => {
849
+ let endpoint = req.endpoint;
850
+ for (const [k, v] of Object.entries(req.pathParams ?? {})) {
851
+ const id = isRefMarker(v) ? await findId(v.$ref) : typeof v === "string" ? v : undefined;
852
+ if (!id)
853
+ return undefined;
854
+ endpoint = endpoint.replace(`{${k}}`, encodeURIComponent(id));
855
+ }
856
+ return endpoint;
857
+ };
858
+ for (const [entityName, req] of ordered) {
859
+ const entry = catalogEntry(req.entityType);
860
+ const endpoint = await resolveEndpointFor(req);
861
+ const id = endpoint ? await findId(entityName) : undefined;
862
+ if (!id) {
863
+ pruned.push({ kind: entry.kind, name: req.name, deleted: false });
864
+ continue;
865
+ }
866
+ // Custom domains delete under their service; everything else at its collection.
867
+ const path = req.entityType === ENTITY_TYPES.customDomain ? `${endpoint}/${encodeURIComponent(id)}` : `${entry.collection}/${id}`;
868
+ const deleted = await remove(ctx, path, http, signal);
869
+ console.log(`${deleted ? "deleted" : "already gone"}: ${entry.kind}/${req.name} (${id})`);
870
+ pruned.push({ kind: entry.kind, name: req.name, deleted });
871
+ }
872
+ return applyResult([], pruned, notAttempted);
873
+ }