@intentius/chant-lexicon-cpln 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/api.js +88 -0
- package/dist/codegen/docs-cli.js +6 -0
- package/dist/codegen/docs.js +85 -0
- package/dist/codegen/generate-cli.js +25 -0
- package/dist/codegen/generate-lexicon.js +50 -0
- package/dist/codegen/generate-typescript.js +48 -0
- package/dist/codegen/generate.js +86 -0
- package/dist/codegen/naming.js +43 -0
- package/dist/codegen/package.js +31 -0
- package/dist/composites/catalog.js +556 -0
- package/dist/composites/cron-job.js +52 -0
- package/dist/composites/gvc-environment.js +49 -0
- package/dist/composites/index.js +6 -0
- package/dist/composites/public-domain.js +70 -0
- package/dist/composites/secret-access.js +82 -0
- package/dist/composites/serverless-service.js +58 -0
- package/dist/composites/stateful-service.js +87 -0
- package/dist/coverage.js +104 -0
- package/dist/describe-resources.js +264 -0
- package/dist/detect.js +52 -0
- package/dist/entity-props.js +100 -0
- package/dist/generated/index.js +169 -0
- package/dist/generated/runtime.js +4 -0
- package/dist/index.js +14 -0
- package/dist/init-templates.js +125 -0
- package/dist/integrity.json +2 -2
- package/dist/kinds.js +134 -0
- package/dist/lint/audit-catalog.js +90 -0
- package/dist/lint/audit-lineage.js +32 -0
- package/dist/lint/post-synth/cpl010-unrestricted-outbound.js +34 -0
- package/dist/lint/post-synth/cpl011-internal-firewall-scope.js +32 -0
- package/dist/lint/post-synth/cpl012-no-literal-credential-env.js +51 -0
- package/dist/lint/post-synth/cpl013-qualified-identity-principal.js +40 -0
- package/dist/lint/post-synth/cpl014-qualified-secret-ref.js +47 -0
- package/dist/lint/post-synth/cpl020-serverless-single-port.js +51 -0
- package/dist/lint/post-synth/cpl021-cron-job-shape.js +55 -0
- package/dist/lint/post-synth/cpl022-container-ports.js +53 -0
- package/dist/lint/post-synth/cpl023-container-resources.js +73 -0
- package/dist/lint/post-synth/cpl024-probe-handler.js +43 -0
- package/dist/lint/post-synth/cpl025-autoscaling-shape.js +84 -0
- package/dist/lint/post-synth/cpl026-scale-to-zero.js +59 -0
- package/dist/lint/post-synth/cpl027-capacity-ai-conflict.js +67 -0
- package/dist/lint/post-synth/cpl028-volume-set.js +155 -0
- package/dist/lint/post-synth/cpl029-link-targets.js +140 -0
- package/dist/lint/post-synth/cpl030-domain-routing.js +114 -0
- package/dist/lint/post-synth/cpl040-pinned-image.js +41 -0
- package/dist/lint/post-synth/cpl041-image-reference-form.js +52 -0
- package/dist/lint/post-synth/cpl042-gvc-placement.js +49 -0
- package/dist/lint/post-synth/cpl043-policy-scope.js +0 -0
- package/dist/lint/post-synth/helpers.js +102 -0
- package/dist/lint/post-synth/index.js +42 -0
- package/dist/lint/rules/cpl001-no-secret-literals.js +73 -0
- package/dist/lint/rules/cpl002-prefer-resource-reference.js +60 -0
- package/dist/lint/rules/index.js +6 -0
- package/dist/lsp/completions.d.ts.map +1 -1
- package/dist/lsp/completions.js +24 -0
- package/dist/lsp/hover.d.ts.map +1 -1
- package/dist/lsp/hover.js +106 -0
- package/dist/manifest.json +1 -1
- package/dist/ownership.js +25 -0
- package/dist/package-cli.js +21 -0
- package/dist/plugin.js +141 -0
- package/dist/reference-catalog.js +99 -0
- package/dist/serializer.js +217 -0
- package/dist/spec/fetch.d.ts.map +1 -1
- package/dist/spec/fetch.js +70 -0
- package/dist/spec/parse.js +338 -0
- package/dist/spec/snapshot-cli.js +60 -0
- package/dist/validate-cli.js +7 -0
- package/dist/validate.js +88 -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/src/spec/fetch.ts +2 -2
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { compositeCatalog } from "./composites/catalog.js";
|
|
2
|
+
import { createSkillsLoader, createDiffTool, createCatalogResource } from "@intentius/chant/lexicon-plugin-helpers";
|
|
3
|
+
import { cplnSerializer } from "./serializer.js";
|
|
4
|
+
import { rules } from "./lint/rules/index.js";
|
|
5
|
+
import { postSynthChecks } from "./lint/post-synth/index.js";
|
|
6
|
+
import { cplnAuditCatalog } from "./lint/audit-catalog.js";
|
|
7
|
+
import { cplnReferenceCatalog } from "./reference-catalog.js";
|
|
8
|
+
import { CPLN_TAG_OWNERSHIP_KEYS } from "./ownership.js";
|
|
9
|
+
import { detectCplnTemplate } from "./detect.js";
|
|
10
|
+
import { cplnInitTemplates } from "./init-templates.js";
|
|
11
|
+
import { completions } from "./lsp/completions.js";
|
|
12
|
+
import { hover } from "./lsp/hover.js";
|
|
13
|
+
/**
|
|
14
|
+
* The cpln lexicon plugin.
|
|
15
|
+
*
|
|
16
|
+
* Control Plane's `tags` map is the ownership channel, and unusually it works
|
|
17
|
+
* on the thin read: every kind carries `tags` and every read path returns
|
|
18
|
+
* them, so `describeResources` resolves a real verdict rather than `unknown`.
|
|
19
|
+
* That is why `describeResources` is declared among the channel's read paths
|
|
20
|
+
* where aws — whose `describe-stack-resources` returns no tags at all — cannot
|
|
21
|
+
* declare it.
|
|
22
|
+
*
|
|
23
|
+
* `observeResourcesDeep` and `exportResources` are not implemented yet, so
|
|
24
|
+
* they are absent from `reads` rather than listed optimistically: the contract
|
|
25
|
+
* is that a declared path resolves a verdict, and claiming one this plugin has
|
|
26
|
+
* no implementation for would be a lie the checker cannot catch.
|
|
27
|
+
*/
|
|
28
|
+
const ownershipChannel = {
|
|
29
|
+
keys: CPLN_TAG_OWNERSHIP_KEYS,
|
|
30
|
+
reads: ["describeResources"],
|
|
31
|
+
};
|
|
32
|
+
export const cplnPlugin = {
|
|
33
|
+
name: "cpln",
|
|
34
|
+
serializer: cplnSerializer,
|
|
35
|
+
// ── Required lifecycle methods ────────────────────────────────
|
|
36
|
+
async generate(options) {
|
|
37
|
+
const { generate, writeGeneratedFiles } = await import("./codegen/generate.js");
|
|
38
|
+
const { dirname } = await import("path");
|
|
39
|
+
const { fileURLToPath } = await import("url");
|
|
40
|
+
const result = await generate(options);
|
|
41
|
+
writeGeneratedFiles(result, dirname(dirname(fileURLToPath(import.meta.url))));
|
|
42
|
+
},
|
|
43
|
+
async validate() {
|
|
44
|
+
const { validate } = await import("./validate.js");
|
|
45
|
+
const { printValidationResult } = await import("@intentius/chant/codegen/validate");
|
|
46
|
+
printValidationResult(await validate());
|
|
47
|
+
},
|
|
48
|
+
async coverage(options) {
|
|
49
|
+
const { analyzeCplnCoverage } = await import("./coverage.js");
|
|
50
|
+
await analyzeCplnCoverage(options);
|
|
51
|
+
},
|
|
52
|
+
async package(options) {
|
|
53
|
+
const { packageLexicon } = await import("./codegen/package.js");
|
|
54
|
+
const { writeBundleSpec } = await import("@intentius/chant/codegen/package");
|
|
55
|
+
const { join, dirname } = await import("path");
|
|
56
|
+
const { fileURLToPath } = await import("url");
|
|
57
|
+
const { spec, stats } = await packageLexicon(options);
|
|
58
|
+
const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
59
|
+
writeBundleSpec(spec, join(pkgDir, "dist"));
|
|
60
|
+
console.error(`Packaged ${stats.resources} resources, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
|
|
61
|
+
},
|
|
62
|
+
// ── Lint and audit ────────────────────────────────────────────
|
|
63
|
+
lintRules() {
|
|
64
|
+
return rules;
|
|
65
|
+
},
|
|
66
|
+
postSynthChecks() {
|
|
67
|
+
return postSynthChecks;
|
|
68
|
+
},
|
|
69
|
+
auditCatalog: () => cplnAuditCatalog,
|
|
70
|
+
// ── Observation ───────────────────────────────────────────────
|
|
71
|
+
ownershipChannel,
|
|
72
|
+
referenceCatalog: cplnReferenceCatalog,
|
|
73
|
+
async describeResources(options) {
|
|
74
|
+
const { describeResources } = await import("./describe-resources.js");
|
|
75
|
+
return describeResources({
|
|
76
|
+
environment: options.environment,
|
|
77
|
+
buildOutput: options.buildOutput,
|
|
78
|
+
entityNames: options.entityNames,
|
|
79
|
+
entities: options.entities,
|
|
80
|
+
owned: options.owned,
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
// ── Language surface ──────────────────────────────────────────
|
|
84
|
+
completionProvider(ctx) {
|
|
85
|
+
return completions(ctx);
|
|
86
|
+
},
|
|
87
|
+
hoverProvider(ctx) {
|
|
88
|
+
return hover(ctx);
|
|
89
|
+
},
|
|
90
|
+
// ── Import and project surface ────────────────────────────────
|
|
91
|
+
detectTemplate(data) {
|
|
92
|
+
return detectCplnTemplate(data);
|
|
93
|
+
},
|
|
94
|
+
/**
|
|
95
|
+
* The three `chant init` scaffolds, named at the plugin surface so the
|
|
96
|
+
* available set is visible where a reader looks for it rather than only
|
|
97
|
+
* inside the switch that resolves them. An unrecognised name falls through
|
|
98
|
+
* to the default — `chant init` treats it as a hint, not a key.
|
|
99
|
+
*/
|
|
100
|
+
initTemplates(template) {
|
|
101
|
+
if (template === "secrets")
|
|
102
|
+
return cplnInitTemplates("secrets");
|
|
103
|
+
if (template === "stateful")
|
|
104
|
+
return cplnInitTemplates("stateful");
|
|
105
|
+
return cplnInitTemplates();
|
|
106
|
+
},
|
|
107
|
+
skills: createSkillsLoader(import.meta.url, [
|
|
108
|
+
{
|
|
109
|
+
file: "chant-cpln.md",
|
|
110
|
+
name: "chant-cpln",
|
|
111
|
+
description: "Declare Control Plane (cpln) infrastructure from chant — the kinds, GVC scoping, links, and what the serializer emits",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
file: "chant-cpln-workloads.md",
|
|
115
|
+
name: "chant-cpln-workloads",
|
|
116
|
+
description: "Choose a Control Plane workload type and configure autoscaling, Capacity AI, resources and probes correctly",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
file: "chant-cpln-secrets.md",
|
|
120
|
+
name: "chant-cpln-secrets",
|
|
121
|
+
description: "Wire Control Plane secrets, identities and policies so a workload can actually read them",
|
|
122
|
+
},
|
|
123
|
+
]),
|
|
124
|
+
composites() {
|
|
125
|
+
return compositeCatalog;
|
|
126
|
+
},
|
|
127
|
+
mcpTools() {
|
|
128
|
+
return [
|
|
129
|
+
createDiffTool(cplnSerializer, "Diff the current cpln manifests against the previous build output", "cpln"),
|
|
130
|
+
];
|
|
131
|
+
},
|
|
132
|
+
mcpResources() {
|
|
133
|
+
return [
|
|
134
|
+
createCatalogResource(import.meta.url, "Control Plane Resource Catalog", "Every cpln resource and property type, with its Control Plane type name", "lexicon-cpln.json", "cpln"),
|
|
135
|
+
];
|
|
136
|
+
},
|
|
137
|
+
async docs(options) {
|
|
138
|
+
const { generateDocs } = await import("./codegen/docs.js");
|
|
139
|
+
return generateDocs(options);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How observed cpln resources reference each other, so `chant graph --live` can
|
|
3
|
+
* rebuild the topology from a bag of nodes.
|
|
4
|
+
*
|
|
5
|
+
* Control Plane addresses resources by **link string**
|
|
6
|
+
* (`//gvc/prod/identity/api`), and edge reconstruction matches identifiers
|
|
7
|
+
* exactly, so a raw link never equals the `name` it points at. Rather than add
|
|
8
|
+
* an enrichment pass, `describeResources` resolves each link down to the bare
|
|
9
|
+
* name it ends in and files it under a stable attribute — `refs.identity`,
|
|
10
|
+
* `refs.gvc`, `refs.pullSecrets` — and the rules below read those. See
|
|
11
|
+
* {@link referenceAttributes} in `describe-resources.ts`.
|
|
12
|
+
*
|
|
13
|
+
* Names are unique per kind within an org (per GVC for the GVC-scoped kinds),
|
|
14
|
+
* so `name` is the identity for every kind and `targetKind` disambiguates the
|
|
15
|
+
* cases where two kinds could hold the same name.
|
|
16
|
+
*
|
|
17
|
+
* Containment vs reference matters for the picture: "this workload is in this
|
|
18
|
+
* GVC" is a boundary the renderer draws as a box, not a line from every
|
|
19
|
+
* resource to its GVC. It still declares `viaAttr` so a traversal can walk it —
|
|
20
|
+
* "which GVCs have no workloads" is a containment question.
|
|
21
|
+
*/
|
|
22
|
+
import { KINDS, kindByName } from "./kinds.js";
|
|
23
|
+
const typeOf = (kind) => kindByName(kind).typeName;
|
|
24
|
+
export const cplnReferenceCatalog = {
|
|
25
|
+
identities: KINDS.map((kind) => ({ kind: kind.typeName, ids: ["name"] })),
|
|
26
|
+
refs: [
|
|
27
|
+
// Every GVC-scoped resource lives inside its GVC — a boundary, not a line.
|
|
28
|
+
...KINDS.filter((kind) => kind.gvcScoped).map((kind) => ({
|
|
29
|
+
from: kind.typeName,
|
|
30
|
+
path: "refs.gvc",
|
|
31
|
+
targetKind: typeOf("gvc"),
|
|
32
|
+
relation: "containment",
|
|
33
|
+
label: "in GVC",
|
|
34
|
+
viaAttr: "gvc",
|
|
35
|
+
})),
|
|
36
|
+
// A workload runs as one identity.
|
|
37
|
+
{
|
|
38
|
+
from: typeOf("workload"),
|
|
39
|
+
path: "refs.identity",
|
|
40
|
+
targetKind: typeOf("identity"),
|
|
41
|
+
relation: "reference",
|
|
42
|
+
label: "identity",
|
|
43
|
+
viaAttr: "identityLink",
|
|
44
|
+
},
|
|
45
|
+
// A GVC pulls private images with org-scoped secrets.
|
|
46
|
+
{
|
|
47
|
+
from: typeOf("gvc"),
|
|
48
|
+
path: "refs.pullSecrets[]",
|
|
49
|
+
targetKind: typeOf("secret"),
|
|
50
|
+
relation: "reference",
|
|
51
|
+
label: "pull secret",
|
|
52
|
+
viaAttr: "pullSecretLinks",
|
|
53
|
+
},
|
|
54
|
+
// A domain routes into a GVC, or straight at a stateful workload.
|
|
55
|
+
{
|
|
56
|
+
from: typeOf("domain"),
|
|
57
|
+
path: "refs.gvc",
|
|
58
|
+
targetKind: typeOf("gvc"),
|
|
59
|
+
relation: "reference",
|
|
60
|
+
label: "routes to",
|
|
61
|
+
viaAttr: "gvcLink",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
from: typeOf("domain"),
|
|
65
|
+
path: "refs.workloads[]",
|
|
66
|
+
targetKind: typeOf("workload"),
|
|
67
|
+
relation: "reference",
|
|
68
|
+
label: "routes to",
|
|
69
|
+
viaAttr: "workloadLink",
|
|
70
|
+
},
|
|
71
|
+
// A policy's explicit target links.
|
|
72
|
+
{
|
|
73
|
+
from: typeOf("policy"),
|
|
74
|
+
path: "refs.targets[]",
|
|
75
|
+
targetKind: typeOf("secret"),
|
|
76
|
+
relation: "reference",
|
|
77
|
+
label: "grants on",
|
|
78
|
+
viaAttr: "targetLinks",
|
|
79
|
+
},
|
|
80
|
+
// An IP set fronts the workload it is bound to.
|
|
81
|
+
{
|
|
82
|
+
from: typeOf("ipset"),
|
|
83
|
+
path: "refs.workloads[]",
|
|
84
|
+
targetKind: typeOf("workload"),
|
|
85
|
+
relation: "reference",
|
|
86
|
+
label: "fronts",
|
|
87
|
+
viaAttr: "link",
|
|
88
|
+
},
|
|
89
|
+
// A workload mounts volume sets.
|
|
90
|
+
{
|
|
91
|
+
from: typeOf("workload"),
|
|
92
|
+
path: "refs.volumeSets[]",
|
|
93
|
+
targetKind: typeOf("volumeset"),
|
|
94
|
+
relation: "reference",
|
|
95
|
+
label: "mounts",
|
|
96
|
+
viaAttr: "volumes",
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cpln serializer — emits the multi-document YAML that `cpln apply --file`
|
|
3
|
+
* consumes.
|
|
4
|
+
*
|
|
5
|
+
* Each declared resource becomes one document:
|
|
6
|
+
*
|
|
7
|
+
* kind: workload
|
|
8
|
+
* name: my-app
|
|
9
|
+
* gvc: my-gvc
|
|
10
|
+
* tags:
|
|
11
|
+
* chant.intentius.io/managed-by: chant
|
|
12
|
+
* spec:
|
|
13
|
+
* containers:
|
|
14
|
+
* - name: main
|
|
15
|
+
* image: nginx:1.27
|
|
16
|
+
*
|
|
17
|
+
* Three things this file is careful about.
|
|
18
|
+
*
|
|
19
|
+
* **Only authoring surface is emitted.** Control Plane's own guidance is to
|
|
20
|
+
* export with `-o yaml-slim` rather than `-o yaml` before re-applying, because
|
|
21
|
+
* the server-side fields (`status`, `id`, `created`, `lastModified`, `links`)
|
|
22
|
+
* break `cpln apply`. Those are attributes in this lexicon rather than
|
|
23
|
+
* properties, so they never reach a document in the first place — the shape a
|
|
24
|
+
* user can declare is already the slim shape.
|
|
25
|
+
*
|
|
26
|
+
* **References become links, not names.** Control Plane addresses resources by
|
|
27
|
+
* link (`//gvc/prod/identity/api`, `//secret/db-password`), and the GVC-scoped
|
|
28
|
+
* forms have to carry the GVC. Passing a declared resource where a link is
|
|
29
|
+
* expected resolves to the right link for its kind, which is the difference
|
|
30
|
+
* between a reference that survives a rename and a hand-spelled string that
|
|
31
|
+
* does not. The identity form matters especially: the bare `//identity/NAME`
|
|
32
|
+
* that reads perfectly plausibly is *silently ignored* by Control Plane's
|
|
33
|
+
* policy engine, and is documented as a common silent failure.
|
|
34
|
+
*
|
|
35
|
+
* **Document order is stable and dependency-first.** `cpln apply` resolves
|
|
36
|
+
* ordering itself for a multi-doc file, so this is not needed for correctness —
|
|
37
|
+
* it is so the emitted file diffs cleanly between builds and reads in the order
|
|
38
|
+
* a person would write it.
|
|
39
|
+
*/
|
|
40
|
+
import { isResourceDeclarable } from "@intentius/chant/declarable";
|
|
41
|
+
import { ownershipEntries } from "@intentius/chant/ownership";
|
|
42
|
+
import { walkValue } from "@intentius/chant/serializer-walker";
|
|
43
|
+
import { emitYAML } from "@intentius/chant/yaml";
|
|
44
|
+
import { KINDS, kindByTypeName } from "./kinds.js";
|
|
45
|
+
import { CPLN_TAG_OWNERSHIP_KEYS } from "./ownership.js";
|
|
46
|
+
/**
|
|
47
|
+
* Document order: a GVC before anything inside it, and the org-scoped assets a
|
|
48
|
+
* workload references before the workload. Matches the order the API would
|
|
49
|
+
* accept the documents in one at a time.
|
|
50
|
+
*/
|
|
51
|
+
const KIND_ORDER = ["gvc", "secret", "ipset", "policy", "identity", "volumeset", "workload", "domain"];
|
|
52
|
+
/**
|
|
53
|
+
* Manifest key order. `kind` and `name` first because that is how every
|
|
54
|
+
* Control Plane example reads; `spec` last because it is the long one.
|
|
55
|
+
*/
|
|
56
|
+
const KEY_ORDER = ["kind", "name", "description", "gvc", "tags"];
|
|
57
|
+
/** Properties consumed to build the envelope rather than copied into the body. */
|
|
58
|
+
const ENVELOPE_KEYS = new Set(["name", "description", "gvc", "tags"]);
|
|
59
|
+
/**
|
|
60
|
+
* `satisfies` rather than a type annotation: `Serializer.serialize` returns
|
|
61
|
+
* `string | SerializerResult`, and cpln always emits a single YAML string.
|
|
62
|
+
* Annotating widens the export to the union and every caller — the tests
|
|
63
|
+
* included — has to narrow it back with a cast that asserts something the
|
|
64
|
+
* implementation already guarantees. `satisfies` checks the shape against the
|
|
65
|
+
* interface and keeps the narrower return type.
|
|
66
|
+
*/
|
|
67
|
+
export const cplnSerializer = {
|
|
68
|
+
name: "cpln",
|
|
69
|
+
rulePrefix: "CPL",
|
|
70
|
+
serialize(entities, _outputs, context) {
|
|
71
|
+
// The walker wants Declarable → name; link resolution wants the inverse.
|
|
72
|
+
const entityNames = new Map();
|
|
73
|
+
for (const [name, entity] of entities)
|
|
74
|
+
entityNames.set(entity, name);
|
|
75
|
+
const visitor = cplnVisitor(entities);
|
|
76
|
+
const documents = [];
|
|
77
|
+
for (const [entityName, entity] of entities) {
|
|
78
|
+
const kind = kindByTypeName(entity.entityType);
|
|
79
|
+
// Not ours. A build can mix lexicons, and each serializer sees only the
|
|
80
|
+
// entities routed to it, but skipping defensively costs nothing.
|
|
81
|
+
if (!kind)
|
|
82
|
+
continue;
|
|
83
|
+
const props = readProps(entity);
|
|
84
|
+
const manifest = { kind: kind.kind };
|
|
85
|
+
// `name` falls back to the entity's own name, so a resource declared
|
|
86
|
+
// without one still round-trips rather than emitting `name: undefined`.
|
|
87
|
+
manifest.name = props.name ?? entityName;
|
|
88
|
+
if (props.description !== undefined)
|
|
89
|
+
manifest.description = walkValue(props.description, entityNames, visitor);
|
|
90
|
+
if (kind.gvcScoped)
|
|
91
|
+
manifest.gvc = walkValue(props.gvc, entityNames, visitor);
|
|
92
|
+
const tags = buildTags(props.tags, entityNames, visitor, context);
|
|
93
|
+
if (tags)
|
|
94
|
+
manifest.tags = tags;
|
|
95
|
+
for (const [key, value] of Object.entries(props)) {
|
|
96
|
+
if (ENVELOPE_KEYS.has(key) || value === undefined)
|
|
97
|
+
continue;
|
|
98
|
+
manifest[key] = walkValue(value, entityNames, visitor);
|
|
99
|
+
}
|
|
100
|
+
documents.push({
|
|
101
|
+
order: KIND_ORDER.indexOf(kind.kind),
|
|
102
|
+
name: String(manifest.name),
|
|
103
|
+
text: emitManifest(manifest),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
documents.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
|
|
107
|
+
return documents.map((d) => d.text).join("\n---\n");
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
// ── Tags and ownership ─────────────────────────────────────────────
|
|
111
|
+
/**
|
|
112
|
+
* Merge the declared tags with chant's ownership marker.
|
|
113
|
+
*
|
|
114
|
+
* The marker is stamped even when the resource declares no tags of its own —
|
|
115
|
+
* that is the whole point of it, since an unmarked resource is invisible to
|
|
116
|
+
* the owned-resource prune that lets `chant delete` be precise without a state
|
|
117
|
+
* file.
|
|
118
|
+
*/
|
|
119
|
+
function buildTags(declared, entityNames, visitor, context) {
|
|
120
|
+
const walked = declared === undefined ? undefined : walkValue(declared, entityNames, visitor);
|
|
121
|
+
const tags = walked && typeof walked === "object" && !Array.isArray(walked) ? { ...walked } : {};
|
|
122
|
+
if (context?.ownership) {
|
|
123
|
+
Object.assign(tags, ownershipEntries(CPLN_TAG_OWNERSHIP_KEYS, context.ownership));
|
|
124
|
+
}
|
|
125
|
+
return Object.keys(tags).length > 0 ? tags : undefined;
|
|
126
|
+
}
|
|
127
|
+
// ── References ─────────────────────────────────────────────────────
|
|
128
|
+
/**
|
|
129
|
+
* Build the Control Plane link for a declared resource.
|
|
130
|
+
*
|
|
131
|
+
* GVC-scoped kinds nest under their GVC. For identities in particular the
|
|
132
|
+
* nested form is not cosmetic: a policy binding written against the bare
|
|
133
|
+
* `//identity/NAME` is accepted and then ignored.
|
|
134
|
+
*/
|
|
135
|
+
export function cplnLink(kind, name, gvc) {
|
|
136
|
+
if (kind.kind === "gvc")
|
|
137
|
+
return `//gvc/${name}`;
|
|
138
|
+
if (kind.gvcScoped) {
|
|
139
|
+
if (typeof gvc !== "string" || gvc.length === 0) {
|
|
140
|
+
throw new Error(`Cannot build a link to ${kind.kind} "${name}": its \`gvc\` is not a plain string at build time. ` +
|
|
141
|
+
`GVC-scoped links must be \`//gvc/<gvc>/${kind.kind}/<name>\`, and the bare \`//${kind.kind}/<name>\` ` +
|
|
142
|
+
`form is silently ignored by Control Plane.`);
|
|
143
|
+
}
|
|
144
|
+
return `//gvc/${gvc}/${kind.kind}/${name}`;
|
|
145
|
+
}
|
|
146
|
+
return `//${kind.kind}/${name}`;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Visitor for the generic serializer walker.
|
|
150
|
+
*
|
|
151
|
+
* Property declarables unwrap to plain objects; resource references resolve to
|
|
152
|
+
* Control Plane links; attribute references have nowhere to go.
|
|
153
|
+
*/
|
|
154
|
+
function cplnVisitor(entities) {
|
|
155
|
+
const linkFor = (logicalName) => {
|
|
156
|
+
const target = entities.get(logicalName);
|
|
157
|
+
const kind = target && kindByTypeName(target.entityType);
|
|
158
|
+
if (!target || !kind) {
|
|
159
|
+
// A reference to something this lexicon does not model. The logical name
|
|
160
|
+
// is the most useful thing left to emit, and a post-synth check reports
|
|
161
|
+
// links that do not resolve.
|
|
162
|
+
return logicalName;
|
|
163
|
+
}
|
|
164
|
+
const props = readProps(target);
|
|
165
|
+
const name = typeof props.name === "string" ? props.name : logicalName;
|
|
166
|
+
return cplnLink(kind, name, props.gvc);
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
attrRef: (name, attribute) => {
|
|
170
|
+
throw new Error(`Cannot serialize a reference to ${name}.${attribute}: Control Plane manifests have no ` +
|
|
171
|
+
`template-time reference language, so an attribute known only after apply cannot be embedded in one. ` +
|
|
172
|
+
`Reference the resource itself — it resolves to a link — or read the attribute after apply.`);
|
|
173
|
+
},
|
|
174
|
+
resourceRef: linkFor,
|
|
175
|
+
propertyDeclarable: (entity, walk) => {
|
|
176
|
+
const props = isResourceDeclarable(entity) ? entity.props : undefined;
|
|
177
|
+
if (!props || typeof props !== "object")
|
|
178
|
+
return undefined;
|
|
179
|
+
const result = {};
|
|
180
|
+
for (const [key, value] of Object.entries(props)) {
|
|
181
|
+
if (value !== undefined)
|
|
182
|
+
result[key] = walk(value);
|
|
183
|
+
}
|
|
184
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// ── Emission ───────────────────────────────────────────────────────
|
|
189
|
+
function readProps(entity) {
|
|
190
|
+
const props = isResourceDeclarable(entity) ? entity.props : undefined;
|
|
191
|
+
return props && typeof props === "object" ? props : {};
|
|
192
|
+
}
|
|
193
|
+
/** Emit one manifest document, ordered keys first and the rest alphabetically. */
|
|
194
|
+
function emitManifest(manifest) {
|
|
195
|
+
const lines = [];
|
|
196
|
+
for (const key of KEY_ORDER) {
|
|
197
|
+
if (manifest[key] !== undefined)
|
|
198
|
+
lines.push(emitKeyValue(key, manifest[key]));
|
|
199
|
+
}
|
|
200
|
+
const rest = Object.keys(manifest)
|
|
201
|
+
.filter((k) => !KEY_ORDER.includes(k))
|
|
202
|
+
.sort();
|
|
203
|
+
for (const key of rest) {
|
|
204
|
+
if (manifest[key] !== undefined)
|
|
205
|
+
lines.push(emitKeyValue(key, manifest[key]));
|
|
206
|
+
}
|
|
207
|
+
return `${lines.join("\n")}\n`;
|
|
208
|
+
}
|
|
209
|
+
/** Scalars get `key: value`; blocks get the value indented beneath the key. */
|
|
210
|
+
function emitKeyValue(key, value) {
|
|
211
|
+
const yaml = emitYAML(value, 1);
|
|
212
|
+
return yaml.startsWith("\n") ? `${key}:${yaml}` : `${key}: ${yaml}`;
|
|
213
|
+
}
|
|
214
|
+
/** Kind order used for document sorting, exported for the serializer tests. */
|
|
215
|
+
export const DOCUMENT_KIND_ORDER = KIND_ORDER;
|
|
216
|
+
/** Every modelled kind, re-exported so consumers need not reach into `kinds`. */
|
|
217
|
+
export const SERIALIZED_KINDS = KINDS;
|
package/dist/spec/fetch.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../../src/spec/fetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAQH,eAAO,MAAM,UAAU,qCAAqC,CAAC;AAS7D,eAAO,MAAM,aAAa,
|
|
1
|
+
{"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../../src/spec/fetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAQH,eAAO,MAAM,UAAU,qCAAqC,CAAC;AAS7D,eAAO,MAAM,aAAa,QAAuE,CAAC;AAElG,yEAAyE;AACzE,eAAO,MAAM,QAAQ,kBAAkB,CAAC;AAExC;;;;GAIG;AACH,wBAAsB,YAAY,CAAC,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAoB9F"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control Plane Core API OpenAPI 3.0.3 spec.
|
|
3
|
+
*
|
|
4
|
+
* `https://api.cpln.io/openapi.json` is an unversioned live endpoint: it always
|
|
5
|
+
* serves the current spec, and there is no tag, release asset, or `info.version`
|
|
6
|
+
* bump to pin against — `info.version` has read `1.0.0` for as long as the
|
|
7
|
+
* endpoint has existed. There is consequently nothing for the self-upgrade
|
|
8
|
+
* tooling to bump, so this plugin declares no `upstreamPin`, the same call fly
|
|
9
|
+
* makes for `docs.machines.dev/openapi.json`. Re-running `generate` picks up
|
|
10
|
+
* whatever the endpoint currently serves.
|
|
11
|
+
*
|
|
12
|
+
* Unlike fly, this lexicon keeps a committed offline fallback. Two reasons the
|
|
13
|
+
* live-only route was not good enough here:
|
|
14
|
+
*
|
|
15
|
+
* - The endpoint is the production API's own doc route, not a docs CDN. It is
|
|
16
|
+
* subject to the same maintenance windows as the API, and a generate step
|
|
17
|
+
* that fails when Control Plane is briefly unreachable makes CI a function of
|
|
18
|
+
* someone else's uptime.
|
|
19
|
+
* - The full document is 2.8 MB, almost all of which is the `patch_*` mirror
|
|
20
|
+
* schemas and kinds this lexicon does not model. The snapshot is pruned to
|
|
21
|
+
* the schemas actually reachable from the modelled kinds — 23 of 106 — which
|
|
22
|
+
* is small enough to commit, and to re-indent for a readable diff, without
|
|
23
|
+
* turning every regeneration into a megabyte-scale change.
|
|
24
|
+
*
|
|
25
|
+
* The fallback is never silent. Both routes log which one was taken, because a
|
|
26
|
+
* fallback nobody can see is how you end up debugging types that came from
|
|
27
|
+
* somewhere you did not expect.
|
|
28
|
+
*
|
|
29
|
+
* Refresh the snapshot with `just snapshot` (see `snapshot-cli.ts`).
|
|
30
|
+
*/
|
|
31
|
+
import { join } from "path";
|
|
32
|
+
import { homedir } from "os";
|
|
33
|
+
import { readFileSync } from "fs";
|
|
34
|
+
import { moduleSourceDir } from "@intentius/chant/module-source";
|
|
35
|
+
import { fetchWithCache } from "@intentius/chant/codegen/fetch";
|
|
36
|
+
export const SCHEMA_URL = "https://api.cpln.io/openapi.json";
|
|
37
|
+
/**
|
|
38
|
+
* Cache under `~/.chant` (like fly and k8s) so CI's `~/.chant` schema cache
|
|
39
|
+
* covers it — an api.cpln.io hiccup then falls back to the cache before it
|
|
40
|
+
* falls back to the committed snapshot.
|
|
41
|
+
*/
|
|
42
|
+
const CACHE_FILE = join(homedir(), ".chant", "cpln-openapi.json");
|
|
43
|
+
export const SNAPSHOT_FILE = join(moduleSourceDir(import.meta.url), "cpln-openapi.snapshot.json");
|
|
44
|
+
/** The single map key the parser is handed. The spec is one document. */
|
|
45
|
+
export const SPEC_KEY = "Cpln::OpenAPI";
|
|
46
|
+
/**
|
|
47
|
+
* Fetch the Control Plane OpenAPI spec, falling back to the committed pruned
|
|
48
|
+
* snapshot when the endpoint is unreachable. Returns a single-entry map — the
|
|
49
|
+
* whole spec is one document; the parser fans it out into per-kind results.
|
|
50
|
+
*/
|
|
51
|
+
export async function fetchSchemas(options) {
|
|
52
|
+
let raw;
|
|
53
|
+
try {
|
|
54
|
+
raw = await fetchWithCache({ url: SCHEMA_URL, cacheFile: CACHE_FILE }, options?.force);
|
|
55
|
+
// A 404 body is still a body. Parsing proves we got a spec and not an error
|
|
56
|
+
// page that would otherwise be cached and then generated from.
|
|
57
|
+
const parsed = JSON.parse(raw.toString("utf-8"));
|
|
58
|
+
if (!parsed.components?.schemas) {
|
|
59
|
+
throw new Error("response carried no components.schemas");
|
|
60
|
+
}
|
|
61
|
+
console.error(`[cpln] spec: live ${SCHEMA_URL}`);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
console.error(`[cpln] spec: committed snapshot — could not fetch ${SCHEMA_URL} ` +
|
|
65
|
+
`(${err instanceof Error ? err.message.split("\n")[0] : String(err)}). ` +
|
|
66
|
+
`Refresh it with \`just snapshot\` on a networked machine.`);
|
|
67
|
+
raw = readFileSync(SNAPSHOT_FILE);
|
|
68
|
+
}
|
|
69
|
+
return new Map([[SPEC_KEY, raw]]);
|
|
70
|
+
}
|