@intentius/chant-lexicon-render 0.124.31 → 0.124.32
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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REN012: The free instance type cannot scale.
|
|
3
|
+
*
|
|
4
|
+
* Render's `free` plan runs a single instance and cannot autoscale; a
|
|
5
|
+
* `numInstances > 1` or an enabled `autoscaling` block on a free service is
|
|
6
|
+
* rejected at create/patch time. Persistent disks are also unavailable on
|
|
7
|
+
* free. Catch all three at synth time with a message that says which.
|
|
8
|
+
*/
|
|
9
|
+
import { readProps, isService, kindOf } from "./render-helpers.js";
|
|
10
|
+
export const ren012 = {
|
|
11
|
+
id: "REN012",
|
|
12
|
+
description: "A free-plan service cannot set numInstances > 1, autoscaling, or a disk",
|
|
13
|
+
check(ctx) {
|
|
14
|
+
const diagnostics = [];
|
|
15
|
+
for (const [name, entity] of ctx.entities) {
|
|
16
|
+
if (!isService(entity))
|
|
17
|
+
continue;
|
|
18
|
+
const details = readProps(readProps(entity).serviceDetails);
|
|
19
|
+
if (details.plan !== "free")
|
|
20
|
+
continue;
|
|
21
|
+
const problems = [];
|
|
22
|
+
if (typeof details.numInstances === "number" && details.numInstances > 1)
|
|
23
|
+
problems.push(`numInstances: ${details.numInstances}`);
|
|
24
|
+
const autoscaling = readProps(details.autoscaling);
|
|
25
|
+
if (autoscaling.enabled === true)
|
|
26
|
+
problems.push("autoscaling");
|
|
27
|
+
if (details.disk !== undefined)
|
|
28
|
+
problems.push("a disk");
|
|
29
|
+
if (problems.length > 0) {
|
|
30
|
+
diagnostics.push({
|
|
31
|
+
checkId: "REN012",
|
|
32
|
+
severity: "error",
|
|
33
|
+
message: `${kindOf(entity)} "${name}" is on the free plan but sets ${problems.join(", ")} — the free instance type runs one instance, cannot autoscale, and has no persistent disk; pick a paid plan (starter or above)`,
|
|
34
|
+
entity: name,
|
|
35
|
+
lexicon: "render",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return diagnostics;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the render post-synth checks. Not a check itself, so the
|
|
3
|
+
* generated barrel skips it (the scanner only picks up exported PostSynthChecks).
|
|
4
|
+
*/
|
|
5
|
+
import { isServiceEntityType } from "../../catalog.js";
|
|
6
|
+
/**
|
|
7
|
+
* Read an entity or property's constructor props. Nested Declarables
|
|
8
|
+
* (WebServiceDetails, Image, ServiceDisk) stash their args under a
|
|
9
|
+
* non-enumerable `props`; a plain inline object carries them directly. Handle
|
|
10
|
+
* both so a check behaves the same whether the user authored
|
|
11
|
+
* `serviceDetails: { runtime }` or `serviceDetails: new WebServiceDetails({ runtime })`.
|
|
12
|
+
*/
|
|
13
|
+
export function readProps(value) {
|
|
14
|
+
if (value && typeof value === "object") {
|
|
15
|
+
const nested = value.props;
|
|
16
|
+
if (nested && typeof nested === "object")
|
|
17
|
+
return nested;
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
/** The entityType of a declarable, or undefined. */
|
|
23
|
+
export function entityTypeOf(value) {
|
|
24
|
+
return value?.entityType;
|
|
25
|
+
}
|
|
26
|
+
/** True when the entity is one of the five service kinds. */
|
|
27
|
+
export function isService(value) {
|
|
28
|
+
return isServiceEntityType(entityTypeOf(value));
|
|
29
|
+
}
|
|
30
|
+
/** The short kind (`WebService`) from an entity type, for messages. */
|
|
31
|
+
export function kindOf(value) {
|
|
32
|
+
const t = entityTypeOf(value) ?? "";
|
|
33
|
+
return t.slice(t.lastIndexOf("::") + 2);
|
|
34
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { validRegionRule } from "./valid-region.js";
|
|
2
|
+
import { noSecretLiteralsRule } from "./no-secret-literals.js";
|
|
3
|
+
import { validCronScheduleRule } from "./valid-cron-schedule.js";
|
|
4
|
+
export { validRegionRule } from "./valid-region.js";
|
|
5
|
+
export { noSecretLiteralsRule } from "./no-secret-literals.js";
|
|
6
|
+
export { validCronScheduleRule, isValidCronSchedule } from "./valid-cron-schedule.js";
|
|
7
|
+
/** All lint rules provided by the render lexicon. */
|
|
8
|
+
export const rules = [validRegionRule, noSecretLiteralsRule, validCronScheduleRule];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ts } from "@intentius/chant/lint/typescript";
|
|
2
|
+
/**
|
|
3
|
+
* Env-var keys that carry a credential. Kept conservative on purpose — an
|
|
4
|
+
* inline value under one of these keys is very likely a real secret.
|
|
5
|
+
*/
|
|
6
|
+
const SECRET_KEY_PATTERN = /(?:^|[_-])(?:password|passwd|secret|token|api[_-]?key|apikey|access[_-]?key|secret[_-]?key|private[_-]?key|client[_-]?secret|credential|auth[_-]?token)(?:$|[_-])/i;
|
|
7
|
+
/**
|
|
8
|
+
* Values that are references or placeholders, not literal secrets: shell/
|
|
9
|
+
* interpolation references ($FOO, ${FOO}) and secret-manager style references.
|
|
10
|
+
*/
|
|
11
|
+
const REFERENCE_VALUE_PATTERN = /^(?:\$\{?[A-Za-z0-9_]+\}?|(?:secret|ref|env|vault):\S+)$/;
|
|
12
|
+
/**
|
|
13
|
+
* REN002: No secret literals in env vars
|
|
14
|
+
*
|
|
15
|
+
* Flags an `envVars` (or `secretFiles`) entry whose `key` looks like a
|
|
16
|
+
* credential and whose `value` is a plain string literal. Render can generate
|
|
17
|
+
* the value (`generateValue: true`), it can come from an EnvGroup or from
|
|
18
|
+
* another resource's attribute (`db.internalConnectionString`), or from the
|
|
19
|
+
* process environment at build time — anything but a literal in source that
|
|
20
|
+
* ends up in git and in the build output.
|
|
21
|
+
*/
|
|
22
|
+
export const noSecretLiteralsRule = {
|
|
23
|
+
id: "REN002",
|
|
24
|
+
severity: "warning",
|
|
25
|
+
category: "security",
|
|
26
|
+
description: "Secret env-var values must not be written inline — use generateValue, an EnvGroup, or a reference",
|
|
27
|
+
check(context) {
|
|
28
|
+
const { sourceFile } = context;
|
|
29
|
+
const diagnostics = [];
|
|
30
|
+
function literalOf(init) {
|
|
31
|
+
return ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init) ? init.text : undefined;
|
|
32
|
+
}
|
|
33
|
+
function visit(node) {
|
|
34
|
+
// `{ key: "DB_PASSWORD", value: "hunter2" }` — an object literal with a
|
|
35
|
+
// secret-looking `key` and a literal `value`.
|
|
36
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
37
|
+
let key;
|
|
38
|
+
let valueNode;
|
|
39
|
+
for (const prop of node.properties) {
|
|
40
|
+
if (!ts.isPropertyAssignment(prop))
|
|
41
|
+
continue;
|
|
42
|
+
const name = prop.name.getText(sourceFile).replace(/^["']|["']$/g, "");
|
|
43
|
+
if (name === "key")
|
|
44
|
+
key = literalOf(prop.initializer);
|
|
45
|
+
if (name === "value")
|
|
46
|
+
valueNode = prop.initializer;
|
|
47
|
+
}
|
|
48
|
+
if (key && valueNode) {
|
|
49
|
+
const value = literalOf(valueNode);
|
|
50
|
+
if (value !== undefined && value.length >= 4 && SECRET_KEY_PATTERN.test(key) && !REFERENCE_VALUE_PATTERN.test(value.trim())) {
|
|
51
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(valueNode.getStart(sourceFile));
|
|
52
|
+
diagnostics.push({
|
|
53
|
+
file: sourceFile.fileName,
|
|
54
|
+
line: line + 1,
|
|
55
|
+
column: character + 1,
|
|
56
|
+
ruleId: "REN002",
|
|
57
|
+
severity: "warning",
|
|
58
|
+
message: `Possible inline secret for env var "${key}". Use \`generateValue: true\`, an EnvGroup, a resource attribute (e.g. db.internalConnectionString), or process.env — not a literal.`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
ts.forEachChild(node, visit);
|
|
64
|
+
}
|
|
65
|
+
visit(sourceFile);
|
|
66
|
+
return diagnostics;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ts } from "@intentius/chant/lint/typescript";
|
|
2
|
+
/** One cron field: `*`, a number, a range, a list, or any of those with a `/step`. */
|
|
3
|
+
const FIELD = String.raw `(?:\*|\d+(?:-\d+)?)(?:,(?:\*|\d+(?:-\d+)?))*(?:\/\d+)?`;
|
|
4
|
+
const CRON_5 = new RegExp(`^${FIELD}(?:\\s+${FIELD}){4}$`);
|
|
5
|
+
/** True for a standard five-field cron expression (Render accepts no seconds field or macros). */
|
|
6
|
+
export function isValidCronSchedule(s) {
|
|
7
|
+
return CRON_5.test(s.trim());
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* REN003: Valid cron schedule
|
|
11
|
+
*
|
|
12
|
+
* A CronJob's `schedule` must be a standard five-field cron expression
|
|
13
|
+
* (`minute hour day-of-month month day-of-week`). Render rejects `@hourly`-style
|
|
14
|
+
* macros and six-field (seconds) expressions at create time; catch them at build.
|
|
15
|
+
*/
|
|
16
|
+
export const validCronScheduleRule = {
|
|
17
|
+
id: "REN003",
|
|
18
|
+
severity: "error",
|
|
19
|
+
category: "correctness",
|
|
20
|
+
description: "A cron job schedule must be a five-field cron expression",
|
|
21
|
+
check(context) {
|
|
22
|
+
const { sourceFile } = context;
|
|
23
|
+
const diagnostics = [];
|
|
24
|
+
function visit(node) {
|
|
25
|
+
if (ts.isPropertyAssignment(node) && node.name.getText(sourceFile) === "schedule") {
|
|
26
|
+
const init = node.initializer;
|
|
27
|
+
if (ts.isStringLiteral(init) && !isValidCronSchedule(init.text)) {
|
|
28
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(init.getStart(sourceFile));
|
|
29
|
+
diagnostics.push({
|
|
30
|
+
file: sourceFile.fileName,
|
|
31
|
+
line: line + 1,
|
|
32
|
+
column: character + 1,
|
|
33
|
+
ruleId: "REN003",
|
|
34
|
+
severity: "error",
|
|
35
|
+
message: `Invalid cron schedule "${init.text}". Use five fields, e.g. "0 * * * *" (hourly) or "*/15 * * * *" (every 15 minutes); Render accepts no @macros or seconds field.`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
ts.forEachChild(node, visit);
|
|
40
|
+
}
|
|
41
|
+
visit(sourceFile);
|
|
42
|
+
return diagnostics;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ts } from "@intentius/chant/lint/typescript";
|
|
2
|
+
/**
|
|
3
|
+
* Render's regions, per the `region` enum in the Public API spec. The generated
|
|
4
|
+
* types already narrow `region` to this union, so a typo is a compile error in
|
|
5
|
+
* a typed project; this rule gives the same answer with a clearer message, and
|
|
6
|
+
* fires in contexts the type checker does not reach (a `.js` stack, a value
|
|
7
|
+
* that has been widened to `string`).
|
|
8
|
+
*/
|
|
9
|
+
export const KNOWN_REGIONS = new Set(["frankfurt", "oregon", "ohio", "singapore", "virginia"]);
|
|
10
|
+
/**
|
|
11
|
+
* REN001: Valid Render region
|
|
12
|
+
*
|
|
13
|
+
* A service, datastore, or Key Value `region` must be a Render region.
|
|
14
|
+
*/
|
|
15
|
+
export const validRegionRule = {
|
|
16
|
+
id: "REN001",
|
|
17
|
+
severity: "error",
|
|
18
|
+
category: "correctness",
|
|
19
|
+
description: "A region must be a Render region (frankfurt, oregon, ohio, singapore, virginia)",
|
|
20
|
+
check(context) {
|
|
21
|
+
const { sourceFile } = context;
|
|
22
|
+
const diagnostics = [];
|
|
23
|
+
function visit(node) {
|
|
24
|
+
if (ts.isPropertyAssignment(node) && node.name.getText(sourceFile) === "region") {
|
|
25
|
+
const init = node.initializer;
|
|
26
|
+
if (ts.isStringLiteral(init) && init.text.length > 0 && !KNOWN_REGIONS.has(init.text)) {
|
|
27
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(init.getStart(sourceFile));
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
file: sourceFile.fileName,
|
|
30
|
+
line: line + 1,
|
|
31
|
+
column: character + 1,
|
|
32
|
+
ruleId: "REN001",
|
|
33
|
+
severity: "error",
|
|
34
|
+
message: `Unknown Render region "${init.text}". Use one of: ${[...KNOWN_REGIONS].join(", ")}.`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
ts.forEachChild(node, visit);
|
|
39
|
+
}
|
|
40
|
+
visit(sourceFile);
|
|
41
|
+
return diagnostics;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"completions.d.ts","sourceRoot":"","sources":["../../src/lsp/completions.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"completions.d.ts","sourceRoot":"","sources":["../../src/lsp/completions.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAapF;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,iBAAiB,GAAG,cAAc,EAAE,CAEpE"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import { moduleSourceUrl } from "@intentius/chant/module-source";
|
|
3
|
+
import { LexiconIndex, lexiconCompletions } from "@intentius/chant/lsp/lexicon-providers";
|
|
4
|
+
const require = createRequire(moduleSourceUrl(import.meta.url));
|
|
5
|
+
let cachedIndex = null;
|
|
6
|
+
function getIndex() {
|
|
7
|
+
if (cachedIndex)
|
|
8
|
+
return cachedIndex;
|
|
9
|
+
const data = require("../generated/lexicon-render.json");
|
|
10
|
+
cachedIndex = new LexiconIndex(data);
|
|
11
|
+
return cachedIndex;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Provide render completions based on context — the resource classes
|
|
15
|
+
* (WebService, Postgres, EnvGroup, ...) and their property entities, from the
|
|
16
|
+
* generated registry.
|
|
17
|
+
*/
|
|
18
|
+
export function completions(ctx) {
|
|
19
|
+
return lexiconCompletions(ctx, getIndex(), "Render resource");
|
|
20
|
+
}
|
package/dist/lsp/hover.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hover.d.ts","sourceRoot":"","sources":["../../src/lsp/hover.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"hover.d.ts","sourceRoot":"","sources":["../../src/lsp/hover.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AAc1E;;GAEG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,YAAY,GAAG,SAAS,GAAG,SAAS,CAE9D"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import { moduleSourceUrl } from "@intentius/chant/module-source";
|
|
3
|
+
import { LexiconIndex, lexiconHover } from "@intentius/chant/lsp/lexicon-providers";
|
|
4
|
+
import { CATALOG } from "../catalog.js";
|
|
5
|
+
const require = createRequire(moduleSourceUrl(import.meta.url));
|
|
6
|
+
let cachedIndex = null;
|
|
7
|
+
function getIndex() {
|
|
8
|
+
if (cachedIndex)
|
|
9
|
+
return cachedIndex;
|
|
10
|
+
const data = require("../generated/lexicon-render.json");
|
|
11
|
+
cachedIndex = new LexiconIndex(data);
|
|
12
|
+
return cachedIndex;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Provide hover information for render entity types.
|
|
16
|
+
*/
|
|
17
|
+
export function hover(ctx) {
|
|
18
|
+
return lexiconHover(ctx, getIndex(), resourceHover);
|
|
19
|
+
}
|
|
20
|
+
function resourceHover(className, entry) {
|
|
21
|
+
const lines = [];
|
|
22
|
+
lines.push(`**${className}**`);
|
|
23
|
+
lines.push("");
|
|
24
|
+
lines.push(`Render type: \`${entry.resourceType}\``);
|
|
25
|
+
lines.push("");
|
|
26
|
+
const cat = CATALOG[entry.resourceType];
|
|
27
|
+
if (entry.kind === "resource" && cat) {
|
|
28
|
+
lines.push(`*Resource — created with \`POST ${cat.collection}\` on the Render Public API and reconciled by name*`);
|
|
29
|
+
if (cat.marked) {
|
|
30
|
+
lines.push("");
|
|
31
|
+
lines.push("Carries chant's `CHANT_MANAGED_BY` env-var ownership marker; eligible for owned-only prune.");
|
|
32
|
+
}
|
|
33
|
+
else if (cat.boundary === "service") {
|
|
34
|
+
lines.push("");
|
|
35
|
+
lines.push("Inherits its parent service's ownership verdict (service boundary); pruned when the service is chant-owned and this is undeclared.");
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
lines.push("");
|
|
39
|
+
lines.push("No ownership marker channel: verdict is `unknown`; never auto-pruned.");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else if (entry.kind === "resource") {
|
|
43
|
+
lines.push("*Resource — a Render Public API object*");
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
lines.push("*Property — a nested value in a Render resource declaration*");
|
|
47
|
+
}
|
|
48
|
+
return { contents: lines.join("\n") };
|
|
49
|
+
}
|
package/dist/manifest.json
CHANGED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory stand-in for the Render Public API, for tests. Implements the
|
|
3
|
+
* slice of routes the applier and the observation seam use — list/create/
|
|
4
|
+
* patch/delete for every collection in the catalog, service env vars, env
|
|
5
|
+
* group vars and links, deploys, connection-info, owners — with Render's list
|
|
6
|
+
* envelope (`[{ service: {...}, cursor }]`) and id prefixes. Not an emulator of
|
|
7
|
+
* Render's semantics beyond what chant relies on; it exists so the reconcile
|
|
8
|
+
* loop is tested end to end without an account.
|
|
9
|
+
*/
|
|
10
|
+
export class FakeRender {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.owners = [{ id: "tea-1", name: "Acme", email: "ops@acme.test", type: "team" }];
|
|
13
|
+
this.collections = {
|
|
14
|
+
"/services": { key: "service", prefix: "srv", items: new Map() },
|
|
15
|
+
"/postgres": { key: "postgres", prefix: "dpg", items: new Map() },
|
|
16
|
+
"/key-value": { key: "keyValue", prefix: "red", items: new Map() },
|
|
17
|
+
"/env-groups": { key: "envGroup", prefix: "evg", items: new Map() },
|
|
18
|
+
"/projects": { key: "project", prefix: "prj", items: new Map() },
|
|
19
|
+
"/environments": { key: "environment", prefix: "evm", items: new Map() },
|
|
20
|
+
"/disks": { key: "disk", prefix: "dsk", items: new Map() },
|
|
21
|
+
"/registrycredentials": { key: "registryCredential", prefix: "rgc", items: new Map() },
|
|
22
|
+
"/webhooks": { key: "webhook", prefix: "whk", items: new Map() },
|
|
23
|
+
};
|
|
24
|
+
/** serviceId → env vars */
|
|
25
|
+
this.serviceEnv = new Map();
|
|
26
|
+
/** serviceId → deploys (newest first) */
|
|
27
|
+
this.deploys = new Map();
|
|
28
|
+
/** serviceId → custom domains */
|
|
29
|
+
this.domains = new Map();
|
|
30
|
+
/** Every call, for assertions. */
|
|
31
|
+
this.calls = [];
|
|
32
|
+
/** Deploys go live after this many polls (0 = immediately). */
|
|
33
|
+
this.deployPolls = 0;
|
|
34
|
+
this.counter = 0;
|
|
35
|
+
}
|
|
36
|
+
id(prefix) {
|
|
37
|
+
this.counter++;
|
|
38
|
+
return `${prefix}-${String(this.counter).padStart(20, "0")}`;
|
|
39
|
+
}
|
|
40
|
+
/** Seed a resource directly. */
|
|
41
|
+
seed(collection, rec) {
|
|
42
|
+
const c = this.collections[collection];
|
|
43
|
+
const id = typeof rec.id === "string" ? rec.id : this.id(c.prefix);
|
|
44
|
+
const full = { id, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", ...rec };
|
|
45
|
+
c.items.set(id, full);
|
|
46
|
+
if (collection === "/services") {
|
|
47
|
+
this.serviceEnv.set(id, [...(rec.envVars ?? [])]);
|
|
48
|
+
this.deploys.set(id, [{ id: this.id("dep"), status: "live" }]);
|
|
49
|
+
}
|
|
50
|
+
return full;
|
|
51
|
+
}
|
|
52
|
+
service(name) {
|
|
53
|
+
return [...this.collections["/services"].items.values()].find((s) => s.name === name);
|
|
54
|
+
}
|
|
55
|
+
http() {
|
|
56
|
+
return async (method, url, body) => {
|
|
57
|
+
const u = new URL(url);
|
|
58
|
+
const path = u.pathname.replace(/^\/v1/, "");
|
|
59
|
+
this.calls.push({ method, path, body });
|
|
60
|
+
return this.route(method, path, u.searchParams, body);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
json(status, value) {
|
|
64
|
+
return { status, text: value === undefined ? "" : JSON.stringify(value) };
|
|
65
|
+
}
|
|
66
|
+
route(method, path, q, body) {
|
|
67
|
+
const b = (body ?? {});
|
|
68
|
+
if (path === "/owners" && method === "GET") {
|
|
69
|
+
return this.json(200, this.owners.map((o) => ({ owner: o, cursor: String(o.id) })));
|
|
70
|
+
}
|
|
71
|
+
// Service sub-resources.
|
|
72
|
+
let m = path.match(/^\/services\/([^/]+)\/env-vars$/);
|
|
73
|
+
if (m) {
|
|
74
|
+
const id = m[1];
|
|
75
|
+
if (!this.collections["/services"].items.has(id))
|
|
76
|
+
return this.json(404, { message: "not found" });
|
|
77
|
+
if (method === "GET") {
|
|
78
|
+
return this.json(200, (this.serviceEnv.get(id) ?? []).map((e) => ({ envVar: e, cursor: e.key })));
|
|
79
|
+
}
|
|
80
|
+
if (method === "PUT") {
|
|
81
|
+
const list = body.map((e) => ({
|
|
82
|
+
key: e.key,
|
|
83
|
+
value: e.value ?? (e.generateValue ? `gen-${e.key}` : ""),
|
|
84
|
+
}));
|
|
85
|
+
this.serviceEnv.set(id, list);
|
|
86
|
+
return this.json(200, list.map((e) => ({ envVar: e, cursor: e.key })));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
m = path.match(/^\/services\/([^/]+)\/deploys$/);
|
|
90
|
+
if (m && method === "GET") {
|
|
91
|
+
const list = this.deploys.get(m[1]) ?? [];
|
|
92
|
+
return this.json(200, list.map((d) => ({ deploy: this.tickDeploy(d), cursor: d.id })));
|
|
93
|
+
}
|
|
94
|
+
m = path.match(/^\/services\/([^/]+)\/deploys\/([^/]+)$/);
|
|
95
|
+
if (m && method === "GET") {
|
|
96
|
+
const d = (this.deploys.get(m[1]) ?? []).find((x) => x.id === m[2]);
|
|
97
|
+
if (!d)
|
|
98
|
+
return this.json(404, { message: "not found" });
|
|
99
|
+
return this.json(200, this.tickDeploy(d));
|
|
100
|
+
}
|
|
101
|
+
m = path.match(/^\/services\/([^/]+)\/custom-domains$/);
|
|
102
|
+
if (m) {
|
|
103
|
+
const id = m[1];
|
|
104
|
+
if (!this.collections["/services"].items.has(id))
|
|
105
|
+
return this.json(404, { message: "not found" });
|
|
106
|
+
const list = this.domains.get(id) ?? [];
|
|
107
|
+
if (method === "GET")
|
|
108
|
+
return this.json(200, list.map((d) => ({ customDomain: d, cursor: d.id })));
|
|
109
|
+
if (method === "POST") {
|
|
110
|
+
const d = { id: this.id("cd"), name: b.name, domainType: "apex", verificationStatus: "unverified", createdAt: "2026-01-01T00:00:00Z" };
|
|
111
|
+
list.push(d);
|
|
112
|
+
this.domains.set(id, list);
|
|
113
|
+
return this.json(201, [d]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
m = path.match(/^\/services\/([^/]+)\/custom-domains\/([^/]+)$/);
|
|
117
|
+
if (m && method === "DELETE") {
|
|
118
|
+
const list = this.domains.get(m[1]) ?? [];
|
|
119
|
+
const idx = list.findIndex((d) => d.id === m[2] || d.name === m[2]);
|
|
120
|
+
if (idx < 0)
|
|
121
|
+
return this.json(404, {});
|
|
122
|
+
list.splice(idx, 1);
|
|
123
|
+
return this.json(204, undefined);
|
|
124
|
+
}
|
|
125
|
+
// Datastore connection info.
|
|
126
|
+
m = path.match(/^\/(postgres|key-value)\/([^/]+)\/connection-info$/);
|
|
127
|
+
if (m && method === "GET") {
|
|
128
|
+
const c = this.collections[`/${m[1]}`];
|
|
129
|
+
const rec = c.items.get(m[2]);
|
|
130
|
+
if (!rec)
|
|
131
|
+
return this.json(404, {});
|
|
132
|
+
return this.json(200, {
|
|
133
|
+
internalConnectionString: `internal://${rec.name}`,
|
|
134
|
+
externalConnectionString: `external://${rec.name}`,
|
|
135
|
+
psqlCommand: `psql ${rec.name}`,
|
|
136
|
+
cliCommand: `redis-cli ${rec.name}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
// Env group vars and links.
|
|
140
|
+
m = path.match(/^\/env-groups\/([^/]+)\/env-vars\/([^/]+)$/);
|
|
141
|
+
if (m) {
|
|
142
|
+
const grp = this.collections["/env-groups"].items.get(m[1]);
|
|
143
|
+
if (!grp)
|
|
144
|
+
return this.json(404, {});
|
|
145
|
+
const vars = grp.envVars ?? [];
|
|
146
|
+
const key = decodeURIComponent(m[2]);
|
|
147
|
+
if (method === "PUT") {
|
|
148
|
+
const value = typeof b.value === "string" ? b.value : `gen-${key}`;
|
|
149
|
+
const idx = vars.findIndex((v) => v.key === key);
|
|
150
|
+
if (idx >= 0)
|
|
151
|
+
vars[idx] = { key, value };
|
|
152
|
+
else
|
|
153
|
+
vars.push({ key, value });
|
|
154
|
+
grp.envVars = vars;
|
|
155
|
+
return this.json(200, { key, value });
|
|
156
|
+
}
|
|
157
|
+
if (method === "DELETE") {
|
|
158
|
+
grp.envVars = vars.filter((v) => v.key !== key);
|
|
159
|
+
return this.json(204, undefined);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
m = path.match(/^\/env-groups\/([^/]+)\/services\/([^/]+)$/);
|
|
163
|
+
if (m && method === "POST") {
|
|
164
|
+
const grp = this.collections["/env-groups"].items.get(m[1]);
|
|
165
|
+
if (!grp)
|
|
166
|
+
return this.json(404, {});
|
|
167
|
+
const links = grp.serviceLinks ?? [];
|
|
168
|
+
if (!links.some((l) => l.id === m[2]))
|
|
169
|
+
links.push({ id: m[2], name: m[2], type: "web_service" });
|
|
170
|
+
grp.serviceLinks = links;
|
|
171
|
+
return this.json(200, grp);
|
|
172
|
+
}
|
|
173
|
+
// Collections.
|
|
174
|
+
for (const [collection, c] of Object.entries(this.collections)) {
|
|
175
|
+
if (path === collection) {
|
|
176
|
+
if (method === "GET") {
|
|
177
|
+
let items = [...c.items.values()];
|
|
178
|
+
const name = q.get("name");
|
|
179
|
+
const ownerId = q.get("ownerId");
|
|
180
|
+
const projectId = q.get("projectId");
|
|
181
|
+
const serviceId = q.get("serviceId");
|
|
182
|
+
if (name)
|
|
183
|
+
items = items.filter((i) => i.name === name);
|
|
184
|
+
// Disks/domains carry no ownerId field on the wire; Render scopes them
|
|
185
|
+
// server-side. The fake has one owner, so only filter what has one.
|
|
186
|
+
if (ownerId)
|
|
187
|
+
items = items.filter((i) => (i.ownerId === undefined && i.owner === undefined) || i.ownerId === ownerId || i.owner?.id === ownerId);
|
|
188
|
+
if (projectId)
|
|
189
|
+
items = items.filter((i) => i.projectId === projectId);
|
|
190
|
+
if (serviceId)
|
|
191
|
+
items = items.filter((i) => i.serviceId === serviceId);
|
|
192
|
+
return this.json(200, items.map((i) => ({ [c.key]: i, cursor: String(i.id) })));
|
|
193
|
+
}
|
|
194
|
+
if (method === "POST") {
|
|
195
|
+
const id = this.id(c.prefix);
|
|
196
|
+
const rec = { ...b, id, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" };
|
|
197
|
+
if (collection === "/services") {
|
|
198
|
+
const env = (b.envVars ?? []).map((e) => ({
|
|
199
|
+
key: e.key,
|
|
200
|
+
value: e.value ?? `gen-${e.key}`,
|
|
201
|
+
}));
|
|
202
|
+
delete rec.envVars;
|
|
203
|
+
delete rec.secretFiles;
|
|
204
|
+
rec.dashboardUrl = `https://dashboard.render.com/web/${id}`;
|
|
205
|
+
rec.slug = String(b.name);
|
|
206
|
+
rec.suspended = "not_suspended";
|
|
207
|
+
this.serviceEnv.set(id, env);
|
|
208
|
+
const dep = { id: this.id("dep"), status: this.deployPolls > 0 ? "build_in_progress" : "live" };
|
|
209
|
+
this.deploys.set(id, [dep]);
|
|
210
|
+
c.items.set(id, rec);
|
|
211
|
+
return this.json(201, { service: rec, deployId: dep.id });
|
|
212
|
+
}
|
|
213
|
+
if (collection === "/env-groups") {
|
|
214
|
+
rec.envVars = (b.envVars ?? []).map((e) => ({
|
|
215
|
+
key: e.key,
|
|
216
|
+
value: e.value ?? `gen-${e.key}`,
|
|
217
|
+
}));
|
|
218
|
+
rec.serviceLinks = (b.serviceIds ?? []).map((sid) => ({ id: sid, name: sid, type: "web_service" }));
|
|
219
|
+
delete rec.serviceIds;
|
|
220
|
+
}
|
|
221
|
+
if (collection === "/projects") {
|
|
222
|
+
rec.owner = { id: b.ownerId, name: "Acme", email: "", type: "team" };
|
|
223
|
+
rec.environmentIds = [];
|
|
224
|
+
}
|
|
225
|
+
if (collection === "/postgres" || collection === "/key-value") {
|
|
226
|
+
rec.owner = { id: b.ownerId, name: "Acme", email: "", type: "team" };
|
|
227
|
+
rec.status = "available";
|
|
228
|
+
rec.dashboardUrl = `https://dashboard.render.com/d/${id}`;
|
|
229
|
+
}
|
|
230
|
+
c.items.set(id, rec);
|
|
231
|
+
return this.json(201, rec);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const one = path.match(new RegExp(`^${collection.replace(/[-/]/g, "\\$&")}/([^/]+)$`));
|
|
235
|
+
if (one) {
|
|
236
|
+
const id = one[1];
|
|
237
|
+
const rec = c.items.get(id);
|
|
238
|
+
if (!rec)
|
|
239
|
+
return this.json(404, { message: "not found" });
|
|
240
|
+
if (method === "GET")
|
|
241
|
+
return this.json(200, rec);
|
|
242
|
+
if (method === "PATCH") {
|
|
243
|
+
const updated = { ...rec, ...b, updatedAt: "2026-01-02T00:00:00Z" };
|
|
244
|
+
if (rec.serviceDetails && b.serviceDetails) {
|
|
245
|
+
updated.serviceDetails = { ...rec.serviceDetails, ...b.serviceDetails };
|
|
246
|
+
}
|
|
247
|
+
c.items.set(id, updated);
|
|
248
|
+
if (collection === "/services") {
|
|
249
|
+
const deps = this.deploys.get(id) ?? [];
|
|
250
|
+
deps.unshift({ id: this.id("dep"), status: "live" });
|
|
251
|
+
}
|
|
252
|
+
return this.json(200, updated);
|
|
253
|
+
}
|
|
254
|
+
if (method === "DELETE") {
|
|
255
|
+
c.items.delete(id);
|
|
256
|
+
return this.json(204, undefined);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return this.json(404, { message: `fake-render: no route for ${method} ${path}` });
|
|
261
|
+
}
|
|
262
|
+
/** Advance a pending deploy toward `live` on each poll. */
|
|
263
|
+
tickDeploy(d) {
|
|
264
|
+
if (d.status !== "live" && this.deployPolls > 0) {
|
|
265
|
+
this.deployPolls--;
|
|
266
|
+
if (this.deployPolls === 0)
|
|
267
|
+
d.status = "live";
|
|
268
|
+
}
|
|
269
|
+
return { ...d };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render Op activities — resolved by the core activity registry when a
|
|
3
|
+
* project's `chant.config.ts` lists the `render` lexicon. Contributes the
|
|
4
|
+
* native Render API applier (`renderApply`), which speaks the Render Public
|
|
5
|
+
* API directly (no CLI, no Blueprint, no state file), and its teardown twin
|
|
6
|
+
* (`renderDelete`).
|
|
7
|
+
*/
|
|
8
|
+
export { renderApply, renderApplyDetailed, renderDelete, toApplyResult, resolveEndpoint, parsePlan, orderPlan, collectDependencies, configEqual, diffForPatch, envVarsToMap, declaredEnvVarsToMap, envVarsDiffer, isChantOwned, ownershipOf, planOwnership, inStack, defaultRenderHttp, listAll, getOne, resolveOwner, findExisting, resolveAttribute, resolveMarkers, create, patch, remove, readServiceEnvVars, putServiceEnvVars, reconcileEnvGroupVars, linkEnvGroupServices, latestDeploy, waitForDeploy, DEFAULT_RENDER_BASE_URL, } from "./render-apply.js";
|