@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.
Files changed (75) hide show
  1. package/dist/api.js +88 -0
  2. package/dist/codegen/docs-cli.js +6 -0
  3. package/dist/codegen/docs.js +85 -0
  4. package/dist/codegen/generate-cli.js +25 -0
  5. package/dist/codegen/generate-lexicon.js +50 -0
  6. package/dist/codegen/generate-typescript.js +48 -0
  7. package/dist/codegen/generate.js +86 -0
  8. package/dist/codegen/naming.js +43 -0
  9. package/dist/codegen/package.js +31 -0
  10. package/dist/composites/catalog.js +556 -0
  11. package/dist/composites/cron-job.js +52 -0
  12. package/dist/composites/gvc-environment.js +49 -0
  13. package/dist/composites/index.js +6 -0
  14. package/dist/composites/public-domain.js +70 -0
  15. package/dist/composites/secret-access.js +82 -0
  16. package/dist/composites/serverless-service.js +58 -0
  17. package/dist/composites/stateful-service.js +87 -0
  18. package/dist/coverage.js +104 -0
  19. package/dist/describe-resources.js +264 -0
  20. package/dist/detect.js +52 -0
  21. package/dist/entity-props.js +100 -0
  22. package/dist/generated/index.js +169 -0
  23. package/dist/generated/runtime.js +4 -0
  24. package/dist/index.js +14 -0
  25. package/dist/init-templates.js +125 -0
  26. package/dist/integrity.json +2 -2
  27. package/dist/kinds.js +134 -0
  28. package/dist/lint/audit-catalog.js +90 -0
  29. package/dist/lint/audit-lineage.js +32 -0
  30. package/dist/lint/post-synth/cpl010-unrestricted-outbound.js +34 -0
  31. package/dist/lint/post-synth/cpl011-internal-firewall-scope.js +32 -0
  32. package/dist/lint/post-synth/cpl012-no-literal-credential-env.js +51 -0
  33. package/dist/lint/post-synth/cpl013-qualified-identity-principal.js +40 -0
  34. package/dist/lint/post-synth/cpl014-qualified-secret-ref.js +47 -0
  35. package/dist/lint/post-synth/cpl020-serverless-single-port.js +51 -0
  36. package/dist/lint/post-synth/cpl021-cron-job-shape.js +55 -0
  37. package/dist/lint/post-synth/cpl022-container-ports.js +53 -0
  38. package/dist/lint/post-synth/cpl023-container-resources.js +73 -0
  39. package/dist/lint/post-synth/cpl024-probe-handler.js +43 -0
  40. package/dist/lint/post-synth/cpl025-autoscaling-shape.js +84 -0
  41. package/dist/lint/post-synth/cpl026-scale-to-zero.js +59 -0
  42. package/dist/lint/post-synth/cpl027-capacity-ai-conflict.js +67 -0
  43. package/dist/lint/post-synth/cpl028-volume-set.js +155 -0
  44. package/dist/lint/post-synth/cpl029-link-targets.js +140 -0
  45. package/dist/lint/post-synth/cpl030-domain-routing.js +114 -0
  46. package/dist/lint/post-synth/cpl040-pinned-image.js +41 -0
  47. package/dist/lint/post-synth/cpl041-image-reference-form.js +52 -0
  48. package/dist/lint/post-synth/cpl042-gvc-placement.js +49 -0
  49. package/dist/lint/post-synth/cpl043-policy-scope.js +0 -0
  50. package/dist/lint/post-synth/helpers.js +102 -0
  51. package/dist/lint/post-synth/index.js +42 -0
  52. package/dist/lint/rules/cpl001-no-secret-literals.js +73 -0
  53. package/dist/lint/rules/cpl002-prefer-resource-reference.js +60 -0
  54. package/dist/lint/rules/index.js +6 -0
  55. package/dist/lsp/completions.d.ts.map +1 -1
  56. package/dist/lsp/completions.js +24 -0
  57. package/dist/lsp/hover.d.ts.map +1 -1
  58. package/dist/lsp/hover.js +106 -0
  59. package/dist/manifest.json +1 -1
  60. package/dist/ownership.js +25 -0
  61. package/dist/package-cli.js +21 -0
  62. package/dist/plugin.js +141 -0
  63. package/dist/reference-catalog.js +99 -0
  64. package/dist/serializer.js +217 -0
  65. package/dist/spec/fetch.d.ts.map +1 -1
  66. package/dist/spec/fetch.js +70 -0
  67. package/dist/spec/parse.js +338 -0
  68. package/dist/spec/snapshot-cli.js +60 -0
  69. package/dist/validate-cli.js +7 -0
  70. package/dist/validate.js +88 -0
  71. package/package.json +7 -7
  72. package/src/codegen/package.ts +2 -2
  73. package/src/lsp/completions.ts +2 -1
  74. package/src/lsp/hover.ts +2 -1
  75. package/src/spec/fetch.ts +2 -2
@@ -0,0 +1,70 @@
1
+ /**
2
+ * PublicDomain — a custom domain routed to workloads in one GVC.
3
+ *
4
+ * Domains carry several rules that are easy to violate by picking the
5
+ * combination that reads best rather than the one that works:
6
+ *
7
+ * - An **apex domain must use `cname` mode**. NS mode does not support apex,
8
+ * and there is no partial-credit failure — the domain simply will not
9
+ * validate.
10
+ * - **NS mode requires the `dns01` certificate challenge**; `http01` is
11
+ * rejected outright.
12
+ * - `gvcLink`, `workloadLink` and `ports[].routes` are **mutually exclusive**
13
+ * ways of saying where traffic goes; exactly one may be set.
14
+ * - Every route in a domain must target workloads in the **same GVC**.
15
+ *
16
+ * This composite takes a GVC and a route table, derives the mode when it can,
17
+ * and refuses the combinations above at build time.
18
+ */
19
+ import { Composite, mergeDefaults } from "@intentius/chant";
20
+ import { Domain } from "../generated/index.js";
21
+ /** An apex domain has exactly one dot: `example.com`, not `api.example.com`. */
22
+ export function isApexDomain(name) {
23
+ return name.split(".").filter(Boolean).length === 2;
24
+ }
25
+ export const PublicDomain = Composite((props) => {
26
+ const { name, gvc, routes, dnsMode = "cname", certChallengeType = dnsMode === "ns" ? "dns01" : "http01", port = 443, protocol = "http2", tags, defaults: defs, } = props;
27
+ if (dnsMode === "ns" && isApexDomain(name)) {
28
+ throw new Error(`PublicDomain "${name}": an apex domain must use dnsMode "cname". NS mode does not support apex domains.`);
29
+ }
30
+ if (dnsMode === "ns" && certChallengeType !== "dns01") {
31
+ throw new Error(`PublicDomain "${name}": dnsMode "ns" requires certChallengeType "dns01"; "${certChallengeType}" is rejected.`);
32
+ }
33
+ for (const route of routes ?? []) {
34
+ if (!route.prefix && !route.regex) {
35
+ throw new Error(`PublicDomain "${name}": every route needs a \`prefix\` or a \`regex\`.`);
36
+ }
37
+ if (route.prefix && route.regex) {
38
+ throw new Error(`PublicDomain "${name}": route for workload "${route.workload}" sets both \`prefix\` and \`regex\`; ` +
39
+ `only one may be provided.`);
40
+ }
41
+ }
42
+ const domain = new Domain(mergeDefaults({
43
+ name,
44
+ ...(tags && { tags }),
45
+ spec: {
46
+ dnsMode,
47
+ certChallengeType,
48
+ // `gvcLink` and `ports[].routes` are mutually exclusive: a domain
49
+ // with explicit routes must not also carry a GVC link.
50
+ ...(routes && routes.length > 0
51
+ ? {
52
+ ports: [
53
+ {
54
+ number: port,
55
+ protocol,
56
+ routes: routes.map((route) => ({
57
+ ...(route.prefix && { prefix: route.prefix }),
58
+ ...(route.regex && { regex: route.regex }),
59
+ ...(route.replacePrefix && { replacePrefix: route.replacePrefix }),
60
+ port: route.port ?? 8080,
61
+ workloadLink: `//gvc/${gvc}/workload/${route.workload}`,
62
+ })),
63
+ },
64
+ ],
65
+ }
66
+ : { gvcLink: `//gvc/${gvc}` }),
67
+ },
68
+ }, defs?.domain));
69
+ return { domain };
70
+ }, "PublicDomain");
@@ -0,0 +1,82 @@
1
+ /**
2
+ * SecretAccess — the identity and policy a workload needs to read a secret.
3
+ *
4
+ * Control Plane's own documentation calls a partial version of this its number
5
+ * one support issue: reading a secret at runtime takes three separate steps,
6
+ * and **missing any one of them fails silently at runtime** rather than at
7
+ * apply time.
8
+ *
9
+ * 1. An identity exists, and the workload's `spec.identityLink` points at it.
10
+ * 2. A policy grants that identity `reveal` on the secret, with the identity
11
+ * named as a principal in a binding.
12
+ * 3. The workload references the secret in the field-qualified form —
13
+ * `cpln://secret/NAME.payload`, not `cpln://secret/NAME`.
14
+ *
15
+ * This composite owns steps 1 and 2, and {@link secretRef} exists for step 3 so
16
+ * the field qualifier is hard to forget.
17
+ *
18
+ * The principal link is the part most worth automating. A binding written
19
+ * against `//identity/NAME` — which reads perfectly naturally, and is what
20
+ * you would guess — is accepted by the API and then **silently ignored**. Only
21
+ * the GVC-qualified `//gvc/GVC/identity/NAME` form works. This composite only
22
+ * emits the qualified form.
23
+ */
24
+ import { Composite, mergeDefaults } from "@intentius/chant";
25
+ import { Identity, Policy } from "../generated/index.js";
26
+ export const SecretAccess = Composite((props) => {
27
+ const { name, gvc, secrets, policyName = `${name}-secret-access`, permissions = ["reveal"], tags, defaults: defs, } = props;
28
+ if (secrets.length === 0) {
29
+ throw new Error(`SecretAccess "${name}": no secrets listed. A policy with no target links grants nothing, ` +
30
+ `and the workload would fail to read its secrets at runtime with no apply-time error.`);
31
+ }
32
+ const identity = new Identity(mergeDefaults({
33
+ name,
34
+ gvc,
35
+ ...(tags && { tags }),
36
+ }, defs?.identity));
37
+ const policy = new Policy(mergeDefaults({
38
+ name: policyName,
39
+ ...(tags && { tags }),
40
+ targetKind: "secret",
41
+ targetLinks: [...secrets].sort().map((secret) => `//secret/${secret}`),
42
+ bindings: [
43
+ {
44
+ // Sorted and deduplicated: Control Plane requires permissions in a
45
+ // binding to be both.
46
+ permissions: [...new Set(permissions)].sort(),
47
+ // The GVC-qualified form. The bare `//identity/NAME` is silently
48
+ // ignored — see the module comment.
49
+ principalLinks: [identityLink(gvc, name)],
50
+ },
51
+ ],
52
+ }, defs?.policy));
53
+ return { identity, policy };
54
+ }, "SecretAccess");
55
+ /**
56
+ * The GVC-qualified principal link for an identity.
57
+ *
58
+ * Exported because this is the form policies must use, and the unqualified one
59
+ * fails silently.
60
+ */
61
+ export function identityLink(gvc, name) {
62
+ return `//gvc/${gvc}/identity/${name}`;
63
+ }
64
+ /**
65
+ * A field-qualified secret reference for use in a container's env or volumes.
66
+ *
67
+ * The field name is required. `cpln://secret/db.password` resolves; the
68
+ * unqualified `cpln://secret/db` does not, except for `gcp` secrets mounted as
69
+ * a file. The field differs by secret type:
70
+ *
71
+ * | Type | Field |
72
+ * |---|---|
73
+ * | `opaque` | `payload` |
74
+ * | `dictionary` | any key |
75
+ * | `userpass` | `username`, `password` |
76
+ * | `tls` | `cert`, `key` |
77
+ * | `keypair` | `publicKey`, `privateKey` |
78
+ * | `aws` | `accessKey`, `secretKey`, `roleArn` |
79
+ */
80
+ export function secretRef(name, field) {
81
+ return `cpln://secret/${name}.${field}`;
82
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * ServerlessService — a serverless workload with the port, probe and firewall
3
+ * settings a public HTTP service actually needs.
4
+ *
5
+ * Serverless is the Control Plane workload type with the most rules attached to
6
+ * it, and most of them are easy to get wrong by omission rather than by writing
7
+ * something incorrect:
8
+ *
9
+ * - It must expose *exactly one* HTTP port. Zero or two is rejected.
10
+ * - The external firewall's inbound is closed by default, so a workload that
11
+ * looks completely correct serves nothing until a CIDR is added.
12
+ * - `concurrency` and `rps` are the only strategies that scale to zero, and
13
+ * only on this workload type.
14
+ *
15
+ * The defaults here are Control Plane's own, restated so they are visible at
16
+ * the call site rather than implied.
17
+ */
18
+ import { Composite, mergeDefaults } from "@intentius/chant";
19
+ import { Workload } from "../generated/index.js";
20
+ export const ServerlessService = Composite((props) => {
21
+ const { name, gvc, image, port = 8080, protocol = "http", containerName = "main", cpu = "50m", memory = "128Mi", env, inboundAllowCidr = [], outboundAllowCidr = [], minScale = 1, maxScale = 5, autoscalingMetric = "concurrency", autoscalingTarget = 95, identityLink, tags, defaults: defs, } = props;
22
+ const workload = new Workload(mergeDefaults({
23
+ name,
24
+ gvc,
25
+ ...(tags && { tags }),
26
+ spec: {
27
+ type: "serverless",
28
+ ...(identityLink && { identityLink }),
29
+ containers: [
30
+ {
31
+ name: containerName,
32
+ image,
33
+ cpu,
34
+ memory,
35
+ ports: [{ number: port, protocol }],
36
+ ...(env && {
37
+ env: Object.entries(env).map(([envName, value]) => ({ name: envName, value })),
38
+ }),
39
+ },
40
+ ],
41
+ firewallConfig: {
42
+ external: {
43
+ inboundAllowCIDR: inboundAllowCidr,
44
+ outboundAllowCIDR: outboundAllowCidr,
45
+ },
46
+ },
47
+ defaultOptions: {
48
+ autoscaling: {
49
+ metric: autoscalingMetric,
50
+ target: autoscalingTarget,
51
+ minScale,
52
+ maxScale,
53
+ },
54
+ },
55
+ },
56
+ }, defs?.workload));
57
+ return { workload };
58
+ }, "ServerlessService");
@@ -0,0 +1,87 @@
1
+ /**
2
+ * StatefulService — a stateful workload with its volume set, mounted.
3
+ *
4
+ * Persistent storage on Control Plane is two resources that have to agree, and
5
+ * the ways they can disagree are all expensive:
6
+ *
7
+ * - `ext4` and `xfs` volume sets bind to exactly one *stateful* workload.
8
+ * Mounting one from a serverless or standard workload is not a degraded
9
+ * mode, it is rejected.
10
+ * - `fileSystemType` and `performanceClass` are both immutable. Changing
11
+ * either later means delete and recreate, which means data loss.
12
+ * - `high-throughput-ssd` has a 200 GB floor, not the 10 GB floor the other
13
+ * classes have, so an initial capacity that is fine for one class is invalid
14
+ * for another.
15
+ * - A volume set is GVC-scoped and only mountable from its own GVC.
16
+ *
17
+ * This composite pairs them so the mount URI, the GVC and the workload type
18
+ * cannot drift apart, and validates the capacity floor at build time rather
19
+ * than at apply time.
20
+ */
21
+ import { Composite, mergeDefaults } from "@intentius/chant";
22
+ import { Workload, VolumeSet } from "../generated/index.js";
23
+ /** Minimum initial capacity in GB, by performance class. */
24
+ const MIN_CAPACITY_GB = {
25
+ "general-purpose-ssd": 10,
26
+ "high-throughput-ssd": 200,
27
+ shared: 10,
28
+ };
29
+ export const StatefulService = Composite((props) => {
30
+ const { name, gvc, image, mountPath, capacityGb, volumeSetName = `${name}-data`, fileSystemType = "ext4", performanceClass = "general-purpose-ssd", recoveryPolicy = "retain", ports = [], containerName = "main", cpu = "50m", memory = "128Mi", env, inboundAllowCidr = [], outboundAllowCidr = [], minScale = 1, maxScale = 1, identityLink, tags, defaults: defs, } = props;
31
+ const floor = MIN_CAPACITY_GB[performanceClass];
32
+ if (floor !== undefined && capacityGb < floor) {
33
+ throw new Error(`StatefulService "${name}": capacityGb ${capacityGb} is below the ${floor} GB minimum for ` +
34
+ `performanceClass "${performanceClass}". Both fileSystemType and performanceClass are immutable ` +
35
+ `after creation, so this is worth getting right before the first apply.`);
36
+ }
37
+ const volumeSet = new VolumeSet(mergeDefaults({
38
+ name: volumeSetName,
39
+ gvc,
40
+ ...(tags && { tags }),
41
+ spec: {
42
+ initialCapacity: capacityGb,
43
+ fileSystemType,
44
+ // Control Plane forces `shared` for a shared filesystem; setting it
45
+ // explicitly keeps the emitted manifest equal to what comes back.
46
+ performanceClass: fileSystemType === "shared" ? "shared" : performanceClass,
47
+ snapshots: { createFinalSnapshot: true },
48
+ },
49
+ }, defs?.volumeSet));
50
+ const workload = new Workload(mergeDefaults({
51
+ name,
52
+ gvc,
53
+ ...(tags && { tags }),
54
+ spec: {
55
+ type: "stateful",
56
+ ...(identityLink && { identityLink }),
57
+ containers: [
58
+ {
59
+ name: containerName,
60
+ image,
61
+ cpu,
62
+ memory,
63
+ ...(ports.length > 0 && {
64
+ ports: ports.map((p) => ({ number: p.number, protocol: p.protocol ?? "http" })),
65
+ }),
66
+ ...(env && {
67
+ env: Object.entries(env).map(([envName, value]) => ({ name: envName, value })),
68
+ }),
69
+ volumes: [
70
+ {
71
+ uri: `cpln://volumeset/${volumeSetName}`,
72
+ path: mountPath,
73
+ recoveryPolicy,
74
+ },
75
+ ],
76
+ },
77
+ ],
78
+ firewallConfig: {
79
+ external: { inboundAllowCIDR: inboundAllowCidr, outboundAllowCIDR: outboundAllowCidr },
80
+ },
81
+ defaultOptions: {
82
+ autoscaling: { minScale, maxScale },
83
+ },
84
+ },
85
+ }, defs?.workload));
86
+ return { workload, volumeSet };
87
+ }, "StatefulService");
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Coverage analysis for the cpln lexicon.
3
+ *
4
+ * The shared `computeCoverage` measures CloudFormation-shaped dimensions
5
+ * (lifecycle flags, return attributes, extension constraints) that an
6
+ * eight-kind OpenAPI lexicon has no analog for — it would report 0% on
7
+ * everything and mean nothing. What cpln needs to know is two different things:
8
+ *
9
+ * 1. **Kind coverage** — which of the API's kinds are modelled as declarables
10
+ * and which are deliberately not, with the reason, so an unmodelled kind
11
+ * is a decision on record rather than an omission nobody noticed.
12
+ * 2. **Property coverage** — for each modelled kind, how much of its writable
13
+ * surface is typed rather than loosened to `Record<string, unknown>`. A
14
+ * drop here means upstream reshaped something and the parser quietly
15
+ * stopped following it.
16
+ *
17
+ * Both are measured against the spec the generator actually reads, so running
18
+ * this after `just generate` compares like with like.
19
+ */
20
+ import { fetchSchemas, SPEC_KEY } from "./spec/fetch.js";
21
+ import { parseCplnOpenAPI } from "./spec/parse.js";
22
+ import { KINDS } from "./kinds.js";
23
+ /**
24
+ * Kinds the API exposes that this lexicon does not model, and why.
25
+ *
26
+ * "First pass" is a real reason, not a placeholder: each of these is a row in
27
+ * `kinds.ts` away, and listing them keeps the gap visible instead of leaving a
28
+ * reader to infer it from the absence.
29
+ */
30
+ export const UNMODELLED_KINDS = {
31
+ agent: "first pass — org infrastructure, not the workload surface",
32
+ auditctx: "first pass — governance",
33
+ cloudaccount: "first pass — org infrastructure",
34
+ group: "first pass — principals",
35
+ serviceaccount: "first pass — principals",
36
+ user: "first pass — principals; membership is invite-driven, not declarative",
37
+ image: "read-mostly — images are produced by `cpln image build`, not declared",
38
+ location: "read-mostly — locations are platform-provided; only `patch` is writable",
39
+ org: "not declarable — an org is immutable and cannot be deleted",
40
+ quota: "read-only",
41
+ mk8s: "first pass — a very large nested spec that deserves its own pass",
42
+ task: "read-only — an operation record, not estate",
43
+ deployment: "read-only — a workload's runtime status",
44
+ };
45
+ const LOOSE = /^Record<string, (unknown|any)>/;
46
+ /** Compute coverage from the spec the generator reads. */
47
+ export async function computeCplnCoverage(options) {
48
+ const schemas = await fetchSchemas({ force: options?.force });
49
+ const raw = schemas.get(SPEC_KEY);
50
+ if (!raw)
51
+ throw new Error(`spec fetch returned no ${SPEC_KEY} entry`);
52
+ const results = parseCplnOpenAPI(raw);
53
+ const resources = results.filter((r) => !r.isProperty);
54
+ const propertyTypes = results.filter((r) => r.isProperty);
55
+ const kinds = [];
56
+ let typed = 0;
57
+ let total = 0;
58
+ for (const kind of KINDS) {
59
+ const resource = resources.find((r) => r.resource.typeName === kind.typeName);
60
+ if (!resource)
61
+ continue;
62
+ const properties = resource.resource.properties;
63
+ const loose = properties.filter((p) => LOOSE.test(p.tsType)).length;
64
+ // Property types whose name is derived from this kind's class name.
65
+ const owned = propertyTypes.filter((p) => p.resource.typeName.split("::").pop().startsWith(kind.className)).length;
66
+ kinds.push({ kind: kind.kind, properties: properties.length, propertyTypes: owned, loose });
67
+ typed += properties.length - loose;
68
+ total += properties.length;
69
+ }
70
+ return {
71
+ modelled: resources.length,
72
+ unmodelled: Object.keys(UNMODELLED_KINDS).length,
73
+ propertyTypes: propertyTypes.length,
74
+ kinds,
75
+ typedRatio: total === 0 ? 0 : typed / total,
76
+ };
77
+ }
78
+ /**
79
+ * Print the coverage report. Exits non-zero when `minOverall` is set and the
80
+ * typed ratio falls below it, so CI can gate on the surface not silently
81
+ * loosening.
82
+ */
83
+ export async function analyzeCplnCoverage(options) {
84
+ const coverage = await computeCplnCoverage({ force: options?.force });
85
+ console.error(`cpln coverage: ${coverage.modelled} kinds modelled, ${coverage.unmodelled} not, ` +
86
+ `${coverage.propertyTypes} property types, ${(coverage.typedRatio * 100).toFixed(0)}% of writable ` +
87
+ `properties typed`);
88
+ for (const kind of coverage.kinds) {
89
+ const detail = kind.loose > 0 ? ` (${kind.loose} loose)` : "";
90
+ console.error(` ${kind.kind.padEnd(11)} ${String(kind.properties).padStart(2)} properties, ` +
91
+ `${String(kind.propertyTypes).padStart(3)} property types${detail}`);
92
+ }
93
+ if (options?.verbose) {
94
+ console.error("\nNot modelled:");
95
+ for (const [kind, reason] of Object.entries(UNMODELLED_KINDS)) {
96
+ console.error(` ${kind.padEnd(15)} ${reason}`);
97
+ }
98
+ }
99
+ if (options?.minOverall !== undefined && coverage.typedRatio * 100 < options.minOverall) {
100
+ console.error(`\nTyped ratio ${(coverage.typedRatio * 100).toFixed(0)}% is below the ${options.minOverall}% minimum.`);
101
+ process.exitCode = 1;
102
+ }
103
+ return coverage;
104
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Live introspection of cpln resources — the read-back seam for chant's
3
+ * plan/drift machinery.
4
+ *
5
+ * Drives core's observer harness (`observeEntities`) rather than re-deriving
6
+ * its control flow: bind-or-not-observe-all with a typed reason, per-entity
7
+ * tri-state routing, and a read throw degrading to `read-failed` rather than a
8
+ * silent absence — which would classify as a spurious `create` and, on the
9
+ * delete path, as an orphan.
10
+ *
11
+ * The read is a lookup, not a fetch. Control Plane has a by-name endpoint for
12
+ * every kind, but it also has an **org-wide rollup** for the GVC-scoped ones
13
+ * (`/org/{org}/workload` alongside `/org/{org}/gvc/{gvc}/workload`), so listing
14
+ * each declared kind once and indexing it costs one request per kind instead of
15
+ * one per resource — or, for GVC-scoped kinds, one per GVC. The list promise is
16
+ * cached per kind, so concurrent reads share a request and a failed list marks
17
+ * only that kind's entities read-failed.
18
+ *
19
+ * Ownership is unusually clean here. Every kind carries a free-form `tags` map
20
+ * and every read path returns it, so the marker resolves on the thin read —
21
+ * which is not true of aws, where `describe-stack-resources` returns no tags
22
+ * and an `owned: true` thin read can only answer `unknown`. `describeResources`
23
+ * is therefore declared on the ownership channel.
24
+ *
25
+ * No secret value is ever read: the `-reveal` endpoint is not called from this
26
+ * path, and a `secret`'s `data` is not part of what is compared.
27
+ */
28
+ import { observeEntities, } from "@intentius/chant/observation";
29
+ import { hasOwnershipMarker } from "@intentius/chant/ownership";
30
+ import { CplnCredentialsError, defaultCplnHttp, listKind, resolveConfig, } from "./api.js";
31
+ import { kindByTypeName } from "./kinds.js";
32
+ import { CPLN_TAG_OWNERSHIP_KEYS } from "./ownership.js";
33
+ /**
34
+ * Observe every declared cpln resource against the live org.
35
+ */
36
+ export async function describeResources(options) {
37
+ const declared = [];
38
+ for (const name of options.entityNames) {
39
+ const entity = options.entities.get(name);
40
+ if (!entity)
41
+ continue;
42
+ declared.push({ name, type: entity.entityType, props: entity.props });
43
+ }
44
+ return observeEntities(declared, adapter(options));
45
+ }
46
+ function adapter(options) {
47
+ return {
48
+ async bind() {
49
+ const config = resolveConfig(options.config);
50
+ return { config, http: options.http ?? defaultCplnHttp(config), lists: new Map() };
51
+ },
52
+ classifyBindFailure(err) {
53
+ if (err instanceof CplnCredentialsError) {
54
+ return { reason: "no-credentials", detail: err.message };
55
+ }
56
+ return { reason: "read-failed", detail: err instanceof Error ? err.message : String(err) };
57
+ },
58
+ async read(client, entity) {
59
+ const kind = kindByTypeName(entity.type);
60
+ if (!kind) {
61
+ return { unobserved: { reason: "unsupported-kind", detail: `${entity.type} is not a modelled cpln kind` } };
62
+ }
63
+ const index = await listFor(client, kind);
64
+ const declaredName = typeof entity.props.name === "string" ? entity.props.name : entity.name;
65
+ const live = index.get(declaredName);
66
+ if (!live)
67
+ return { absent: true };
68
+ // A GVC-scoped kind is listed org-wide, so two GVCs may hold resources of
69
+ // the same name. Compare the live resource's own GVC before calling it a
70
+ // match — otherwise a workload in `staging` would be reported as the
71
+ // observed state of the one declared in `prod`.
72
+ if (kind.gvcScoped) {
73
+ const declaredGvc = typeof entity.props.gvc === "string" ? entity.props.gvc : undefined;
74
+ const liveGvc = gvcOf(live);
75
+ if (declaredGvc && liveGvc && declaredGvc !== liveGvc)
76
+ return { absent: true };
77
+ }
78
+ const owned = hasOwnershipMarker(live.tags, CPLN_TAG_OWNERSHIP_KEYS);
79
+ // `owned: true` filters rather than hides: a foreign resource becomes a
80
+ // typed NOT-OBSERVED, so the caller can tell "not chant's" from "not
81
+ // there" — the latter would read as a create.
82
+ if (options.owned && !owned) {
83
+ return {
84
+ unobserved: {
85
+ reason: "filtered",
86
+ detail: `live ${kind.kind} "${declaredName}" carries no ${CPLN_TAG_OWNERSHIP_KEYS.managedBy} tag`,
87
+ },
88
+ };
89
+ }
90
+ return { present: toMetadata(kind, live, owned) };
91
+ },
92
+ };
93
+ }
94
+ /** List a kind once per observation, sharing the promise across entities. */
95
+ function listFor(client, kind) {
96
+ const cached = client.lists.get(kind.kind);
97
+ if (cached)
98
+ return cached;
99
+ const pending = listKind(client.http, client.config, kind);
100
+ client.lists.set(kind.kind, pending);
101
+ return pending;
102
+ }
103
+ /**
104
+ * The GVC a live resource belongs to.
105
+ *
106
+ * The org-wide rollups do not repeat the GVC as a field; it is in the `links`
107
+ * array as a `gvc` relation (`/org/acme/gvc/prod`). `volumeset` and `identity`
108
+ * additionally carry a `gvc` of their own, in two different shapes, so those
109
+ * are read as a fallback.
110
+ */
111
+ export function gvcOf(resource) {
112
+ for (const link of resource.links ?? []) {
113
+ if (link.rel !== "gvc" || typeof link.href !== "string")
114
+ continue;
115
+ const name = link.href.split("/").filter(Boolean).pop();
116
+ if (name)
117
+ return name;
118
+ }
119
+ const own = resource.gvc;
120
+ if (typeof own === "string" && own.length > 0)
121
+ return own.split("/").filter(Boolean).pop();
122
+ if (own && typeof own === "object") {
123
+ const name = own.name;
124
+ if (typeof name === "string")
125
+ return name;
126
+ }
127
+ return undefined;
128
+ }
129
+ /**
130
+ * Map a live resource onto chant's `ResourceMetadata`.
131
+ *
132
+ * `status` is a string in chant's model and an object in Control Plane's, so it
133
+ * is summarized rather than stringified: a workload's readiness where there is
134
+ * one, `"active"` otherwise. The full object stays available under `attributes`
135
+ * for anything that wants to look closer.
136
+ */
137
+ function toMetadata(kind, live, owned) {
138
+ return {
139
+ type: kind.typeName,
140
+ physicalId: live.id,
141
+ status: statusOf(live),
142
+ lastUpdated: live.lastModified,
143
+ ownership: owned ? "owned" : "foreign",
144
+ attributes: {
145
+ name: live.name,
146
+ version: live.version,
147
+ refs: referenceAttributes(kind, live),
148
+ ...(live.tags ? { tags: live.tags } : {}),
149
+ ...(kind.gvcScoped ? { gvc: gvcOf(live) } : {}),
150
+ ...(live.status ? { status: live.status } : {}),
151
+ },
152
+ };
153
+ }
154
+ /**
155
+ * Resolve this resource's outbound links down to the bare names they point at.
156
+ *
157
+ * Edge reconstruction matches identifiers exactly, and a Control Plane link
158
+ * (`//gvc/prod/identity/api`) never equals the `name` it references. Resolving
159
+ * here — where the whole live resource is in hand — means `reference-catalog.ts`
160
+ * is a plain table of paths rather than a table of paths plus extractors, and
161
+ * means no second enrichment pass over the observed set.
162
+ *
163
+ * Keys are stable regardless of kind; an absent relation is simply missing.
164
+ */
165
+ export function referenceAttributes(kind, live) {
166
+ const refs = {};
167
+ const spec = (live.spec ?? {});
168
+ if (kind.gvcScoped) {
169
+ const gvc = gvcOf(live);
170
+ if (gvc)
171
+ refs.gvc = gvc;
172
+ }
173
+ const identity = linkName(spec.identityLink);
174
+ if (identity)
175
+ refs.identity = identity;
176
+ const pullSecrets = linkNames(spec.pullSecretLinks);
177
+ if (pullSecrets.length > 0)
178
+ refs.pullSecrets = pullSecrets;
179
+ // A domain's GVC is a link rather than a scoping relation, so it is read from
180
+ // the spec even though the kind is org-scoped.
181
+ if (kind.kind === "domain") {
182
+ const gvc = linkName(spec.gvcLink);
183
+ if (gvc)
184
+ refs.gvc = gvc;
185
+ }
186
+ const workloads = [
187
+ ...linkNames(spec.workloadLink),
188
+ ...linkNames(spec.link),
189
+ ...routeWorkloads(spec),
190
+ ].filter((name, index, all) => all.indexOf(name) === index);
191
+ if (workloads.length > 0)
192
+ refs.workloads = workloads;
193
+ const targets = linkNames(live.targetLinks);
194
+ if (targets.length > 0)
195
+ refs.targets = targets;
196
+ const volumeSets = mountedVolumeSets(spec);
197
+ if (volumeSets.length > 0)
198
+ refs.volumeSets = volumeSets;
199
+ return refs;
200
+ }
201
+ /** The last path segment of a link (`//gvc/prod/identity/api` → `api`). */
202
+ function linkName(value) {
203
+ if (typeof value !== "string" || value.length === 0)
204
+ return undefined;
205
+ if (!value.startsWith("//") && !value.startsWith("/org/") && !value.startsWith("cpln://"))
206
+ return undefined;
207
+ // A `cpln://secret/name.field` reference names a field after the resource.
208
+ const last = value.split("/").filter(Boolean).pop();
209
+ return last?.split(".")[0];
210
+ }
211
+ /** Resolve a link or a list of links to names, dropping anything unparseable. */
212
+ function linkNames(value) {
213
+ const values = Array.isArray(value) ? value : [value];
214
+ return values.map(linkName).filter((name) => !!name);
215
+ }
216
+ /** Workloads named by a domain's per-port routes. */
217
+ function routeWorkloads(spec) {
218
+ const names = [];
219
+ const ports = Array.isArray(spec.ports) ? spec.ports : [];
220
+ for (const port of ports) {
221
+ const routes = port.routes;
222
+ if (!Array.isArray(routes))
223
+ continue;
224
+ for (const route of routes) {
225
+ const name = linkName(route.workloadLink);
226
+ if (name)
227
+ names.push(name);
228
+ }
229
+ }
230
+ return names;
231
+ }
232
+ /** Volume sets a workload's containers mount, from their `cpln://volumeset/…` URIs. */
233
+ function mountedVolumeSets(spec) {
234
+ const names = [];
235
+ const containers = Array.isArray(spec.containers) ? spec.containers : [];
236
+ for (const container of containers) {
237
+ const volumes = container.volumes;
238
+ if (!Array.isArray(volumes))
239
+ continue;
240
+ for (const volume of volumes) {
241
+ const uri = volume.uri;
242
+ if (typeof uri !== "string" || !uri.startsWith("cpln://volumeset/"))
243
+ continue;
244
+ names.push(uri.slice("cpln://volumeset/".length));
245
+ }
246
+ }
247
+ return names;
248
+ }
249
+ /** A one-word status for a live resource. */
250
+ function statusOf(live) {
251
+ const health = live.health;
252
+ if (health && typeof health === "object") {
253
+ const readiness = health.readiness;
254
+ if (typeof readiness === "string" && readiness.length > 0)
255
+ return readiness;
256
+ }
257
+ const status = live.status;
258
+ if (status && typeof status === "object") {
259
+ const ready = status.ready;
260
+ if (typeof ready === "boolean")
261
+ return ready ? "ready" : "not-ready";
262
+ }
263
+ return "active";
264
+ }