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