@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.
- package/dist/catalog.js +221 -0
- package/dist/codegen/docs-cli.js +6 -0
- package/dist/codegen/docs.js +110 -0
- package/dist/codegen/generate-cli.js +26 -0
- package/dist/codegen/generate-lexicon.js +38 -0
- package/dist/codegen/generate-typescript.js +46 -0
- package/dist/codegen/generate.js +82 -0
- package/dist/codegen/naming.js +43 -0
- package/dist/codegen/package.js +27 -0
- package/dist/composites/render-deploy.js +71 -0
- package/dist/coverage.js +29 -0
- package/dist/describe-resources.js +325 -0
- package/dist/detect.js +27 -0
- package/dist/generated/index.js +40 -0
- package/dist/generated/runtime.js +4 -0
- package/dist/index.js +21 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.js +23 -0
- package/dist/lint/audit-lineage.js +8 -0
- package/dist/lint/post-synth/index.js +8 -0
- package/dist/lint/post-synth/ren010-native-runtime-requires-commands.js +43 -0
- package/dist/lint/post-synth/ren011-service-requires-source.js +45 -0
- package/dist/lint/post-synth/ren012-free-plan-cannot-scale.js +41 -0
- package/dist/lint/post-synth/render-helpers.js +34 -0
- package/dist/lint/rules/index.js +8 -0
- package/dist/lint/rules/no-secret-literals.js +68 -0
- package/dist/lint/rules/valid-cron-schedule.js +44 -0
- package/dist/lint/rules/valid-region.js +43 -0
- package/dist/lsp/completions.d.ts.map +1 -1
- package/dist/lsp/completions.js +20 -0
- package/dist/lsp/hover.d.ts.map +1 -1
- package/dist/lsp/hover.js +49 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/fake-render.js +271 -0
- package/dist/op/activities/index.js +8 -0
- package/dist/op/activities/render-apply.js +873 -0
- package/dist/ownership.js +38 -0
- package/dist/package-cli.js +17 -0
- package/dist/plugin.js +229 -0
- package/dist/pseudo.js +23 -0
- package/dist/serializer.js +238 -0
- package/dist/spec/fetch.js +30 -0
- package/dist/spec/parse.js +600 -0
- package/dist/validate-cli.js +4 -0
- package/dist/validate.js +41 -0
- package/package.json +7 -7
- package/src/codegen/package.ts +2 -2
- package/src/lsp/completions.ts +2 -1
- package/src/lsp/hover.ts +2 -1
package/dist/coverage.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage analysis for the render lexicon — how much of the generated Render
|
|
3
|
+
* API surface the lexicon's typed resources cover. Thin wrapper over the shared
|
|
4
|
+
* `computeCoverage`, mirroring the fly/gcp peers.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from "fs";
|
|
7
|
+
import { join, dirname } from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import { computeCoverage, overallPct, formatSummary, formatVerbose, checkThresholds, } from "@intentius/chant/codegen/coverage";
|
|
10
|
+
export { computeCoverage, overallPct, formatSummary, formatVerbose, checkThresholds };
|
|
11
|
+
/** Run coverage analysis for the render lexicon. */
|
|
12
|
+
export async function analyzeRenderCoverage(opts) {
|
|
13
|
+
const basePath = opts?.basePath ?? dirname(dirname(fileURLToPath(import.meta.url)));
|
|
14
|
+
const lexiconPath = join(basePath, "src", "generated", "lexicon-render.json");
|
|
15
|
+
if (!existsSync(lexiconPath)) {
|
|
16
|
+
throw new Error(`Generated lexicon not found at ${lexiconPath}. Run "chant dev generate" first.`);
|
|
17
|
+
}
|
|
18
|
+
const report = computeCoverage(readFileSync(lexiconPath, "utf-8"));
|
|
19
|
+
console.error(opts?.verbose ? formatVerbose(report) : formatSummary(report));
|
|
20
|
+
if (typeof opts?.minOverall === "number") {
|
|
21
|
+
const result = checkThresholds(report, { minOverallPct: opts.minOverall });
|
|
22
|
+
if (!result.ok) {
|
|
23
|
+
for (const v of result.violations)
|
|
24
|
+
console.error(` FAIL: ${v}`);
|
|
25
|
+
throw new Error("Coverage below threshold");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return report;
|
|
29
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live introspection of Render resources — the read-back seam for chant's
|
|
3
|
+
* plan/drift machinery.
|
|
4
|
+
*
|
|
5
|
+
* The write path (renderApply) already GETs live state to decide
|
|
6
|
+
* create/update/unchanged and to prune owned orphans. This surfaces that same
|
|
7
|
+
* read side through the standard `describeResources` seam so core's change set
|
|
8
|
+
* and `chant lifecycle plan` can see live Render state and classify
|
|
9
|
+
* create/update/delete/adopt/noop.
|
|
10
|
+
*
|
|
11
|
+
* Peer of fly's describe-resources: list what is live, key it by chant entity
|
|
12
|
+
* name, attach an ownership verdict. Two-tier ownership, matching the applier:
|
|
13
|
+
*
|
|
14
|
+
* - Services and env groups carry the primary marker (`CHANT_MANAGED_BY`,
|
|
15
|
+
* RENDER_ENV_OWNERSHIP_KEYS) in their env vars, so their verdict is
|
|
16
|
+
* per-resource: `owned` when marked, else `foreign`.
|
|
17
|
+
* - Disks and custom domains carry no marker but hang off a service, so
|
|
18
|
+
* they inherit the parent's verdict (the service boundary — fly's app
|
|
19
|
+
* boundary, one level down); undeclared ones under an owned declared
|
|
20
|
+
* service surface as owned orphans.
|
|
21
|
+
* - Datastores, projects, environments, registry credentials, and webhooks
|
|
22
|
+
* carry no marker channel and no boundary, so their verdict is `unknown`
|
|
23
|
+
* — the change set never escalates `unknown` to a delete. When the
|
|
24
|
+
* `owned` filter asks for those types, this logs that no verdict is
|
|
25
|
+
* available rather than silently returning everything.
|
|
26
|
+
*
|
|
27
|
+
* Endpoint + auth reuse the applier verbatim (resolveEndpoint /
|
|
28
|
+
* RENDER_API_BASE_URL / RENDER_API_KEY), so plan reads the same target
|
|
29
|
+
* renderApply writes. Undeclared chant-marked services and env groups in the
|
|
30
|
+
* workspace are also returned (as orphans keyed by `<type>/<name>`), so a
|
|
31
|
+
* declared-then-removed service shows up as a delete candidate.
|
|
32
|
+
*/
|
|
33
|
+
import { observation, unobservedAll } from "@intentius/chant/observation";
|
|
34
|
+
import { CATALOG, ENTITY_TYPES, ENTITY_TYPE_OF_SERVICE, catalogEntry, isServiceEntityType } from "./catalog.js";
|
|
35
|
+
import { isOwnerMarker, isRefMarker } from "./serializer.js";
|
|
36
|
+
import { resolveEndpoint, defaultRenderHttp, parsePlan, orderPlan, listAll, getOne, readServiceEnvVars, envVarsToMap, isChantOwned, resolveOwner, } from "./op/activities/render-apply.js";
|
|
37
|
+
/** Live service/datastore states that mean "gone or going" — not live. */
|
|
38
|
+
const NOT_LIVE = new Set(["deleted", "deleting"]);
|
|
39
|
+
/** Live resource → chant status string. */
|
|
40
|
+
function statusOf(rec) {
|
|
41
|
+
if (typeof rec.status === "string")
|
|
42
|
+
return rec.status; // datastores
|
|
43
|
+
if (rec.suspended === "suspended")
|
|
44
|
+
return "suspended";
|
|
45
|
+
if (typeof rec.verificationStatus === "string")
|
|
46
|
+
return rec.verificationStatus; // custom domains
|
|
47
|
+
return "deployed";
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* List live Render resources and return them keyed by chant entity name, each
|
|
51
|
+
* tagged with an ownership verdict the core change set reads to decide whether
|
|
52
|
+
* an orphan is a delete (owned) or an adopt candidate (foreign). Reused by
|
|
53
|
+
* `renderPlugin.describeResources`.
|
|
54
|
+
*
|
|
55
|
+
* `http` is injectable for tests; the default reuses the applier's fetch client
|
|
56
|
+
* (bearer token from RENDER_API_KEY).
|
|
57
|
+
*/
|
|
58
|
+
export async function describeResources(options, http = defaultRenderHttp(), signal) {
|
|
59
|
+
const result = {};
|
|
60
|
+
const unobserved = {};
|
|
61
|
+
// Without a readable plan there is nothing to key a read by, so chant never
|
|
62
|
+
// asks Render anything — every declared entity is unobserved, not absent.
|
|
63
|
+
if (!options.buildOutput) {
|
|
64
|
+
return observation(result, unobservedAll(options.entities.keys(), "read-failed", "no render plan in the build output to read back", options.entities));
|
|
65
|
+
}
|
|
66
|
+
let plan;
|
|
67
|
+
try {
|
|
68
|
+
plan = parsePlan(options.buildOutput);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return observation(result, unobservedAll(options.entities.keys(), "read-failed", `build output is not a readable render plan: ${err instanceof Error ? err.message : String(err)}`, options.entities));
|
|
72
|
+
}
|
|
73
|
+
const ctx = { base: resolveEndpoint({ endpoint: options.endpoint }) };
|
|
74
|
+
const owned = options.owned ?? false;
|
|
75
|
+
const typeOf = (entityName, fallback) => options.entities.get(entityName)?.entityType ?? fallback;
|
|
76
|
+
// The workspace: needed to scope every list. Resolved once; a failure here
|
|
77
|
+
// is a whole-read failure (no credentials / no workspace), reported per entity.
|
|
78
|
+
let ownerId;
|
|
79
|
+
try {
|
|
80
|
+
ownerId = await resolveOwner(ctx, { ownerId: options.ownerId }, http, signal);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
return observation(result, unobservedAll(options.entities.keys(), "no-credentials", err instanceof Error ? err.message : String(err), options.entities));
|
|
84
|
+
}
|
|
85
|
+
let ordered;
|
|
86
|
+
try {
|
|
87
|
+
ordered = orderPlan(plan);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
return observation(result, unobservedAll(options.entities.keys(), "read-failed", err instanceof Error ? err.message : String(err), options.entities));
|
|
91
|
+
}
|
|
92
|
+
// Live ids by entity, so a child's `{ $ref }` (Disk.serviceId, Environment.
|
|
93
|
+
// projectId, CustomDomain path) resolves to the parent's live id when the
|
|
94
|
+
// parent exists. A missing parent means the child cannot exist yet — it is
|
|
95
|
+
// reported absent, which is exactly the create the plan should propose.
|
|
96
|
+
const liveId = new Map();
|
|
97
|
+
// Service verdicts by live id, so a disk/domain can inherit its parent's.
|
|
98
|
+
const serviceVerdict = new Map();
|
|
99
|
+
// Declared children by `${serviceId}:${name}`, for the orphan scan.
|
|
100
|
+
const declaredChildren = new Set();
|
|
101
|
+
// Declared services and env groups by natural key, so the workspace-wide
|
|
102
|
+
// marker scan below can tell an orphan from a declared one.
|
|
103
|
+
const declaredServices = new Set();
|
|
104
|
+
const declaredGroups = new Set();
|
|
105
|
+
let loggedUnknown = false;
|
|
106
|
+
const noteUnknown = () => {
|
|
107
|
+
if (loggedUnknown || !owned)
|
|
108
|
+
return;
|
|
109
|
+
loggedUnknown = true;
|
|
110
|
+
console.warn("[render] datastores, projects, environments, registry credentials and webhooks carry no ownership marker — their verdict is unknown; --owned withholds them rather than guessing");
|
|
111
|
+
};
|
|
112
|
+
for (const [entityName, req] of ordered) {
|
|
113
|
+
const entry = catalogEntry(req.entityType);
|
|
114
|
+
if (isServiceEntityType(req.entityType))
|
|
115
|
+
declaredServices.add(`${req.body.type}:${req.name}`);
|
|
116
|
+
if (req.entityType === ENTITY_TYPES.envGroup)
|
|
117
|
+
declaredGroups.add(req.name);
|
|
118
|
+
// Resolve the path/body references this read depends on.
|
|
119
|
+
let endpoint = req.endpoint;
|
|
120
|
+
let unresolved = false;
|
|
121
|
+
for (const [k, v] of Object.entries(req.pathParams ?? {})) {
|
|
122
|
+
const id = isRefMarker(v) ? liveId.get(v.$ref) : typeof v === "string" ? v : undefined;
|
|
123
|
+
if (!id) {
|
|
124
|
+
unresolved = true;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
endpoint = endpoint.replace(`{${k}}`, encodeURIComponent(id));
|
|
128
|
+
}
|
|
129
|
+
if (unresolved)
|
|
130
|
+
continue; // parent absent → child absent (in neither map)
|
|
131
|
+
const query = {};
|
|
132
|
+
if (entry.filters.name)
|
|
133
|
+
query.name = req.name;
|
|
134
|
+
if (entry.filters.ownerId)
|
|
135
|
+
query.ownerId = typeof req.body.ownerId === "string" && !isOwnerMarker(req.body.ownerId) ? req.body.ownerId : ownerId;
|
|
136
|
+
let scopeId;
|
|
137
|
+
if (req.entityType === ENTITY_TYPES.environment) {
|
|
138
|
+
scopeId = isRefMarker(req.body.projectId) ? liveId.get(req.body.projectId.$ref) : typeof req.body.projectId === "string" ? req.body.projectId : undefined;
|
|
139
|
+
if (!scopeId)
|
|
140
|
+
continue;
|
|
141
|
+
query.projectId = scopeId;
|
|
142
|
+
}
|
|
143
|
+
if (req.entityType === ENTITY_TYPES.disk) {
|
|
144
|
+
scopeId = isRefMarker(req.body.serviceId) ? liveId.get(req.body.serviceId.$ref) : typeof req.body.serviceId === "string" ? req.body.serviceId : undefined;
|
|
145
|
+
if (!scopeId)
|
|
146
|
+
continue;
|
|
147
|
+
query.serviceId = scopeId;
|
|
148
|
+
}
|
|
149
|
+
const listPath = req.entityType === ENTITY_TYPES.customDomain ? endpoint : entry.collection;
|
|
150
|
+
let candidates;
|
|
151
|
+
try {
|
|
152
|
+
candidates = await listAll(ctx, listPath, query, entry.listKey, http, signal);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
unobserved[entityName] = {
|
|
156
|
+
type: req.entityType,
|
|
157
|
+
reason: "read-failed",
|
|
158
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
159
|
+
queried: `${ctx.base}${listPath}`,
|
|
160
|
+
};
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const live = candidates.find((c) => {
|
|
164
|
+
if (c.name !== req.name)
|
|
165
|
+
return false;
|
|
166
|
+
if (isServiceEntityType(req.entityType) && c.type !== req.body.type)
|
|
167
|
+
return false;
|
|
168
|
+
if (scopeId && req.entityType === ENTITY_TYPES.environment && c.projectId !== scopeId)
|
|
169
|
+
return false;
|
|
170
|
+
if (scopeId && req.entityType === ENTITY_TYPES.disk && c.serviceId !== scopeId)
|
|
171
|
+
return false;
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
if (!live || (typeof live.status === "string" && NOT_LIVE.has(live.status)))
|
|
175
|
+
continue; // absent
|
|
176
|
+
const id = typeof live.id === "string" ? live.id : undefined;
|
|
177
|
+
if (id)
|
|
178
|
+
liveId.set(entityName, id);
|
|
179
|
+
// Ownership verdict: the marker for services and env groups; the parent
|
|
180
|
+
// service's verdict for disks and custom domains (service boundary);
|
|
181
|
+
// unknown for the rest.
|
|
182
|
+
let ownership;
|
|
183
|
+
if (entry.marked && id) {
|
|
184
|
+
let env;
|
|
185
|
+
if (isServiceEntityType(req.entityType)) {
|
|
186
|
+
env = await readServiceEnvVars(ctx, id, http, signal);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
const full = (await getOne(ctx, `${entry.collection}/${id}`, http, signal)) ?? live;
|
|
190
|
+
env = envVarsToMap(full.envVars);
|
|
191
|
+
}
|
|
192
|
+
ownership = isChantOwned(env) ? "owned" : "foreign";
|
|
193
|
+
if (isServiceEntityType(req.entityType))
|
|
194
|
+
serviceVerdict.set(id, ownership);
|
|
195
|
+
}
|
|
196
|
+
else if (entry.boundary === "service") {
|
|
197
|
+
const raw = req.entityType === ENTITY_TYPES.customDomain ? req.pathParams?.serviceId : req.body.serviceId;
|
|
198
|
+
const parentId = isRefMarker(raw) ? liveId.get(raw.$ref) : typeof raw === "string" ? raw : undefined;
|
|
199
|
+
if (parentId)
|
|
200
|
+
declaredChildren.add(`${parentId}:${req.name}`);
|
|
201
|
+
let verdict = parentId ? serviceVerdict.get(parentId) : undefined;
|
|
202
|
+
if (!verdict && parentId) {
|
|
203
|
+
// Parent named by literal id (not declared here): read its marker.
|
|
204
|
+
verdict = isChantOwned(await readServiceEnvVars(ctx, parentId, http, signal)) ? "owned" : "foreign";
|
|
205
|
+
serviceVerdict.set(parentId, verdict);
|
|
206
|
+
}
|
|
207
|
+
ownership = verdict ?? "unknown";
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
ownership = "unknown";
|
|
211
|
+
noteUnknown();
|
|
212
|
+
}
|
|
213
|
+
if (owned && ownership !== "owned") {
|
|
214
|
+
unobserved[entityName] = {
|
|
215
|
+
type: req.entityType,
|
|
216
|
+
reason: "filtered",
|
|
217
|
+
detail: ownership === "foreign"
|
|
218
|
+
? "live resource carries no chant ownership marker and --owned was requested"
|
|
219
|
+
: "resource kind has no ownership marker channel and --owned was requested",
|
|
220
|
+
};
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
result[entityName] = {
|
|
224
|
+
type: typeOf(entityName, req.entityType),
|
|
225
|
+
physicalId: id,
|
|
226
|
+
status: statusOf(live),
|
|
227
|
+
lastUpdated: typeof live.updatedAt === "string" ? live.updatedAt : undefined,
|
|
228
|
+
ownership,
|
|
229
|
+
attributes: pruneUndefined({
|
|
230
|
+
id,
|
|
231
|
+
name: live.name,
|
|
232
|
+
dashboardUrl: live.dashboardUrl,
|
|
233
|
+
type: live.type,
|
|
234
|
+
region: live.serviceDetails?.region ?? live.region,
|
|
235
|
+
environmentId: live.environmentId,
|
|
236
|
+
projectId: live.projectId,
|
|
237
|
+
serviceId: live.serviceId,
|
|
238
|
+
}),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
// Orphans: chant-marked services and env groups in the workspace that the
|
|
242
|
+
// plan does not declare — delete candidates for the change set.
|
|
243
|
+
try {
|
|
244
|
+
for (const svc of await listAll(ctx, "/services", { ownerId }, "service", http, signal)) {
|
|
245
|
+
const id = typeof svc.id === "string" ? svc.id : undefined;
|
|
246
|
+
const name = typeof svc.name === "string" ? svc.name : "";
|
|
247
|
+
const type = typeof svc.type === "string" ? svc.type : "";
|
|
248
|
+
if (!id)
|
|
249
|
+
continue;
|
|
250
|
+
const declared = declaredServices.has(`${type}:${name}`);
|
|
251
|
+
const verdict = serviceVerdict.get(id) ?? (isChantOwned(await readServiceEnvVars(ctx, id, http, signal)) ? "owned" : "foreign");
|
|
252
|
+
if (verdict !== "owned")
|
|
253
|
+
continue;
|
|
254
|
+
if (!declared) {
|
|
255
|
+
const entityType = ENTITY_TYPE_OF_SERVICE[type] ?? ENTITY_TYPES.webService;
|
|
256
|
+
result[`${CATALOG[entityType].kind}/${name}`] = {
|
|
257
|
+
type: entityType,
|
|
258
|
+
physicalId: id,
|
|
259
|
+
status: statusOf(svc),
|
|
260
|
+
lastUpdated: typeof svc.updatedAt === "string" ? svc.updatedAt : undefined,
|
|
261
|
+
ownership: "owned",
|
|
262
|
+
attributes: pruneUndefined({ id, name, dashboardUrl: svc.dashboardUrl, type }),
|
|
263
|
+
};
|
|
264
|
+
continue; // its children go with it
|
|
265
|
+
}
|
|
266
|
+
// Service boundary: undeclared disks and custom domains under a declared
|
|
267
|
+
// owned service are owned orphans too.
|
|
268
|
+
for (const disk of await listAll(ctx, "/disks", { serviceId: id, ownerId }, "disk", http, signal)) {
|
|
269
|
+
const diskId = typeof disk.id === "string" ? disk.id : undefined;
|
|
270
|
+
const diskName = typeof disk.name === "string" ? disk.name : "";
|
|
271
|
+
if (!diskId || disk.serviceId !== id || declaredChildren.has(`${id}:${diskName}`))
|
|
272
|
+
continue;
|
|
273
|
+
result[`Disk/${diskName}`] = {
|
|
274
|
+
type: ENTITY_TYPES.disk,
|
|
275
|
+
physicalId: diskId,
|
|
276
|
+
status: "deployed",
|
|
277
|
+
ownership: "owned",
|
|
278
|
+
attributes: pruneUndefined({ id: diskId, name: diskName, serviceId: id, mountPath: disk.mountPath, sizeGB: disk.sizeGB }),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
for (const domain of await listAll(ctx, `/services/${id}/custom-domains`, {}, "customDomain", http, signal)) {
|
|
282
|
+
const domainId = typeof domain.id === "string" ? domain.id : undefined;
|
|
283
|
+
const domainName = typeof domain.name === "string" ? domain.name : "";
|
|
284
|
+
if (!domainId || declaredChildren.has(`${id}:${domainName}`))
|
|
285
|
+
continue;
|
|
286
|
+
result[`CustomDomain/${domainName}`] = {
|
|
287
|
+
type: ENTITY_TYPES.customDomain,
|
|
288
|
+
physicalId: domainId,
|
|
289
|
+
status: statusOf(domain),
|
|
290
|
+
ownership: "owned",
|
|
291
|
+
attributes: pruneUndefined({ id: domainId, name: domainName, serviceId: id }),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const grp of await listAll(ctx, "/env-groups", { ownerId }, "envGroup", http, signal)) {
|
|
296
|
+
const id = typeof grp.id === "string" ? grp.id : undefined;
|
|
297
|
+
const name = typeof grp.name === "string" ? grp.name : "";
|
|
298
|
+
if (!id || declaredGroups.has(name))
|
|
299
|
+
continue;
|
|
300
|
+
const full = (await getOne(ctx, `/env-groups/${id}`, http, signal)) ?? grp;
|
|
301
|
+
if (!isChantOwned(envVarsToMap(full.envVars)))
|
|
302
|
+
continue;
|
|
303
|
+
result[`EnvGroup/${name}`] = {
|
|
304
|
+
type: ENTITY_TYPES.envGroup,
|
|
305
|
+
physicalId: id,
|
|
306
|
+
status: "deployed",
|
|
307
|
+
lastUpdated: typeof grp.updatedAt === "string" ? grp.updatedAt : undefined,
|
|
308
|
+
ownership: "owned",
|
|
309
|
+
attributes: { id, name },
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
console.warn(`[render] orphan scan failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
315
|
+
}
|
|
316
|
+
return observation(result, unobserved);
|
|
317
|
+
}
|
|
318
|
+
function pruneUndefined(obj) {
|
|
319
|
+
const out = {};
|
|
320
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
321
|
+
if (v !== undefined)
|
|
322
|
+
out[k] = v;
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template detection for the render lexicon.
|
|
3
|
+
*
|
|
4
|
+
* `chant import` parses a template file as JSON and asks each installed lexicon
|
|
5
|
+
* whether it recognizes the shape. render's importable source is the serializer
|
|
6
|
+
* plan (what `chant build` emits and `renderApply` POSTs): an object keyed by
|
|
7
|
+
* entity name whose values carry a `Render::…` entityType and a `POST` request.
|
|
8
|
+
*/
|
|
9
|
+
function isRecord(v) {
|
|
10
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
11
|
+
}
|
|
12
|
+
/** A single request as the serializer emits it. */
|
|
13
|
+
function isRenderRequest(v) {
|
|
14
|
+
if (!isRecord(v))
|
|
15
|
+
return false;
|
|
16
|
+
return typeof v.entityType === "string" && v.entityType.startsWith("Render::") && typeof v.endpoint === "string" && "body" in v;
|
|
17
|
+
}
|
|
18
|
+
/** The serializer's whole output: entity name → request. */
|
|
19
|
+
export function isSerializerPlan(v) {
|
|
20
|
+
if (!isRecord(v))
|
|
21
|
+
return false;
|
|
22
|
+
const values = Object.values(v);
|
|
23
|
+
return values.length > 0 && values.every(isRenderRequest);
|
|
24
|
+
}
|
|
25
|
+
export function detectTemplate(data) {
|
|
26
|
+
return isSerializerPlan(data);
|
|
27
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Code generated by chant generate. DO NOT EDIT.
|
|
2
|
+
import { createResource, createProperty } from "./runtime.js";
|
|
3
|
+
export const BackgroundWorker = /* @__PURE__ */ createResource("Render::Services::BackgroundWorker", "render", { "id": "id", "buildSourceId": "buildSourceId", "createdAt": "createdAt", "dashboardUrl": "dashboardUrl", "imagePath": "imagePath", "notifyOnFail": "notifyOnFail", "registryCredential": "registryCredential", "slug": "slug", "suspended": "suspended", "suspenders": "suspenders", "updatedAt": "updatedAt" });
|
|
4
|
+
export const CronJob = /* @__PURE__ */ createResource("Render::Services::CronJob", "render", { "id": "id", "buildSourceId": "buildSourceId", "createdAt": "createdAt", "dashboardUrl": "dashboardUrl", "imagePath": "imagePath", "notifyOnFail": "notifyOnFail", "registryCredential": "registryCredential", "slug": "slug", "suspended": "suspended", "suspenders": "suspenders", "updatedAt": "updatedAt" });
|
|
5
|
+
export const CustomDomain = /* @__PURE__ */ createResource("Render::Services::CustomDomain", "render", { "id": "id", "domainType": "domainType", "publicSuffix": "publicSuffix", "redirectForName": "redirectForName", "verificationStatus": "verificationStatus", "createdAt": "createdAt", "server": "server" });
|
|
6
|
+
export const Disk = /* @__PURE__ */ createResource("Render::Services::Disk", "render", { "id": "id", "createdAt": "createdAt", "updatedAt": "updatedAt" });
|
|
7
|
+
export const EnvGroup = /* @__PURE__ */ createResource("Render::Config::EnvGroup", "render", { "id": "id", "createdAt": "createdAt", "updatedAt": "updatedAt", "serviceLinks": "serviceLinks" });
|
|
8
|
+
export const Environment = /* @__PURE__ */ createResource("Render::Projects::Environment", "render", { "id": "id", "databasesIds": "databasesIds", "redisIds": "redisIds", "serviceIds": "serviceIds", "envGroupIds": "envGroupIds" });
|
|
9
|
+
export const KeyValue = /* @__PURE__ */ createResource("Render::Datastores::KeyValue", "render", { "id": "id", "createdAt": "createdAt", "updatedAt": "updatedAt", "status": "status", "owner": "owner", "options": "options", "version": "version", "maintenance": "maintenance", "internalConnectionString": "internalConnectionString", "externalConnectionString": "externalConnectionString", "cliCommand": "cliCommand" });
|
|
10
|
+
export const Postgres = /* @__PURE__ */ createResource("Render::Datastores::Postgres", "render", { "id": "id", "createdAt": "createdAt", "updatedAt": "updatedAt", "expiresAt": "expiresAt", "dashboardUrl": "dashboardUrl", "highAvailabilityEnabled": "highAvailabilityEnabled", "maintenance": "maintenance", "owner": "owner", "primaryPostgresID": "primaryPostgresID", "role": "role", "status": "status", "suspended": "suspended", "suspenders": "suspenders", "diskAutoscalingEnabled": "diskAutoscalingEnabled", "internalConnectionString": "internalConnectionString", "externalConnectionString": "externalConnectionString", "psqlCommand": "psqlCommand" });
|
|
11
|
+
export const PrivateService = /* @__PURE__ */ createResource("Render::Services::PrivateService", "render", { "id": "id", "buildSourceId": "buildSourceId", "createdAt": "createdAt", "dashboardUrl": "dashboardUrl", "imagePath": "imagePath", "notifyOnFail": "notifyOnFail", "registryCredential": "registryCredential", "slug": "slug", "suspended": "suspended", "suspenders": "suspenders", "updatedAt": "updatedAt" });
|
|
12
|
+
export const Project = /* @__PURE__ */ createResource("Render::Projects::Project", "render", { "id": "id", "createdAt": "createdAt", "updatedAt": "updatedAt", "owner": "owner", "environmentIds": "environmentIds" });
|
|
13
|
+
export const RegistryCredential = /* @__PURE__ */ createResource("Render::Config::RegistryCredential", "render", { "id": "id", "updatedAt": "updatedAt" });
|
|
14
|
+
export const StaticSite = /* @__PURE__ */ createResource("Render::Services::StaticSite", "render", { "id": "id", "buildSourceId": "buildSourceId", "createdAt": "createdAt", "dashboardUrl": "dashboardUrl", "imagePath": "imagePath", "notifyOnFail": "notifyOnFail", "registryCredential": "registryCredential", "slug": "slug", "suspended": "suspended", "suspenders": "suspenders", "updatedAt": "updatedAt" });
|
|
15
|
+
export const Webhook = /* @__PURE__ */ createResource("Render::Config::Webhook", "render", { "id": "id", "secret": "secret" });
|
|
16
|
+
export const WebService = /* @__PURE__ */ createResource("Render::Services::WebService", "render", { "id": "id", "buildSourceId": "buildSourceId", "createdAt": "createdAt", "dashboardUrl": "dashboardUrl", "imagePath": "imagePath", "notifyOnFail": "notifyOnFail", "registryCredential": "registryCredential", "slug": "slug", "suspended": "suspended", "suspenders": "suspenders", "updatedAt": "updatedAt" });
|
|
17
|
+
export const Autoscaling = /* @__PURE__ */ createProperty("Render::Services::Autoscaling", "render");
|
|
18
|
+
export const AutoscalingCriteria = /* @__PURE__ */ createProperty("Render::Services::AutoscalingCriteria", "render");
|
|
19
|
+
export const AutoscalingCriterion = /* @__PURE__ */ createProperty("Render::Services::AutoscalingCriterion", "render");
|
|
20
|
+
export const BackgroundWorkerDetails = /* @__PURE__ */ createProperty("Render::Services::BackgroundWorkerDetails", "render");
|
|
21
|
+
export const BuildFilter = /* @__PURE__ */ createProperty("Render::Services::BuildFilter", "render");
|
|
22
|
+
export const CidrBlockAndDescription = /* @__PURE__ */ createProperty("Render::Services::CidrBlockAndDescription", "render");
|
|
23
|
+
export const CronJobDetails = /* @__PURE__ */ createProperty("Render::Services::CronJobDetails", "render");
|
|
24
|
+
export const DockerDetails = /* @__PURE__ */ createProperty("Render::Services::DockerDetails", "render");
|
|
25
|
+
export const EnvVar = /* @__PURE__ */ createProperty("Render::Services::EnvVar", "render");
|
|
26
|
+
export const GeneratedEnvVar = /* @__PURE__ */ createProperty("Render::Services::GeneratedEnvVar", "render");
|
|
27
|
+
export const Header = /* @__PURE__ */ createProperty("Render::Services::Header", "render");
|
|
28
|
+
export const Image = /* @__PURE__ */ createProperty("Render::Services::Image", "render");
|
|
29
|
+
export const MaintenanceMode = /* @__PURE__ */ createProperty("Render::Services::MaintenanceMode", "render");
|
|
30
|
+
export const NativeEnvironmentDetails = /* @__PURE__ */ createProperty("Render::Services::NativeEnvironmentDetails", "render");
|
|
31
|
+
export const Previews = /* @__PURE__ */ createProperty("Render::Services::Previews", "render");
|
|
32
|
+
export const PrivateServiceDetails = /* @__PURE__ */ createProperty("Render::Services::PrivateServiceDetails", "render");
|
|
33
|
+
export const ProjectEnvironment = /* @__PURE__ */ createProperty("Render::Services::ProjectEnvironment", "render");
|
|
34
|
+
export const ReadReplica = /* @__PURE__ */ createProperty("Render::Services::ReadReplica", "render");
|
|
35
|
+
export const Route = /* @__PURE__ */ createProperty("Render::Services::Route", "render");
|
|
36
|
+
export const SecretFile = /* @__PURE__ */ createProperty("Render::Services::SecretFile", "render");
|
|
37
|
+
export const ServiceDisk = /* @__PURE__ */ createProperty("Render::Services::ServiceDisk", "render");
|
|
38
|
+
export const StaticSiteDetails = /* @__PURE__ */ createProperty("Render::Services::StaticSiteDetails", "render");
|
|
39
|
+
export const WebServiceDetails = /* @__PURE__ */ createProperty("Render::Services::WebServiceDetails", "render");
|
|
40
|
+
// Re-exports for convenience
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Plugin
|
|
2
|
+
export { renderPlugin } from "./plugin.js";
|
|
3
|
+
// Serializer (+ the marker shapes the applier resolves)
|
|
4
|
+
export { renderSerializer, isRefMarker, isAttrMarker, isOwnerMarker } from "./serializer.js";
|
|
5
|
+
// Pseudo-parameters — environment-resolved values (`Render.OwnerId`,
|
|
6
|
+
// `Render.Region`) usable in place of hard-coded strings.
|
|
7
|
+
export { Render, OwnerId, Region, PseudoParameter } from "./pseudo.js";
|
|
8
|
+
// Ownership marker convention (env-var keys on services and env groups)
|
|
9
|
+
export { RENDER_ENV_OWNERSHIP_KEYS } from "./ownership.js";
|
|
10
|
+
// The resource catalog: entity type → REST collection, identity, ordering.
|
|
11
|
+
export { CATALOG, ENTITY_TYPES, SERVICE_TYPE_OF, catalogEntry, isServiceEntityType } from "./catalog.js";
|
|
12
|
+
// Deploy Op composite + typed step builders. `renderDeploy` returns a
|
|
13
|
+
// `build → renderApply [→ verify] [→ teardown]` Op; the step builders wrap the
|
|
14
|
+
// generic `activity()` so the render activities resolve by name.
|
|
15
|
+
export { renderDeploy, renderApplyStep, renderDeleteStep } from "./composites/render-deploy.js";
|
|
16
|
+
// Generated resources — export everything from generated index.
|
|
17
|
+
// Provides `WebService`, `StaticSite`, `PrivateService`, `BackgroundWorker`,
|
|
18
|
+
// `CronJob`, `Postgres`, `KeyValue`, `EnvGroup`, `Project`, `Environment`,
|
|
19
|
+
// `Disk`, `CustomDomain`, `RegistryCredential`, `Webhook`, and the property
|
|
20
|
+
// types (`WebServiceDetails`, `Image`, `ServiceDisk`, ...) for authoring.
|
|
21
|
+
export * from "./generated/index.js";
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "1e19cf063669752c1e524c89d7932b4bd5fe9f3bc461fb9e9363ef3b6a4fcc0f",
|
|
5
5
|
"meta.json": "1af07221f8eedfaec1fcd2c26d396554b7fef33769541fb484331bbb505b2603",
|
|
6
6
|
"types/index.d.ts": "0a7a7f086e16d043cc58281bb517fc18d0ba977664347b27a8593e53c79b43d0",
|
|
7
7
|
"rules/no-secret-literals.ts": "6afb75ac75bc77c25bc6b5a1a31351a581831a9ae0fb41cbdb4ac7cad2d0e42d",
|
|
@@ -14,5 +14,5 @@
|
|
|
14
14
|
"skills/chant-render.md": "1a6743636165d86851cf102686a0c8ac3601c1d322dc97eff0ff64bd8e494abd",
|
|
15
15
|
"skills/chant-render-patterns.md": "3a7e395bcd478c8253ae2f45b0a09d3b45d08075a49781aa07e8bb7f0ac7ef8d"
|
|
16
16
|
},
|
|
17
|
-
"composite": "
|
|
17
|
+
"composite": "10883069f2c28c7f07c8dd405b83cca68b771079476d6c2fe45442745b2ed5d2"
|
|
18
18
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The render lexicon's chant audit catalog — metadata for the REN post-synth
|
|
3
|
+
* checks, contributed via `renderPlugin.auditCatalog()`.
|
|
4
|
+
*
|
|
5
|
+
* All entries carry `yamlBased: false`: each check reads the chant model
|
|
6
|
+
* (`ctx.entities`) rather than the emitted plan, because what they need — the
|
|
7
|
+
* runtime, the plan tier, whether a source was named — lives in the typed
|
|
8
|
+
* graph. So they fire on `chant build` and cannot fire on an audit of a
|
|
9
|
+
* standalone Render config.
|
|
10
|
+
*/
|
|
11
|
+
import { renderAuditLineage } from "./audit-lineage.js";
|
|
12
|
+
import { applyLineage } from "@intentius/chant/audit/catalog";
|
|
13
|
+
/** Entity-based rule: everything render ships. */
|
|
14
|
+
function rule(id, tier, category, title, remediation) {
|
|
15
|
+
return { id, tier, fixKind: "guidance", category, title, remediation, yamlBased: false };
|
|
16
|
+
}
|
|
17
|
+
export const renderAuditCatalog = {
|
|
18
|
+
REN010: rule("REN010", "merge-worthy", "correctness", "Native-runtime service has no build/start command", "Set `envSpecificDetails: new NativeEnvironmentDetails({ buildCommand, startCommand })` on the service."),
|
|
19
|
+
REN011: rule("REN011", "merge-worthy", "correctness", "Service has no source (repo or image)", "Set `repo` for a git-backed service, or `image: new Image({ imagePath })` for an image-backed one."),
|
|
20
|
+
REN012: rule("REN012", "merge-worthy", "correctness", "Free-plan service configured to scale or mount a disk", "Move the service to a paid instance type, or drop numInstances/autoscaling/disk."),
|
|
21
|
+
};
|
|
22
|
+
// Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
|
|
23
|
+
applyLineage(renderAuditCatalog, renderAuditLineage);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const renderAuditLineage = {
|
|
2
|
+
REN010: [
|
|
3
|
+
{ tool: "render-docs", rule: "buildCommand / startCommand", url: "https://render.com/docs/blueprint-spec#buildcommand", relation: "overlaps" },
|
|
4
|
+
],
|
|
5
|
+
REN012: [
|
|
6
|
+
{ tool: "render-docs", rule: "Free web services limitations", url: "https://render.com/docs/free#persistent-disks", relation: "overlaps" },
|
|
7
|
+
],
|
|
8
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ren010 } from "./ren010-native-runtime-requires-commands.js";
|
|
2
|
+
import { ren011 } from "./ren011-service-requires-source.js";
|
|
3
|
+
import { ren012 } from "./ren012-free-plan-cannot-scale.js";
|
|
4
|
+
export const postSynthChecks = [
|
|
5
|
+
ren010,
|
|
6
|
+
ren011,
|
|
7
|
+
ren012,
|
|
8
|
+
];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REN010: A native-runtime service must say how to build and start.
|
|
3
|
+
*
|
|
4
|
+
* Render's native runtimes (node, python, ruby, go, rust, elixir) need a
|
|
5
|
+
* `buildCommand` and a `startCommand` in `envSpecificDetails`; without them the
|
|
6
|
+
* API rejects the create (or, on some paths, accepts it and the first deploy
|
|
7
|
+
* fails). `docker` reads a Dockerfile and `image` pulls a prebuilt image, so
|
|
8
|
+
* neither needs commands. Static sites have no runtime.
|
|
9
|
+
*/
|
|
10
|
+
import { ENTITY_TYPES } from "../../catalog.js";
|
|
11
|
+
import { readProps, entityTypeOf, isService, kindOf } from "./render-helpers.js";
|
|
12
|
+
const NATIVE_RUNTIMES = new Set(["node", "python", "ruby", "go", "rust", "elixir"]);
|
|
13
|
+
export const ren010 = {
|
|
14
|
+
id: "REN010",
|
|
15
|
+
description: "A native-runtime service must set envSpecificDetails.buildCommand and startCommand",
|
|
16
|
+
check(ctx) {
|
|
17
|
+
const diagnostics = [];
|
|
18
|
+
for (const [name, entity] of ctx.entities) {
|
|
19
|
+
if (!isService(entity) || entityTypeOf(entity) === ENTITY_TYPES.staticSite)
|
|
20
|
+
continue;
|
|
21
|
+
const details = readProps(readProps(entity).serviceDetails);
|
|
22
|
+
const runtime = details.runtime;
|
|
23
|
+
if (typeof runtime !== "string" || !NATIVE_RUNTIMES.has(runtime))
|
|
24
|
+
continue;
|
|
25
|
+
const env = readProps(details.envSpecificDetails);
|
|
26
|
+
const missing = [];
|
|
27
|
+
if (typeof env.buildCommand !== "string" || env.buildCommand.length === 0)
|
|
28
|
+
missing.push("buildCommand");
|
|
29
|
+
if (typeof env.startCommand !== "string" || env.startCommand.length === 0)
|
|
30
|
+
missing.push("startCommand");
|
|
31
|
+
if (missing.length > 0) {
|
|
32
|
+
diagnostics.push({
|
|
33
|
+
checkId: "REN010",
|
|
34
|
+
severity: "error",
|
|
35
|
+
message: `${kindOf(entity)} "${name}" uses the ${runtime} runtime but sets no ${missing.join(" or ")} — add envSpecificDetails: new NativeEnvironmentDetails({ buildCommand, startCommand })`,
|
|
36
|
+
entity: name,
|
|
37
|
+
lexicon: "render",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return diagnostics;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REN011: A service needs a source — a repo or an image.
|
|
3
|
+
*
|
|
4
|
+
* Every Render service is either git-backed (`repo`, built on Render) or
|
|
5
|
+
* image-backed (`image.imagePath`, pulled from a registry). One that names
|
|
6
|
+
* neither has nothing to deploy and is rejected at create time; one that uses
|
|
7
|
+
* the `image` runtime without an `image` is the same mistake spelled
|
|
8
|
+
* differently. Static sites are always git-backed.
|
|
9
|
+
*/
|
|
10
|
+
import { readProps, isService, kindOf } from "./render-helpers.js";
|
|
11
|
+
export const ren011 = {
|
|
12
|
+
id: "REN011",
|
|
13
|
+
description: "A service must have a repo (git-backed) or an image (image-backed)",
|
|
14
|
+
check(ctx) {
|
|
15
|
+
const diagnostics = [];
|
|
16
|
+
for (const [name, entity] of ctx.entities) {
|
|
17
|
+
if (!isService(entity))
|
|
18
|
+
continue;
|
|
19
|
+
const props = readProps(entity);
|
|
20
|
+
const repo = typeof props.repo === "string" && props.repo.length > 0;
|
|
21
|
+
const imagePath = readProps(props.image).imagePath;
|
|
22
|
+
const image = typeof imagePath === "string" && imagePath.length > 0;
|
|
23
|
+
const runtime = readProps(props.serviceDetails).runtime;
|
|
24
|
+
if (!repo && !image) {
|
|
25
|
+
diagnostics.push({
|
|
26
|
+
checkId: "REN011",
|
|
27
|
+
severity: "error",
|
|
28
|
+
message: `${kindOf(entity)} "${name}" has neither a repo nor an image — set repo: "https://github.com/…" or image: new Image({ imagePath: "docker.io/…" })`,
|
|
29
|
+
entity: name,
|
|
30
|
+
lexicon: "render",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
else if (runtime === "image" && !image) {
|
|
34
|
+
diagnostics.push({
|
|
35
|
+
checkId: "REN011",
|
|
36
|
+
severity: "error",
|
|
37
|
+
message: `${kindOf(entity)} "${name}" uses runtime "image" but sets no image.imagePath`,
|
|
38
|
+
entity: name,
|
|
39
|
+
lexicon: "render",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return diagnostics;
|
|
44
|
+
},
|
|
45
|
+
};
|