@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,366 @@
|
|
|
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
|
+
|
|
34
|
+
import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
|
|
35
|
+
import { observation, unobservedAll } from "@intentius/chant/observation";
|
|
36
|
+
import { CATALOG, ENTITY_TYPES, ENTITY_TYPE_OF_SERVICE, catalogEntry, isServiceEntityType } from "./catalog";
|
|
37
|
+
import { isOwnerMarker, isRefMarker, type RenderPlan, type RenderRequest } from "./serializer";
|
|
38
|
+
import {
|
|
39
|
+
resolveEndpoint,
|
|
40
|
+
defaultRenderHttp,
|
|
41
|
+
parsePlan,
|
|
42
|
+
orderPlan,
|
|
43
|
+
listAll,
|
|
44
|
+
getOne,
|
|
45
|
+
readServiceEnvVars,
|
|
46
|
+
envVarsToMap,
|
|
47
|
+
isChantOwned,
|
|
48
|
+
resolveOwner,
|
|
49
|
+
type RenderHttp,
|
|
50
|
+
type ApplyCtx,
|
|
51
|
+
} from "./op/activities/render-apply";
|
|
52
|
+
|
|
53
|
+
export interface DescribeResourcesOptions {
|
|
54
|
+
environment: string;
|
|
55
|
+
buildOutput: string;
|
|
56
|
+
entityNames: string[];
|
|
57
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
58
|
+
/** Restrict to chant-owned resources (#119). */
|
|
59
|
+
owned?: boolean;
|
|
60
|
+
/** API endpoint override (tests). Defaults to resolveEndpoint() → RENDER_API_BASE_URL. */
|
|
61
|
+
endpoint?: string;
|
|
62
|
+
/** Workspace override (tests). Defaults to RENDER_OWNER_ID, else the sole visible owner. */
|
|
63
|
+
ownerId?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Live service/datastore states that mean "gone or going" — not live. */
|
|
67
|
+
const NOT_LIVE = new Set(["deleted", "deleting"]);
|
|
68
|
+
|
|
69
|
+
/** Live resource → chant status string. */
|
|
70
|
+
function statusOf(rec: Record<string, unknown>): string {
|
|
71
|
+
if (typeof rec.status === "string") return rec.status; // datastores
|
|
72
|
+
if (rec.suspended === "suspended") return "suspended";
|
|
73
|
+
if (typeof rec.verificationStatus === "string") return rec.verificationStatus; // custom domains
|
|
74
|
+
return "deployed";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* List live Render resources and return them keyed by chant entity name, each
|
|
79
|
+
* tagged with an ownership verdict the core change set reads to decide whether
|
|
80
|
+
* an orphan is a delete (owned) or an adopt candidate (foreign). Reused by
|
|
81
|
+
* `renderPlugin.describeResources`.
|
|
82
|
+
*
|
|
83
|
+
* `http` is injectable for tests; the default reuses the applier's fetch client
|
|
84
|
+
* (bearer token from RENDER_API_KEY).
|
|
85
|
+
*/
|
|
86
|
+
export async function describeResources(
|
|
87
|
+
options: DescribeResourcesOptions,
|
|
88
|
+
http: RenderHttp = defaultRenderHttp(),
|
|
89
|
+
signal?: AbortSignal,
|
|
90
|
+
): Promise<ObservationResult> {
|
|
91
|
+
const result: Record<string, ResourceMetadata> = {};
|
|
92
|
+
const unobserved: Record<string, UnobservedEntity> = {};
|
|
93
|
+
|
|
94
|
+
// Without a readable plan there is nothing to key a read by, so chant never
|
|
95
|
+
// asks Render anything — every declared entity is unobserved, not absent.
|
|
96
|
+
if (!options.buildOutput) {
|
|
97
|
+
return observation(
|
|
98
|
+
result,
|
|
99
|
+
unobservedAll(options.entities.keys(), "read-failed", "no render plan in the build output to read back", options.entities),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let plan: RenderPlan;
|
|
104
|
+
try {
|
|
105
|
+
plan = parsePlan(options.buildOutput);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return observation(
|
|
108
|
+
result,
|
|
109
|
+
unobservedAll(
|
|
110
|
+
options.entities.keys(),
|
|
111
|
+
"read-failed",
|
|
112
|
+
`build output is not a readable render plan: ${err instanceof Error ? err.message : String(err)}`,
|
|
113
|
+
options.entities,
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ctx: ApplyCtx = { base: resolveEndpoint({ endpoint: options.endpoint }) };
|
|
119
|
+
const owned = options.owned ?? false;
|
|
120
|
+
const typeOf = (entityName: string, fallback: string): string =>
|
|
121
|
+
options.entities.get(entityName)?.entityType ?? fallback;
|
|
122
|
+
|
|
123
|
+
// The workspace: needed to scope every list. Resolved once; a failure here
|
|
124
|
+
// is a whole-read failure (no credentials / no workspace), reported per entity.
|
|
125
|
+
let ownerId: string;
|
|
126
|
+
try {
|
|
127
|
+
ownerId = await resolveOwner(ctx, { ownerId: options.ownerId }, http, signal);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return observation(
|
|
130
|
+
result,
|
|
131
|
+
unobservedAll(options.entities.keys(), "no-credentials", err instanceof Error ? err.message : String(err), options.entities),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let ordered: Array<[string, RenderRequest]>;
|
|
136
|
+
try {
|
|
137
|
+
ordered = orderPlan(plan);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return observation(
|
|
140
|
+
result,
|
|
141
|
+
unobservedAll(options.entities.keys(), "read-failed", err instanceof Error ? err.message : String(err), options.entities),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Live ids by entity, so a child's `{ $ref }` (Disk.serviceId, Environment.
|
|
146
|
+
// projectId, CustomDomain path) resolves to the parent's live id when the
|
|
147
|
+
// parent exists. A missing parent means the child cannot exist yet — it is
|
|
148
|
+
// reported absent, which is exactly the create the plan should propose.
|
|
149
|
+
const liveId = new Map<string, string>();
|
|
150
|
+
// Service verdicts by live id, so a disk/domain can inherit its parent's.
|
|
151
|
+
const serviceVerdict = new Map<string, "owned" | "foreign">();
|
|
152
|
+
// Declared children by `${serviceId}:${name}`, for the orphan scan.
|
|
153
|
+
const declaredChildren = new Set<string>();
|
|
154
|
+
|
|
155
|
+
// Declared services and env groups by natural key, so the workspace-wide
|
|
156
|
+
// marker scan below can tell an orphan from a declared one.
|
|
157
|
+
const declaredServices = new Set<string>();
|
|
158
|
+
const declaredGroups = new Set<string>();
|
|
159
|
+
|
|
160
|
+
let loggedUnknown = false;
|
|
161
|
+
const noteUnknown = (): void => {
|
|
162
|
+
if (loggedUnknown || !owned) return;
|
|
163
|
+
loggedUnknown = true;
|
|
164
|
+
console.warn(
|
|
165
|
+
"[render] datastores, projects, environments, registry credentials and webhooks carry no ownership marker — their verdict is unknown; --owned withholds them rather than guessing",
|
|
166
|
+
);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
for (const [entityName, req] of ordered) {
|
|
170
|
+
const entry = catalogEntry(req.entityType);
|
|
171
|
+
if (isServiceEntityType(req.entityType)) declaredServices.add(`${req.body.type}:${req.name}`);
|
|
172
|
+
if (req.entityType === ENTITY_TYPES.envGroup) declaredGroups.add(req.name);
|
|
173
|
+
|
|
174
|
+
// Resolve the path/body references this read depends on.
|
|
175
|
+
let endpoint = req.endpoint;
|
|
176
|
+
let unresolved = false;
|
|
177
|
+
for (const [k, v] of Object.entries(req.pathParams ?? {})) {
|
|
178
|
+
const id = isRefMarker(v) ? liveId.get(v.$ref) : typeof v === "string" ? v : undefined;
|
|
179
|
+
if (!id) {
|
|
180
|
+
unresolved = true;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
endpoint = endpoint.replace(`{${k}}`, encodeURIComponent(id));
|
|
184
|
+
}
|
|
185
|
+
if (unresolved) continue; // parent absent → child absent (in neither map)
|
|
186
|
+
|
|
187
|
+
const query: Record<string, string | undefined> = {};
|
|
188
|
+
if (entry.filters.name) query.name = req.name;
|
|
189
|
+
if (entry.filters.ownerId) query.ownerId = typeof req.body.ownerId === "string" && !isOwnerMarker(req.body.ownerId) ? req.body.ownerId : ownerId;
|
|
190
|
+
let scopeId: string | undefined;
|
|
191
|
+
if (req.entityType === ENTITY_TYPES.environment) {
|
|
192
|
+
scopeId = isRefMarker(req.body.projectId) ? liveId.get(req.body.projectId.$ref) : typeof req.body.projectId === "string" ? req.body.projectId : undefined;
|
|
193
|
+
if (!scopeId) continue;
|
|
194
|
+
query.projectId = scopeId;
|
|
195
|
+
}
|
|
196
|
+
if (req.entityType === ENTITY_TYPES.disk) {
|
|
197
|
+
scopeId = isRefMarker(req.body.serviceId) ? liveId.get(req.body.serviceId.$ref) : typeof req.body.serviceId === "string" ? req.body.serviceId : undefined;
|
|
198
|
+
if (!scopeId) continue;
|
|
199
|
+
query.serviceId = scopeId;
|
|
200
|
+
}
|
|
201
|
+
const listPath = req.entityType === ENTITY_TYPES.customDomain ? endpoint : entry.collection;
|
|
202
|
+
|
|
203
|
+
let candidates: Record<string, unknown>[];
|
|
204
|
+
try {
|
|
205
|
+
candidates = await listAll(ctx, listPath, query, entry.listKey, http, signal);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
unobserved[entityName] = {
|
|
208
|
+
type: req.entityType,
|
|
209
|
+
reason: "read-failed",
|
|
210
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
211
|
+
queried: `${ctx.base}${listPath}`,
|
|
212
|
+
};
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const live = candidates.find((c) => {
|
|
216
|
+
if (c.name !== req.name) return false;
|
|
217
|
+
if (isServiceEntityType(req.entityType) && c.type !== req.body.type) return false;
|
|
218
|
+
if (scopeId && req.entityType === ENTITY_TYPES.environment && c.projectId !== scopeId) return false;
|
|
219
|
+
if (scopeId && req.entityType === ENTITY_TYPES.disk && c.serviceId !== scopeId) return false;
|
|
220
|
+
return true;
|
|
221
|
+
});
|
|
222
|
+
if (!live || (typeof live.status === "string" && NOT_LIVE.has(live.status))) continue; // absent
|
|
223
|
+
|
|
224
|
+
const id = typeof live.id === "string" ? live.id : undefined;
|
|
225
|
+
if (id) liveId.set(entityName, id);
|
|
226
|
+
|
|
227
|
+
// Ownership verdict: the marker for services and env groups; the parent
|
|
228
|
+
// service's verdict for disks and custom domains (service boundary);
|
|
229
|
+
// unknown for the rest.
|
|
230
|
+
let ownership: ResourceMetadata["ownership"];
|
|
231
|
+
if (entry.marked && id) {
|
|
232
|
+
let env: Record<string, string>;
|
|
233
|
+
if (isServiceEntityType(req.entityType)) {
|
|
234
|
+
env = await readServiceEnvVars(ctx, id, http, signal);
|
|
235
|
+
} else {
|
|
236
|
+
const full = (await getOne(ctx, `${entry.collection}/${id}`, http, signal)) ?? live;
|
|
237
|
+
env = envVarsToMap(full.envVars);
|
|
238
|
+
}
|
|
239
|
+
ownership = isChantOwned(env) ? "owned" : "foreign";
|
|
240
|
+
if (isServiceEntityType(req.entityType)) serviceVerdict.set(id, ownership);
|
|
241
|
+
} else if (entry.boundary === "service") {
|
|
242
|
+
const raw = req.entityType === ENTITY_TYPES.customDomain ? req.pathParams?.serviceId : req.body.serviceId;
|
|
243
|
+
const parentId = isRefMarker(raw) ? liveId.get(raw.$ref) : typeof raw === "string" ? raw : undefined;
|
|
244
|
+
if (parentId) declaredChildren.add(`${parentId}:${req.name}`);
|
|
245
|
+
let verdict = parentId ? serviceVerdict.get(parentId) : undefined;
|
|
246
|
+
if (!verdict && parentId) {
|
|
247
|
+
// Parent named by literal id (not declared here): read its marker.
|
|
248
|
+
verdict = isChantOwned(await readServiceEnvVars(ctx, parentId, http, signal)) ? "owned" : "foreign";
|
|
249
|
+
serviceVerdict.set(parentId, verdict);
|
|
250
|
+
}
|
|
251
|
+
ownership = verdict ?? "unknown";
|
|
252
|
+
} else {
|
|
253
|
+
ownership = "unknown";
|
|
254
|
+
noteUnknown();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (owned && ownership !== "owned") {
|
|
258
|
+
unobserved[entityName] = {
|
|
259
|
+
type: req.entityType,
|
|
260
|
+
reason: "filtered",
|
|
261
|
+
detail:
|
|
262
|
+
ownership === "foreign"
|
|
263
|
+
? "live resource carries no chant ownership marker and --owned was requested"
|
|
264
|
+
: "resource kind has no ownership marker channel and --owned was requested",
|
|
265
|
+
};
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
result[entityName] = {
|
|
270
|
+
type: typeOf(entityName, req.entityType),
|
|
271
|
+
physicalId: id,
|
|
272
|
+
status: statusOf(live),
|
|
273
|
+
lastUpdated: typeof live.updatedAt === "string" ? live.updatedAt : undefined,
|
|
274
|
+
ownership,
|
|
275
|
+
attributes: pruneUndefined({
|
|
276
|
+
id,
|
|
277
|
+
name: live.name,
|
|
278
|
+
dashboardUrl: live.dashboardUrl,
|
|
279
|
+
type: live.type,
|
|
280
|
+
region: (live.serviceDetails as Record<string, unknown> | undefined)?.region ?? live.region,
|
|
281
|
+
environmentId: live.environmentId,
|
|
282
|
+
projectId: live.projectId,
|
|
283
|
+
serviceId: live.serviceId,
|
|
284
|
+
}),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Orphans: chant-marked services and env groups in the workspace that the
|
|
289
|
+
// plan does not declare — delete candidates for the change set.
|
|
290
|
+
try {
|
|
291
|
+
for (const svc of await listAll(ctx, "/services", { ownerId }, "service", http, signal)) {
|
|
292
|
+
const id = typeof svc.id === "string" ? svc.id : undefined;
|
|
293
|
+
const name = typeof svc.name === "string" ? svc.name : "";
|
|
294
|
+
const type = typeof svc.type === "string" ? svc.type : "";
|
|
295
|
+
if (!id) continue;
|
|
296
|
+
const declared = declaredServices.has(`${type}:${name}`);
|
|
297
|
+
const verdict = serviceVerdict.get(id) ?? (isChantOwned(await readServiceEnvVars(ctx, id, http, signal)) ? "owned" : "foreign");
|
|
298
|
+
if (verdict !== "owned") continue;
|
|
299
|
+
if (!declared) {
|
|
300
|
+
const entityType = ENTITY_TYPE_OF_SERVICE[type] ?? ENTITY_TYPES.webService;
|
|
301
|
+
result[`${CATALOG[entityType].kind}/${name}`] = {
|
|
302
|
+
type: entityType,
|
|
303
|
+
physicalId: id,
|
|
304
|
+
status: statusOf(svc),
|
|
305
|
+
lastUpdated: typeof svc.updatedAt === "string" ? svc.updatedAt : undefined,
|
|
306
|
+
ownership: "owned",
|
|
307
|
+
attributes: pruneUndefined({ id, name, dashboardUrl: svc.dashboardUrl, type }),
|
|
308
|
+
};
|
|
309
|
+
continue; // its children go with it
|
|
310
|
+
}
|
|
311
|
+
// Service boundary: undeclared disks and custom domains under a declared
|
|
312
|
+
// owned service are owned orphans too.
|
|
313
|
+
for (const disk of await listAll(ctx, "/disks", { serviceId: id, ownerId }, "disk", http, signal)) {
|
|
314
|
+
const diskId = typeof disk.id === "string" ? disk.id : undefined;
|
|
315
|
+
const diskName = typeof disk.name === "string" ? disk.name : "";
|
|
316
|
+
if (!diskId || disk.serviceId !== id || declaredChildren.has(`${id}:${diskName}`)) continue;
|
|
317
|
+
result[`Disk/${diskName}`] = {
|
|
318
|
+
type: ENTITY_TYPES.disk,
|
|
319
|
+
physicalId: diskId,
|
|
320
|
+
status: "deployed",
|
|
321
|
+
ownership: "owned",
|
|
322
|
+
attributes: pruneUndefined({ id: diskId, name: diskName, serviceId: id, mountPath: disk.mountPath, sizeGB: disk.sizeGB }),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
for (const domain of await listAll(ctx, `/services/${id}/custom-domains`, {}, "customDomain", http, signal)) {
|
|
326
|
+
const domainId = typeof domain.id === "string" ? domain.id : undefined;
|
|
327
|
+
const domainName = typeof domain.name === "string" ? domain.name : "";
|
|
328
|
+
if (!domainId || declaredChildren.has(`${id}:${domainName}`)) continue;
|
|
329
|
+
result[`CustomDomain/${domainName}`] = {
|
|
330
|
+
type: ENTITY_TYPES.customDomain,
|
|
331
|
+
physicalId: domainId,
|
|
332
|
+
status: statusOf(domain),
|
|
333
|
+
ownership: "owned",
|
|
334
|
+
attributes: pruneUndefined({ id: domainId, name: domainName, serviceId: id }),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
for (const grp of await listAll(ctx, "/env-groups", { ownerId }, "envGroup", http, signal)) {
|
|
339
|
+
const id = typeof grp.id === "string" ? grp.id : undefined;
|
|
340
|
+
const name = typeof grp.name === "string" ? grp.name : "";
|
|
341
|
+
if (!id || declaredGroups.has(name)) continue;
|
|
342
|
+
const full = (await getOne(ctx, `/env-groups/${id}`, http, signal)) ?? grp;
|
|
343
|
+
if (!isChantOwned(envVarsToMap(full.envVars))) continue;
|
|
344
|
+
result[`EnvGroup/${name}`] = {
|
|
345
|
+
type: ENTITY_TYPES.envGroup,
|
|
346
|
+
physicalId: id,
|
|
347
|
+
status: "deployed",
|
|
348
|
+
lastUpdated: typeof grp.updatedAt === "string" ? grp.updatedAt : undefined,
|
|
349
|
+
ownership: "owned",
|
|
350
|
+
attributes: { id, name },
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
} catch (err) {
|
|
354
|
+
console.warn(`[render] orphan scan failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return observation(result, unobserved);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function pruneUndefined(obj: Record<string, unknown>): Record<string, unknown> {
|
|
361
|
+
const out: Record<string, unknown> = {};
|
|
362
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
363
|
+
if (v !== undefined) out[k] = v;
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
package/src/detect.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
|
|
10
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
11
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A single request as the serializer emits it. */
|
|
15
|
+
function isRenderRequest(v: unknown): boolean {
|
|
16
|
+
if (!isRecord(v)) return false;
|
|
17
|
+
return typeof v.entityType === "string" && v.entityType.startsWith("Render::") && typeof v.endpoint === "string" && "body" in v;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The serializer's whole output: entity name → request. */
|
|
21
|
+
export function isSerializerPlan(v: unknown): boolean {
|
|
22
|
+
if (!isRecord(v)) return false;
|
|
23
|
+
const values = Object.values(v);
|
|
24
|
+
return values.length > 0 && values.every(isRenderRequest);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function detectTemplate(data: unknown): boolean {
|
|
28
|
+
return isSerializerPlan(data);
|
|
29
|
+
}
|