@intentius/chant-lexicon-cpln 0.124.31 → 0.124.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,90 @@
1
+ /**
2
+ * The cpln lexicon's `chant audit` catalog — metadata for every CPL check.
3
+ *
4
+ * Every entry carries `yamlBased: false`. That is not an oversight: all of
5
+ * cpln's checks read the chant model (`ctx.entities`) rather than the emitted
6
+ * manifests (`ctx.outputs`), because the facts they need — which GVC a workload
7
+ * is placed in, which volume set a mount names, whether a policy principal is
8
+ * GVC-qualified — are relationships between declared resources rather than text
9
+ * in one document. They fire on `chant build` and cannot fire on an audit of
10
+ * standalone cpln YAML. `auditRule()` hardcodes `yamlBased: true`, so these are
11
+ * constructed directly.
12
+ *
13
+ * A test asserts this catalog covers every check the plugin registers, so a
14
+ * check added without an entry here fails the build rather than contributing
15
+ * nothing to `chant audit` silently.
16
+ */
17
+ import { cplnAuditLineage } from "./audit-lineage.js";
18
+ import { applyLineage } from "@intentius/chant/audit/catalog";
19
+ // ── Authorities ────────────────────────────────────────────────────
20
+ const CPLN_WORKLOAD = {
21
+ name: "Control Plane — Workload reference",
22
+ url: "https://docs.controlplane.com/reference/workload",
23
+ };
24
+ const CPLN_SECRETS = {
25
+ name: "Control Plane — Secret access model",
26
+ url: "https://docs.controlplane.com/reference/secret",
27
+ };
28
+ const CPLN_POLICY = {
29
+ name: "Control Plane — Policy reference",
30
+ url: "https://docs.controlplane.com/reference/policy",
31
+ };
32
+ /**
33
+ * An entry with no external citation.
34
+ *
35
+ * Deliberately unable to take one. Core asserts two invariants over the
36
+ * aggregated catalog — an authority citation always means `security`, and it
37
+ * only attaches to `merge-worthy` — so a citation is not decoration, it is a
38
+ * claim about the finding's weight. Splitting the constructors makes the pair
39
+ * unviolatable here rather than caught later by core's test.
40
+ */
41
+ function rule(id, tier, category, title, remediation) {
42
+ return { id, tier, fixKind: "guidance", category, title, remediation, yamlBased: false };
43
+ }
44
+ /**
45
+ * A merge-worthy security entry, which is the only kind that may cite an
46
+ * authority. The tier and category are not parameters because there is only
47
+ * one legal combination.
48
+ */
49
+ function securityRule(id, title, remediation, authority) {
50
+ return {
51
+ id,
52
+ tier: "merge-worthy",
53
+ fixKind: "guidance",
54
+ category: "security",
55
+ title,
56
+ remediation,
57
+ authority,
58
+ yamlBased: false,
59
+ };
60
+ }
61
+ export const cplnAuditCatalog = {
62
+ // ── Source-level rules ───────────────────────────────────────────
63
+ CPL001: securityRule("CPL001", "Literal credential in a cpln declaration", "Store the value as a Secret and reference it as cpln://secret/NAME.FIELD.", [CPLN_SECRETS]),
64
+ CPL002: rule("CPL002", "report-only", "best-practice", "Hand-written Control Plane link where a resource reference would do", "Pass the declared resource; the serializer emits the correct link, GVC qualifier included."),
65
+ // ── Security ─────────────────────────────────────────────────────
66
+ CPL010: securityRule("CPL010", "Workload allows outbound traffic to every address", "Narrow outboundAllowCIDR, or use outboundAllowHostname with wildcards.", [CPLN_WORKLOAD]),
67
+ CPL011: rule("CPL011", "report-only", "security", "Internal firewall opened to the whole org", 'Prefer "same-gvc", or "workload-list" naming the specific workloads.'),
68
+ CPL012: securityRule("CPL012", "Credential-shaped environment variable set to a literal", "Reference a secret: cpln://secret/NAME.FIELD.", [CPLN_SECRETS]),
69
+ CPL013: securityRule("CPL013", "Policy binds an identity by its unqualified link", "Use //gvc/<gvc>/identity/<name>; the bare //identity/<name> form is silently ignored.", [CPLN_POLICY]),
70
+ CPL014: rule("CPL014", "merge-worthy", "correctness", "Secret reference names no field", "Qualify the reference: cpln://secret/NAME.payload, .username, .cert, or a dictionary key."),
71
+ // ── Correctness ──────────────────────────────────────────────────
72
+ CPL020: rule("CPL020", "merge-worthy", "correctness", "Serverless workload does not expose exactly one HTTP port", "Expose exactly one http/http2 port, or change the workload to type standard."),
73
+ CPL021: rule("CPL021", "merge-worthy", "correctness", "Cron workload shape is invalid", "Cron requires spec.job.schedule and must expose no ports; no other type may set spec.job."),
74
+ CPL022: rule("CPL022", "merge-worthy", "correctness", "Container ports conflict", "Use `ports` rather than `port`, and keep port numbers unique across a workload's containers."),
75
+ CPL023: rule("CPL023", "merge-worthy", "correctness", "Container resources violate a platform floor or the memory-to-CPU ratio", "CPU ≥ 25m, memory ≥ 32Mi, and memory(MiB)/cpu(m) ≤ 8 — or add the cpln/relaxMemoryToCpuRatio tag."),
76
+ CPL024: rule("CPL024", "merge-worthy", "correctness", "Health-check probe sets no handler, or more than one", "Set exactly one of exec, grpc, tcpSocket, httpGet per probe."),
77
+ CPL025: rule("CPL025", "merge-worthy", "correctness", "Autoscaling configuration is not a valid combination", "`metric` and `multi` are exclusive; `target` belongs to the single-metric form and caps at 100 for cpu/memory."),
78
+ CPL026: rule("CPL026", "merge-worthy", "correctness", "minScale 0 with a strategy that cannot scale to zero", "Serverless needs rps or concurrency; standard and stateful need KEDA, enabled at the GVC."),
79
+ CPL027: rule("CPL027", "merge-worthy", "correctness", "Capacity AI combined with a feature it excludes", "Set defaultOptions.capacityAI false, or drop the CPU-utilization scaling, multi-metric scaling, or GPU."),
80
+ CPL028: rule("CPL028", "merge-worthy", "correctness", "Volume set capacity, filesystem binding, or mount paths are invalid", "Clear the performance-class capacity floor, mount ext4/xfs only from stateful workloads, and keep mount paths disjoint."),
81
+ CPL029: rule("CPL029", "merge-worthy", "correctness", "Control Plane link does not resolve, or crosses a GVC boundary", "Point the link at a declared resource in the right GVC; identities cannot be shared across GVCs."),
82
+ CPL030: rule("CPL030", "merge-worthy", "correctness", "Domain DNS mode, challenge, or routing target is invalid", "Apex domains need cname; ns needs dns01; pick exactly one of gvcLink, workloadLink, ports[].routes."),
83
+ // ── Best practice ────────────────────────────────────────────────
84
+ CPL040: rule("CPL040", "report-only", "best-practice", "Container image is not pinned", "Pin a version tag or a digest; a scale-from-zero cold start re-pulls."),
85
+ CPL041: rule("CPL041", "merge-worthy", "correctness", "Image reference uses a registry prefix Control Plane rejects", "Drop the docker.io/ prefix; address this org's own images as //image/NAME:TAG."),
86
+ CPL042: rule("CPL042", "merge-worthy", "correctness", "GVC declares no placement", "Set spec.staticPlacement.locationLinks, e.g. /org/<org>/location/aws-us-east-1."),
87
+ CPL043: rule("CPL043", "merge-worthy", "correctness", "Policy target kind, scope, or origin is not well formed", "Pick exactly one target scope, target a policy-targetable kind, and never set origin by hand."),
88
+ };
89
+ // Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
90
+ applyLineage(cplnAuditCatalog, cplnAuditLineage);
@@ -0,0 +1,32 @@
1
+ export const cplnAuditLineage = {
2
+ CPL020: [
3
+ { tool: "controlplane-docs", rule: "Workload types: serverless", url: "https://docs.controlplane.com/reference/workload/types", relation: "overlaps" },
4
+ ],
5
+ CPL021: [
6
+ { tool: "controlplane-docs", rule: "Workload types: cron", url: "https://docs.controlplane.com/reference/workload/types", relation: "overlaps" },
7
+ ],
8
+ CPL026: [
9
+ { tool: "controlplane-docs", rule: "Autoscaling: scale to zero", url: "https://docs.controlplane.com/reference/workload/autoscaling", relation: "overlaps" },
10
+ ],
11
+ CPL027: [
12
+ { tool: "controlplane-docs", rule: "Autoscaling: Capacity AI", url: "https://docs.controlplane.com/reference/workload/autoscaling", relation: "overlaps" },
13
+ ],
14
+ CPL028: [
15
+ { tool: "controlplane-docs", rule: "Volume set: performance classes / file system type", url: "https://docs.controlplane.com/reference/volumeset#performance-classes", relation: "overlaps" },
16
+ ],
17
+ CPL029: [
18
+ { tool: "controlplane-docs", rule: "Identity: GVC scope", url: "https://docs.controlplane.com/reference/identity", relation: "overlaps" },
19
+ ],
20
+ CPL030: [
21
+ { tool: "controlplane-docs", rule: "Domain: apex domain considerations", url: "https://docs.controlplane.com/reference/domain#apex-domain-considerations", relation: "overlaps" },
22
+ ],
23
+ CPL040: [
24
+ { tool: "controlplane-docs", rule: "Image: tags and digests", url: "https://docs.controlplane.com/reference/image#image-tags-and-digests", relation: "overlaps" },
25
+ ],
26
+ CPL041: [
27
+ { tool: "controlplane-docs", rule: "Image: reference formats", url: "https://docs.controlplane.com/reference/image#image-reference-formats", relation: "overlaps" },
28
+ ],
29
+ CPL042: [
30
+ { tool: "controlplane-docs", rule: "GVC locations", url: "https://docs.controlplane.com/reference/gvc#gvc-locations", relation: "overlaps" },
31
+ ],
32
+ };
@@ -0,0 +1,34 @@
1
+ import { readArray } from "../../entity-props.js";
2
+ import { WORKLOAD, entitiesOfType } from "./helpers.js";
3
+ const ANY_ADDRESS = "0.0.0.0/0";
4
+ /**
5
+ * CPL010: a workload that may call out to the entire internet.
6
+ *
7
+ * Inbound `0.0.0.0/0` is how a public service is *supposed* to be written, so
8
+ * it is not flagged. Outbound `0.0.0.0/0` is different: it is rarely required,
9
+ * it is the egress path for anything that gets a foothold in the container,
10
+ * and Control Plane supports hostname rules (with wildcards) precisely so it
11
+ * does not have to be all-or-nothing.
12
+ */
13
+ export const unrestrictedOutboundCheck = {
14
+ id: "CPL010",
15
+ description: "Workloads should not allow outbound traffic to every address",
16
+ check(ctx) {
17
+ const diagnostics = [];
18
+ for (const [name, entity] of entitiesOfType(ctx.entities, WORKLOAD)) {
19
+ const outbound = readArray(entity, "spec", "firewallConfig", "external", "outboundAllowCIDR");
20
+ if (!outbound.includes(ANY_ADDRESS))
21
+ continue;
22
+ diagnostics.push({
23
+ checkId: "CPL010",
24
+ severity: "warning",
25
+ message: `Workload "${name}" allows outbound traffic to ${ANY_ADDRESS}. Narrow it to the CIDRs it ` +
26
+ `actually calls, or use \`outboundAllowHostname\` (wildcards like "*.amazonaws.com" are ` +
27
+ `supported) so egress is bounded.`,
28
+ entity: name,
29
+ lexicon: "cpln",
30
+ });
31
+ }
32
+ return diagnostics;
33
+ },
34
+ };
@@ -0,0 +1,32 @@
1
+ import { readString } from "../../entity-props.js";
2
+ import { WORKLOAD, entitiesOfType } from "./helpers.js";
3
+ /**
4
+ * CPL011: internal firewall opened to the whole org.
5
+ *
6
+ * `same-org` lets every workload in every GVC reach this one, which crosses the
7
+ * boundary a GVC exists to draw. `same-gvc` and `workload-list` cover almost
8
+ * every real case, and `workload-list` works cross-GVC when that is genuinely
9
+ * needed.
10
+ */
11
+ export const internalFirewallScopeCheck = {
12
+ id: "CPL011",
13
+ description: "Internal firewall should not be opened to the entire org",
14
+ check(ctx) {
15
+ const diagnostics = [];
16
+ for (const [name, entity] of entitiesOfType(ctx.entities, WORKLOAD)) {
17
+ const allowType = readString(entity, "spec", "firewallConfig", "internal", "inboundAllowType");
18
+ if (allowType !== "same-org")
19
+ continue;
20
+ diagnostics.push({
21
+ checkId: "CPL011",
22
+ severity: "warning",
23
+ message: `Workload "${name}" sets internal inboundAllowType "same-org", so every workload in the org — ` +
24
+ `including other GVCs — can reach it. Prefer "same-gvc", or "workload-list" with the specific ` +
25
+ `workloads (which also works across GVCs).`,
26
+ entity: name,
27
+ lexicon: "cpln",
28
+ });
29
+ }
30
+ return diagnostics;
31
+ },
32
+ };
@@ -0,0 +1,51 @@
1
+ import { readArray, readString } from "../../entity-props.js";
2
+ import { WORKLOAD, entitiesOfType } from "./helpers.js";
3
+ /** `cpln://secret/NAME.FIELD` — the field qualifier is the part that matters. */
4
+ const SECRET_REF = /^cpln:\/\/secret\/([^/.\s]+)(?:\.([^/\s]+))?$/;
5
+ /** Env var names that read as credentials. */
6
+ const CREDENTIAL_NAME = /(password|passwd|secret|token|apikey|api_key|access_key|private_key|credential)/i;
7
+ /**
8
+ * Values that look like a deliberate non-secret rather than a leaked one: an
9
+ * empty string, an obvious placeholder, or another `cpln://` reference.
10
+ */
11
+ const PLACEHOLDER = /^(|changeme|placeholder|todo|xxx+|\*+|<[^>]*>|\$\{[^}]*\})$/i;
12
+ /**
13
+ * CPL012: a credential-shaped env var carrying a literal value.
14
+ *
15
+ * The alternative is one string change — `cpln://secret/db.password` — so a
16
+ * literal here is almost always an oversight rather than a decision. Env values
17
+ * end up in the workload spec, which is readable by anyone with `get` on the
18
+ * workload, and in every diff of the manifest.
19
+ */
20
+ export const noLiteralCredentialEnvCheck = {
21
+ id: "CPL012",
22
+ description: "Credential-shaped environment variables must reference a secret, not a literal",
23
+ check(ctx) {
24
+ const diagnostics = [];
25
+ for (const [name, entity] of entitiesOfType(ctx.entities, WORKLOAD)) {
26
+ for (const container of readArray(entity, "spec", "containers")) {
27
+ const containerName = readString(container, "name") ?? "?";
28
+ for (const env of readArray(container, "env")) {
29
+ const envName = readString(env, "name");
30
+ const value = readString(env, "value");
31
+ if (!envName || value === undefined)
32
+ continue;
33
+ if (!CREDENTIAL_NAME.test(envName))
34
+ continue;
35
+ if (value.startsWith("cpln://") || PLACEHOLDER.test(value))
36
+ continue;
37
+ diagnostics.push({
38
+ checkId: "CPL012",
39
+ severity: "error",
40
+ message: `Workload "${name}" container "${containerName}" sets env "${envName}" to a literal value. ` +
41
+ `Reference a secret instead — \`cpln://secret/NAME.FIELD\` — so the value is not stored in ` +
42
+ `the workload spec or its diffs.`,
43
+ entity: name,
44
+ lexicon: "cpln",
45
+ });
46
+ }
47
+ }
48
+ }
49
+ return diagnostics;
50
+ },
51
+ };
@@ -0,0 +1,40 @@
1
+ import { propsOf, readArray } from "../../entity-props.js";
2
+ import { POLICY, entitiesOfType } from "./helpers.js";
3
+ /**
4
+ * CPL013: an identity principal link that Control Plane will ignore.
5
+ *
6
+ * `//identity/NAME` is accepted by the policy API and then has no effect. Only
7
+ * the GVC-qualified `//gvc/GVC/identity/NAME` grants anything. Nothing reports
8
+ * this — the policy exists, the binding exists, and the permission is simply
9
+ * never granted.
10
+ */
11
+ export const qualifiedIdentityPrincipalCheck = {
12
+ id: "CPL013",
13
+ description: "Policy bindings must name identities by their GVC-qualified link",
14
+ check(ctx) {
15
+ const diagnostics = [];
16
+ for (const [name, entity] of entitiesOfType(ctx.entities, POLICY)) {
17
+ for (const binding of readArray(entity, "bindings")) {
18
+ const links = propsOf(binding).principalLinks;
19
+ if (!Array.isArray(links))
20
+ continue;
21
+ for (const link of links) {
22
+ if (typeof link !== "string")
23
+ continue;
24
+ if (!/^\/\/identity\/[^/]+$/.test(link))
25
+ continue;
26
+ diagnostics.push({
27
+ checkId: "CPL013",
28
+ severity: "error",
29
+ message: `Policy "${name}" binds the principal "${link}". Control Plane silently ignores the bare ` +
30
+ `//identity/NAME form — use //gvc/<gvc>/identity/${link.slice("//identity/".length)} instead. ` +
31
+ `The policy will apply cleanly and grant nothing as written.`,
32
+ entity: name,
33
+ lexicon: "cpln",
34
+ });
35
+ }
36
+ }
37
+ }
38
+ return diagnostics;
39
+ },
40
+ };
@@ -0,0 +1,47 @@
1
+ import { readArray, readString } from "../../entity-props.js";
2
+ import { WORKLOAD, entitiesOfType } from "./helpers.js";
3
+ /** `cpln://secret/NAME.FIELD` — the field qualifier is the part that matters. */
4
+ const SECRET_REF = /^cpln:\/\/secret\/([^/.\s]+)(?:\.([^/\s]+))?$/;
5
+ /**
6
+ * CPL014: a secret reference with no field qualifier.
7
+ *
8
+ * `cpln://secret/db` resolves to nothing for every secret type except `gcp`,
9
+ * which is conventionally mounted as a whole JSON file. The field name is not
10
+ * optional: `opaque` needs `.payload`, `userpass` needs `.username`/`.password`,
11
+ * `tls` needs `.cert`/`.key`, and a `dictionary` needs one of its keys.
12
+ *
13
+ * Volume mounts are exempt — mounting a secret as a directory is the documented
14
+ * way to consume a `dictionary` or a `gcp` credential file.
15
+ */
16
+ export const qualifiedSecretRefCheck = {
17
+ id: "CPL014",
18
+ description: "Secret references in environment variables must name a field",
19
+ check(ctx) {
20
+ const diagnostics = [];
21
+ for (const [name, entity] of entitiesOfType(ctx.entities, WORKLOAD)) {
22
+ for (const container of readArray(entity, "spec", "containers")) {
23
+ const containerName = readString(container, "name") ?? "?";
24
+ for (const env of readArray(container, "env")) {
25
+ const envName = readString(env, "name") ?? "?";
26
+ const value = readString(env, "value");
27
+ if (!value)
28
+ continue;
29
+ const match = SECRET_REF.exec(value);
30
+ if (!match || match[2])
31
+ continue;
32
+ diagnostics.push({
33
+ checkId: "CPL014",
34
+ severity: "error",
35
+ message: `Workload "${name}" container "${containerName}" sets env "${envName}" to "${value}", which ` +
36
+ `names no field. Use \`cpln://secret/${match[1]}.FIELD\` — \`.payload\` for opaque, ` +
37
+ `\`.username\`/\`.password\` for userpass, \`.cert\`/\`.key\` for tls, a key name for a ` +
38
+ `dictionary. An unqualified reference resolves to nothing at runtime.`,
39
+ entity: name,
40
+ lexicon: "cpln",
41
+ });
42
+ }
43
+ }
44
+ }
45
+ return diagnostics;
46
+ },
47
+ };
@@ -0,0 +1,51 @@
1
+ import { readArray, readString } from "../../entity-props.js";
2
+ import { WORKLOAD, entitiesOfType, exposedPorts, workloadType } from "./helpers.js";
3
+ /**
4
+ * CPL020: a serverless workload must expose exactly one HTTP port.
5
+ *
6
+ * Not "at least one" and not "any number" — exactly one, and it must be HTTP.
7
+ * Zero is the more common mistake and the more confusing one, because the
8
+ * workload deploys and reports healthy while serving nothing.
9
+ */
10
+ export const serverlessSinglePortCheck = {
11
+ id: "CPL020",
12
+ description: "Serverless workloads must expose exactly one HTTP port",
13
+ check(ctx) {
14
+ const diagnostics = [];
15
+ for (const [name, entity] of entitiesOfType(ctx.entities, WORKLOAD)) {
16
+ if (workloadType(entity) !== "serverless")
17
+ continue;
18
+ const ports = exposedPorts(entity);
19
+ if (ports.length !== 1) {
20
+ diagnostics.push({
21
+ checkId: "CPL020",
22
+ severity: "error",
23
+ message: `Serverless workload "${name}" exposes ${ports.length} ports; exactly one HTTP port is required. ` +
24
+ (ports.length === 0
25
+ ? `It will deploy and serve nothing.`
26
+ : `Use a standard workload if it genuinely needs several.`),
27
+ entity: name,
28
+ lexicon: "cpln",
29
+ });
30
+ continue;
31
+ }
32
+ // The single port must be HTTP. `tcp` and `grpc` need a standard workload.
33
+ for (const container of readArray(entity, "spec", "containers")) {
34
+ for (const port of readArray(container, "ports")) {
35
+ const protocol = readString(port, "protocol") ?? "http";
36
+ if (protocol === "http" || protocol === "http2")
37
+ continue;
38
+ diagnostics.push({
39
+ checkId: "CPL020",
40
+ severity: "error",
41
+ message: `Serverless workload "${name}" exposes a "${protocol}" port. Serverless supports only http ` +
42
+ `and http2 — use a standard workload for ${protocol}.`,
43
+ entity: name,
44
+ lexicon: "cpln",
45
+ });
46
+ }
47
+ }
48
+ }
49
+ return diagnostics;
50
+ },
51
+ };
@@ -0,0 +1,55 @@
1
+ import { readPath, readString } from "../../entity-props.js";
2
+ import { WORKLOAD, entitiesOfType, exposedPorts, workloadType } from "./helpers.js";
3
+ /**
4
+ * CPL021: a cron workload needs a schedule and must expose no ports.
5
+ *
6
+ * Cron cannot serve traffic, so a port on one is always a misunderstanding —
7
+ * usually a workload converted from serverless with the ports left behind. A
8
+ * missing `spec.job.schedule` is the other half: `spec.job` is *required* for
9
+ * cron and forbidden for every other type.
10
+ */
11
+ export const cronJobShapeCheck = {
12
+ id: "CPL021",
13
+ description: "Cron workloads must declare a schedule and expose no ports",
14
+ check(ctx) {
15
+ const diagnostics = [];
16
+ for (const [name, entity] of entitiesOfType(ctx.entities, WORKLOAD)) {
17
+ const type = workloadType(entity);
18
+ const job = readPath(entity, "spec", "job");
19
+ const schedule = readString(entity, "spec", "job", "schedule");
20
+ if (type === "cron") {
21
+ if (!schedule) {
22
+ diagnostics.push({
23
+ checkId: "CPL021",
24
+ severity: "error",
25
+ message: `Cron workload "${name}" has no spec.job.schedule. A cron workload requires one.`,
26
+ entity: name,
27
+ lexicon: "cpln",
28
+ });
29
+ }
30
+ const ports = exposedPorts(entity);
31
+ if (ports.length > 0) {
32
+ diagnostics.push({
33
+ checkId: "CPL021",
34
+ severity: "error",
35
+ message: `Cron workload "${name}" exposes ${ports.length} port(s) (${ports.join(", ")}). Cron workloads ` +
36
+ `cannot serve traffic — remove the ports, or use a standard workload.`,
37
+ entity: name,
38
+ lexicon: "cpln",
39
+ });
40
+ }
41
+ continue;
42
+ }
43
+ if (job !== undefined) {
44
+ diagnostics.push({
45
+ checkId: "CPL021",
46
+ severity: "error",
47
+ message: `Workload "${name}" is type "${type}" but sets spec.job. Only cron workloads may declare one.`,
48
+ entity: name,
49
+ lexicon: "cpln",
50
+ });
51
+ }
52
+ }
53
+ return diagnostics;
54
+ },
55
+ };
@@ -0,0 +1,53 @@
1
+ import { readArray, readNumber, readString } from "../../entity-props.js";
2
+ import { WORKLOAD, entitiesOfType } from "./helpers.js";
3
+ /**
4
+ * CPL022: `port` and `ports` are mutually exclusive, and port numbers must be
5
+ * unique across every container in a workload.
6
+ *
7
+ * The duplicate case is the one worth catching: two containers each binding
8
+ * 8080 is accepted at author time, rejected at apply, and reads as correct.
9
+ */
10
+ export const containerPortsCheck = {
11
+ id: "CPL022",
12
+ description: "Container ports must not mix `port` and `ports`, and must be unique across containers",
13
+ check(ctx) {
14
+ const diagnostics = [];
15
+ for (const [name, entity] of entitiesOfType(ctx.entities, WORKLOAD)) {
16
+ const seen = new Map();
17
+ for (const container of readArray(entity, "spec", "containers")) {
18
+ const containerName = readString(container, "name") ?? "?";
19
+ const single = readNumber(container, "port");
20
+ const list = readArray(container, "ports");
21
+ if (single !== undefined && list.length > 0) {
22
+ diagnostics.push({
23
+ checkId: "CPL022",
24
+ severity: "error",
25
+ message: `Workload "${name}" container "${containerName}" sets both \`port\` and \`ports\`. ` +
26
+ `They are mutually exclusive — keep \`ports\`.`,
27
+ entity: name,
28
+ lexicon: "cpln",
29
+ });
30
+ }
31
+ const numbers = [
32
+ ...(single !== undefined ? [single] : []),
33
+ ...list.map((port) => readNumber(port, "number")).filter((n) => n !== undefined),
34
+ ];
35
+ for (const number of numbers) {
36
+ const owner = seen.get(number);
37
+ if (owner !== undefined && owner !== containerName) {
38
+ diagnostics.push({
39
+ checkId: "CPL022",
40
+ severity: "error",
41
+ message: `Workload "${name}" exposes port ${number} from both "${owner}" and "${containerName}". ` +
42
+ `Port numbers must be unique across the containers of one workload.`,
43
+ entity: name,
44
+ lexicon: "cpln",
45
+ });
46
+ }
47
+ seen.set(number, containerName);
48
+ }
49
+ }
50
+ }
51
+ return diagnostics;
52
+ },
53
+ };
@@ -0,0 +1,73 @@
1
+ import { propsOf, readArray, readString } from "../../entity-props.js";
2
+ import { WORKLOAD, entitiesOfType, parseCpuMillicores, parseMemoryMib } from "./helpers.js";
3
+ /** The tag that raises the memory-to-CPU ceiling from 8 to 32. */
4
+ const RELAX_RATIO_TAG = "cpln/relaxMemoryToCpuRatio";
5
+ const CPU_MIN_MILLICORES = 25;
6
+ const MEMORY_MIN_MIB = 32;
7
+ const RATIO_MAX = 8;
8
+ const RATIO_MAX_RELAXED = 32;
9
+ /**
10
+ * CPL023: CPU and memory floors, and the memory-to-CPU ratio.
11
+ *
12
+ * The ratio is the non-obvious one. `memory(MiB) / cpu(millicores)` must be
13
+ * ≤ 8, so `2Gi` of memory needs at least 256 millicores of CPU — a workload
14
+ * asking for lots of memory and a little CPU is rejected, which surprises
15
+ * people coming from Kubernetes where the two are independent. The
16
+ * `cpln/relaxMemoryToCpuRatio` tag raises the ceiling to 32.
17
+ */
18
+ export const containerResourcesCheck = {
19
+ id: "CPL023",
20
+ description: "Container CPU and memory must clear the platform floors and the memory-to-CPU ratio",
21
+ check(ctx) {
22
+ const diagnostics = [];
23
+ for (const [name, entity] of entitiesOfType(ctx.entities, WORKLOAD)) {
24
+ const tags = propsOf(entity).tags;
25
+ const relaxed = !!tags && typeof tags === "object" && RELAX_RATIO_TAG in tags;
26
+ const ceiling = relaxed ? RATIO_MAX_RELAXED : RATIO_MAX;
27
+ for (const container of readArray(entity, "spec", "containers")) {
28
+ const containerName = readString(container, "name") ?? "?";
29
+ // Control Plane's defaults, so an omitted value is checked as what it
30
+ // will actually become rather than skipped.
31
+ const cpu = parseCpuMillicores(readString(container, "cpu") ?? "50m");
32
+ const memory = parseMemoryMib(readString(container, "memory") ?? "128Mi");
33
+ if (cpu !== undefined && cpu < CPU_MIN_MILLICORES) {
34
+ diagnostics.push({
35
+ checkId: "CPL023",
36
+ severity: "error",
37
+ message: `Workload "${name}" container "${containerName}" requests ${cpu}m CPU; the minimum is ` +
38
+ `${CPU_MIN_MILLICORES}m.`,
39
+ entity: name,
40
+ lexicon: "cpln",
41
+ });
42
+ }
43
+ if (memory !== undefined && memory < MEMORY_MIN_MIB) {
44
+ diagnostics.push({
45
+ checkId: "CPL023",
46
+ severity: "error",
47
+ message: `Workload "${name}" container "${containerName}" requests ${Math.round(memory)}Mi memory; ` +
48
+ `the minimum is ${MEMORY_MIN_MIB}Mi.`,
49
+ entity: name,
50
+ lexicon: "cpln",
51
+ });
52
+ }
53
+ if (cpu !== undefined && memory !== undefined && cpu > 0) {
54
+ const ratio = memory / cpu;
55
+ if (ratio > ceiling) {
56
+ const needed = Math.ceil(memory / ceiling);
57
+ diagnostics.push({
58
+ checkId: "CPL023",
59
+ severity: "error",
60
+ message: `Workload "${name}" container "${containerName}" has a memory-to-CPU ratio of ` +
61
+ `${ratio.toFixed(1)} (${Math.round(memory)}Mi / ${cpu}m), above the limit of ${ceiling}. ` +
62
+ `Raise CPU to at least ${needed}m` +
63
+ (relaxed ? "." : `, or add the \`${RELAX_RATIO_TAG}\` tag to raise the ceiling to ${RATIO_MAX_RELAXED}.`),
64
+ entity: name,
65
+ lexicon: "cpln",
66
+ });
67
+ }
68
+ }
69
+ }
70
+ }
71
+ return diagnostics;
72
+ },
73
+ };
@@ -0,0 +1,43 @@
1
+ import { readArray, readPath, readString } from "../../entity-props.js";
2
+ import { WORKLOAD, entitiesOfType } from "./helpers.js";
3
+ /** The four mutually exclusive probe handlers. */
4
+ const PROBE_HANDLERS = ["exec", "grpc", "tcpSocket", "httpGet"];
5
+ /**
6
+ * CPL024: a probe must set exactly one handler.
7
+ *
8
+ * Zero is the interesting case: a `readinessProbe` with only
9
+ * `initialDelaySeconds` and `periodSeconds` set looks configured and probes
10
+ * nothing.
11
+ */
12
+ export const probeHandlerCheck = {
13
+ id: "CPL024",
14
+ description: "Each health-check probe must set exactly one of exec, grpc, tcpSocket, httpGet",
15
+ check(ctx) {
16
+ const diagnostics = [];
17
+ for (const [name, entity] of entitiesOfType(ctx.entities, WORKLOAD)) {
18
+ for (const container of readArray(entity, "spec", "containers")) {
19
+ const containerName = readString(container, "name") ?? "?";
20
+ for (const probeName of ["readinessProbe", "livenessProbe"]) {
21
+ const probe = readPath(container, probeName);
22
+ if (!probe || typeof probe !== "object")
23
+ continue;
24
+ const set = PROBE_HANDLERS.filter((handler) => readPath(probe, handler) !== undefined);
25
+ if (set.length === 1)
26
+ continue;
27
+ diagnostics.push({
28
+ checkId: "CPL024",
29
+ severity: "error",
30
+ message: set.length === 0
31
+ ? `Workload "${name}" container "${containerName}" declares a ${probeName} with no handler. ` +
32
+ `Set exactly one of ${PROBE_HANDLERS.join(", ")} — as written the probe checks nothing.`
33
+ : `Workload "${name}" container "${containerName}" ${probeName} sets ${set.length} handlers ` +
34
+ `(${set.join(", ")}). They are mutually exclusive; keep one.`,
35
+ entity: name,
36
+ lexicon: "cpln",
37
+ });
38
+ }
39
+ }
40
+ }
41
+ return diagnostics;
42
+ },
43
+ };