@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,84 @@
1
+ import { readArray, readNumber, readString } from "../../entity-props.js";
2
+ import { WORKLOAD, autoscalingOf, entitiesOfType, workloadType } from "./helpers.js";
3
+ /** Which strategies each workload type supports at all. */
4
+ const SUPPORTED_METRICS = {
5
+ serverless: ["concurrency", "rps", "cpu", "memory", "disabled"],
6
+ standard: ["rps", "cpu", "memory", "latency", "keda", "disabled"],
7
+ stateful: ["rps", "cpu", "memory", "latency", "keda", "disabled"],
8
+ };
9
+ /**
10
+ * CPL025: mutually exclusive autoscaling fields, and the `target` ceiling.
11
+ *
12
+ * `metric` and `multi` are alternatives, not layers; `target` belongs to the
13
+ * single-metric form and is rejected alongside `multi`; and with cpu or memory
14
+ * as the metric, `target` is a utilization percentage, so above 100 it is not
15
+ * an aggressive setting but an invalid one.
16
+ */
17
+ export const autoscalingShapeCheck = {
18
+ id: "CPL025",
19
+ description: "Autoscaling `metric`, `multi` and `target` must form a valid combination",
20
+ check(ctx) {
21
+ const diagnostics = [];
22
+ for (const [name, entity] of entitiesOfType(ctx.entities, WORKLOAD)) {
23
+ const scaling = autoscalingOf(entity);
24
+ if (!scaling || typeof scaling !== "object")
25
+ continue;
26
+ const metric = readString(scaling, "metric");
27
+ const multi = readArray(scaling, "multi");
28
+ const target = readNumber(scaling, "target");
29
+ const type = workloadType(entity);
30
+ if (metric && multi.length > 0) {
31
+ diagnostics.push({
32
+ checkId: "CPL025",
33
+ severity: "error",
34
+ message: `Workload "${name}" sets both autoscaling.metric ("${metric}") and autoscaling.multi. ` +
35
+ `They are mutually exclusive — multi is the multi-metric form and replaces metric.`,
36
+ entity: name,
37
+ lexicon: "cpln",
38
+ });
39
+ }
40
+ if (target !== undefined && multi.length > 0) {
41
+ diagnostics.push({
42
+ checkId: "CPL025",
43
+ severity: "error",
44
+ message: `Workload "${name}" sets autoscaling.target alongside autoscaling.multi. ` +
45
+ `Each entry in multi carries its own target.`,
46
+ entity: name,
47
+ lexicon: "cpln",
48
+ });
49
+ }
50
+ if (target !== undefined && (metric === "cpu" || metric === "memory") && target > 100) {
51
+ diagnostics.push({
52
+ checkId: "CPL025",
53
+ severity: "error",
54
+ message: `Workload "${name}" sets autoscaling.target ${target} with metric "${metric}", which is a ` +
55
+ `utilization percentage capped at 100.`,
56
+ entity: name,
57
+ lexicon: "cpln",
58
+ });
59
+ }
60
+ const supported = SUPPORTED_METRICS[type];
61
+ if (metric && supported && !supported.includes(metric)) {
62
+ diagnostics.push({
63
+ checkId: "CPL025",
64
+ severity: "error",
65
+ message: `Workload "${name}" is type "${type}" and uses autoscaling metric "${metric}", which that type ` +
66
+ `does not support. Available: ${supported.join(", ")}.`,
67
+ entity: name,
68
+ lexicon: "cpln",
69
+ });
70
+ }
71
+ if (multi.length > 0 && type === "serverless") {
72
+ diagnostics.push({
73
+ checkId: "CPL025",
74
+ severity: "error",
75
+ message: `Workload "${name}" is serverless and uses multi-metric autoscaling, which is available only ` +
76
+ `on standard and stateful workloads.`,
77
+ entity: name,
78
+ lexicon: "cpln",
79
+ });
80
+ }
81
+ }
82
+ return diagnostics;
83
+ },
84
+ };
@@ -0,0 +1,59 @@
1
+ import { readNumber, readPath, readString } from "../../entity-props.js";
2
+ import { WORKLOAD, autoscalingOf, entitiesOfType, workloadType } from "./helpers.js";
3
+ /** Strategies that let a workload scale to zero, by workload type. */
4
+ const SCALE_TO_ZERO = {
5
+ serverless: ["rps", "concurrency"],
6
+ standard: ["keda"],
7
+ stateful: ["keda"],
8
+ };
9
+ /**
10
+ * CPL026: scale-to-zero that will not happen.
11
+ *
12
+ * `minScale: 0` is accepted on any workload type. It only *takes effect* for
13
+ * serverless with `rps` or `concurrency`, or for standard/stateful under KEDA.
14
+ * Everywhere else the workload holds at one replica and the cost saving that
15
+ * motivated the setting never arrives — with nothing reported.
16
+ */
17
+ export const scaleToZeroCheck = {
18
+ id: "CPL026",
19
+ description: "minScale 0 must pair with a strategy that can actually scale to zero",
20
+ check(ctx) {
21
+ const diagnostics = [];
22
+ for (const [name, entity] of entitiesOfType(ctx.entities, WORKLOAD)) {
23
+ const scaling = autoscalingOf(entity);
24
+ if (!scaling || typeof scaling !== "object")
25
+ continue;
26
+ if (readNumber(scaling, "minScale") !== 0)
27
+ continue;
28
+ const type = workloadType(entity);
29
+ const metric = readString(scaling, "metric") ?? (type === "serverless" ? "concurrency" : undefined);
30
+ if (type === "cron") {
31
+ diagnostics.push({
32
+ checkId: "CPL026",
33
+ severity: "error",
34
+ message: `Cron workload "${name}" sets minScale 0. Cron workloads cannot scale to zero.`,
35
+ entity: name,
36
+ lexicon: "cpln",
37
+ });
38
+ continue;
39
+ }
40
+ const allowed = SCALE_TO_ZERO[type] ?? [];
41
+ const usingKeda = metric === "keda" || readPath(scaling, "keda") !== undefined;
42
+ if (allowed.includes(metric ?? "") || (allowed.includes("keda") && usingKeda))
43
+ continue;
44
+ diagnostics.push({
45
+ checkId: "CPL026",
46
+ severity: "warning",
47
+ message: `Workload "${name}" (type "${type}") sets minScale 0 with metric "${metric ?? "unset"}", which ` +
48
+ `cannot scale to zero. ` +
49
+ (type === "serverless"
50
+ ? `Serverless scales to zero only with "rps" or "concurrency".`
51
+ : `Standard and stateful workloads scale to zero only under KEDA, which must be enabled on the GVC.`) +
52
+ ` As written the workload holds at one replica with no error.`,
53
+ entity: name,
54
+ lexicon: "cpln",
55
+ });
56
+ }
57
+ return diagnostics;
58
+ },
59
+ };
@@ -0,0 +1,67 @@
1
+ import { readArray, readBoolean, readPath, readString } from "../../entity-props.js";
2
+ import { WORKLOAD, autoscalingOf, entitiesOfType, workloadType } from "./helpers.js";
3
+ /**
4
+ * CPL027: Capacity AI combined with something it excludes.
5
+ *
6
+ * Capacity AI resizes CPU and memory from observed usage, and it is **on by
7
+ * default** for serverless, standard and cron — so these conflicts are usually
8
+ * reached by adding CPU autoscaling or a GPU to a workload that never opted
9
+ * into Capacity AI in the first place. Each conflict has the same root: the
10
+ * other feature needs a stable resource baseline that Capacity AI is actively
11
+ * moving.
12
+ */
13
+ export const capacityAiConflictCheck = {
14
+ id: "CPL027",
15
+ description: "Capacity AI conflicts with CPU-utilization autoscaling, multi-metric autoscaling and GPUs",
16
+ check(ctx) {
17
+ const diagnostics = [];
18
+ for (const [name, entity] of entitiesOfType(ctx.entities, WORKLOAD)) {
19
+ const type = workloadType(entity);
20
+ // Default-on for every type except stateful, so `undefined` means enabled
21
+ // for those — which is exactly how these conflicts get reached unnoticed.
22
+ const declared = readBoolean(entity, "spec", "defaultOptions", "capacityAI");
23
+ const enabled = declared ?? type !== "stateful";
24
+ if (!enabled)
25
+ continue;
26
+ const implicit = declared === undefined;
27
+ const how = implicit ? "Capacity AI is on by default for this workload type and" : "Capacity AI";
28
+ const scaling = autoscalingOf(entity);
29
+ const metric = scaling && typeof scaling === "object" ? readString(scaling, "metric") : undefined;
30
+ const multi = scaling && typeof scaling === "object" ? readArray(scaling, "multi") : [];
31
+ if (metric === "cpu") {
32
+ diagnostics.push({
33
+ checkId: "CPL027",
34
+ severity: "error",
35
+ message: `Workload "${name}": ${how} conflicts with CPU-utilization autoscaling — dynamic CPU allocation ` +
36
+ `moves the baseline the metric scales against. Set defaultOptions.capacityAI false, or scale on ` +
37
+ `rps or memory.`,
38
+ entity: name,
39
+ lexicon: "cpln",
40
+ });
41
+ }
42
+ if (multi.length > 0) {
43
+ diagnostics.push({
44
+ checkId: "CPL027",
45
+ severity: "error",
46
+ message: `Workload "${name}": ${how} conflicts with multi-metric autoscaling, which requires stable ` +
47
+ `resource baselines. Set defaultOptions.capacityAI false.`,
48
+ entity: name,
49
+ lexicon: "cpln",
50
+ });
51
+ }
52
+ for (const container of readArray(entity, "spec", "containers")) {
53
+ if (readPath(container, "gpu") === undefined)
54
+ continue;
55
+ diagnostics.push({
56
+ checkId: "CPL027",
57
+ severity: "error",
58
+ message: `Workload "${name}" container "${readString(container, "name") ?? "?"}" requests a GPU, and ` +
59
+ `${how} conflicts with it — GPU allocation is fixed. Set defaultOptions.capacityAI false.`,
60
+ entity: name,
61
+ lexicon: "cpln",
62
+ });
63
+ }
64
+ }
65
+ return diagnostics;
66
+ },
67
+ };
@@ -0,0 +1,155 @@
1
+ import { readArray, readNumber, readString } from "../../entity-props.js";
2
+ import { VOLUMESET, WORKLOAD, entitiesOfType } from "./helpers.js";
3
+ /** Minimum initial capacity in GB, by performance class. */
4
+ const MIN_CAPACITY_GB = {
5
+ "general-purpose-ssd": 10,
6
+ "high-throughput-ssd": 200,
7
+ shared: 10,
8
+ };
9
+ const MAX_CAPACITY_GB = 65536;
10
+ /** Filesystems that bind to exactly one stateful workload. */
11
+ const EXCLUSIVE_FILESYSTEMS = new Set(["ext4", "xfs"]);
12
+ /** Control Plane caps a workload at 15 volumes. */
13
+ const MAX_VOLUMES = 15;
14
+ const VOLUMESET_URI = /^cpln:\/\/volumeset\/(.+)$/;
15
+ export const volumeSetCheck = {
16
+ id: "CPL028",
17
+ description: "Volume set capacity, filesystem binding, and workload mount rules",
18
+ check(ctx) {
19
+ const diagnostics = [];
20
+ // Volume sets by their declared name, so a mount can be checked against the
21
+ // set it names rather than only against itself.
22
+ const volumeSets = new Map();
23
+ for (const [name, entity] of entitiesOfType(ctx.entities, VOLUMESET)) {
24
+ const declaredName = readString(entity, "name") ?? name;
25
+ const fileSystemType = readString(entity, "spec", "fileSystemType") ?? "ext4";
26
+ volumeSets.set(declaredName, { entity, gvc: readString(entity, "gvc"), fileSystemType });
27
+ const performanceClass = readString(entity, "spec", "performanceClass") ?? (fileSystemType === "shared" ? "shared" : "general-purpose-ssd");
28
+ const capacity = readNumber(entity, "spec", "initialCapacity");
29
+ const floor = MIN_CAPACITY_GB[performanceClass];
30
+ if (capacity !== undefined && floor !== undefined && capacity < floor) {
31
+ diagnostics.push({
32
+ checkId: "CPL028",
33
+ severity: "error",
34
+ message: `Volume set "${name}" requests ${capacity} GB with performanceClass "${performanceClass}", ` +
35
+ `below its ${floor} GB minimum. performanceClass is immutable after creation, so correcting ` +
36
+ `this later means recreating the volume set and losing its data.`,
37
+ entity: name,
38
+ lexicon: "cpln",
39
+ });
40
+ }
41
+ if (capacity !== undefined && capacity > MAX_CAPACITY_GB) {
42
+ diagnostics.push({
43
+ checkId: "CPL028",
44
+ severity: "error",
45
+ message: `Volume set "${name}" requests ${capacity} GB, above the ${MAX_CAPACITY_GB} GB maximum.`,
46
+ entity: name,
47
+ lexicon: "cpln",
48
+ });
49
+ }
50
+ if (fileSystemType === "shared" && performanceClass !== "shared") {
51
+ diagnostics.push({
52
+ checkId: "CPL028",
53
+ severity: "warning",
54
+ message: `Volume set "${name}" has fileSystemType "shared" but performanceClass "${performanceClass}". ` +
55
+ `Control Plane forces "shared" for a shared filesystem, so the declared value will not match ` +
56
+ `what comes back and will read as drift on every plan.`,
57
+ entity: name,
58
+ lexicon: "cpln",
59
+ });
60
+ }
61
+ // Custom encryption is AWS KMS on a block device; a shared filesystem has
62
+ // no device to encrypt.
63
+ if (fileSystemType === "shared" && readString(entity, "spec", "customEncryption", "secretLink")) {
64
+ diagnostics.push({
65
+ checkId: "CPL028",
66
+ severity: "error",
67
+ message: `Volume set "${name}" sets customEncryption with fileSystemType "shared"; it is supported on ext4 and xfs only.`,
68
+ entity: name,
69
+ lexicon: "cpln",
70
+ });
71
+ }
72
+ }
73
+ for (const [name, entity] of entitiesOfType(ctx.entities, WORKLOAD)) {
74
+ const type = readString(entity, "spec", "type") ?? "serverless";
75
+ const workloadGvc = readString(entity, "gvc");
76
+ const paths = [];
77
+ let mountCount = 0;
78
+ for (const container of readArray(entity, "spec", "containers")) {
79
+ const containerName = readString(container, "name") ?? "?";
80
+ for (const volume of readArray(container, "volumes")) {
81
+ mountCount++;
82
+ const uri = readString(volume, "uri");
83
+ const path = readString(volume, "path");
84
+ if (path)
85
+ paths.push(path);
86
+ const match = uri ? VOLUMESET_URI.exec(uri) : null;
87
+ if (!match)
88
+ continue;
89
+ const target = volumeSets.get(match[1]);
90
+ if (!target)
91
+ continue; // declared elsewhere; CPL029 covers dangling references
92
+ if (EXCLUSIVE_FILESYSTEMS.has(target.fileSystemType) && type !== "stateful") {
93
+ diagnostics.push({
94
+ checkId: "CPL028",
95
+ severity: "error",
96
+ message: `Workload "${name}" is type "${type}" and container "${containerName}" mounts volume set ` +
97
+ `"${match[1]}", whose fileSystemType "${target.fileSystemType}" binds to exactly one ` +
98
+ `stateful workload. Use a "shared" volume set, or make the workload stateful.`,
99
+ entity: name,
100
+ lexicon: "cpln",
101
+ });
102
+ }
103
+ if (target.gvc && workloadGvc && target.gvc !== workloadGvc) {
104
+ diagnostics.push({
105
+ checkId: "CPL028",
106
+ severity: "error",
107
+ message: `Workload "${name}" (GVC "${workloadGvc}") mounts volume set "${match[1]}" from GVC ` +
108
+ `"${target.gvc}". Volume sets are only mountable from within their own GVC.`,
109
+ entity: name,
110
+ lexicon: "cpln",
111
+ });
112
+ }
113
+ }
114
+ }
115
+ if (mountCount > MAX_VOLUMES) {
116
+ diagnostics.push({
117
+ checkId: "CPL028",
118
+ severity: "error",
119
+ message: `Workload "${name}" mounts ${mountCount} volumes; the maximum is ${MAX_VOLUMES}.`,
120
+ entity: name,
121
+ lexicon: "cpln",
122
+ });
123
+ }
124
+ for (const [i, path] of paths.entries()) {
125
+ for (const other of paths.slice(i + 1)) {
126
+ if (path === other) {
127
+ diagnostics.push({
128
+ checkId: "CPL028",
129
+ severity: "error",
130
+ message: `Workload "${name}" mounts two volumes at the same path "${path}".`,
131
+ entity: name,
132
+ lexicon: "cpln",
133
+ });
134
+ }
135
+ else if (isParentPath(path, other) || isParentPath(other, path)) {
136
+ diagnostics.push({
137
+ checkId: "CPL028",
138
+ severity: "error",
139
+ message: `Workload "${name}" mounts volumes at "${path}" and "${other}"; no mount path may be a ` +
140
+ `parent of another.`,
141
+ entity: name,
142
+ lexicon: "cpln",
143
+ });
144
+ }
145
+ }
146
+ }
147
+ }
148
+ return diagnostics;
149
+ },
150
+ };
151
+ /** Whether `parent` is a proper ancestor directory of `child`. */
152
+ function isParentPath(parent, child) {
153
+ const normalized = parent.endsWith("/") ? parent : `${parent}/`;
154
+ return child.startsWith(normalized);
155
+ }
@@ -0,0 +1,140 @@
1
+ import { propsOf, readString } from "../../entity-props.js";
2
+ import { KINDS, kindByTypeName } from "../../kinds.js";
3
+ import { GVC, WORKLOAD, entitiesOfType, parseLink } from "./helpers.js";
4
+ /**
5
+ * Every string anywhere in a value, so links are found wherever they were
6
+ * written. Depth-bounded — a spec is deep but finite, and a cycle would not be.
7
+ */
8
+ function* strings(value, depth = 0) {
9
+ if (depth > 12)
10
+ return;
11
+ if (typeof value === "string") {
12
+ yield value;
13
+ return;
14
+ }
15
+ if (Array.isArray(value)) {
16
+ for (const item of value)
17
+ yield* strings(item, depth + 1);
18
+ return;
19
+ }
20
+ if (value && typeof value === "object") {
21
+ for (const nested of Object.values(propsOf(value)))
22
+ yield* strings(nested, depth + 1);
23
+ }
24
+ }
25
+ /**
26
+ * A workload's identity must live in the workload's own GVC.
27
+ *
28
+ * Identities cannot be shared across GVCs — the documented remedy is to
29
+ * recreate the identity with the same spec in each GVC that needs it. A
30
+ * cross-GVC `identityLink` is therefore never right, however reasonable it
31
+ * reads.
32
+ */
33
+ function identityGvcCheck(entities) {
34
+ const diagnostics = [];
35
+ for (const [name, entity] of entitiesOfType(entities, WORKLOAD)) {
36
+ const workloadGvc = readString(entity, "gvc");
37
+ const identityLink = readString(entity, "spec", "identityLink");
38
+ if (!workloadGvc || !identityLink)
39
+ continue;
40
+ const link = parseLink(identityLink);
41
+ if (!link || link.kind !== "identity" || !link.gvc)
42
+ continue;
43
+ if (link.gvc === workloadGvc)
44
+ continue;
45
+ diagnostics.push({
46
+ checkId: "CPL029",
47
+ severity: "error",
48
+ message: `Workload "${name}" is in GVC "${workloadGvc}" but its identityLink names GVC "${link.gvc}". ` +
49
+ `Identities cannot be shared across GVCs — declare an identity with the same spec in ` +
50
+ `"${workloadGvc}".`,
51
+ entity: name,
52
+ lexicon: "cpln",
53
+ });
54
+ }
55
+ return diagnostics;
56
+ }
57
+ export const linkTargetsCheck = {
58
+ id: "CPL029",
59
+ description: "Control Plane links must resolve to a declared resource of the right kind and GVC",
60
+ check(ctx) {
61
+ const diagnostics = [];
62
+ // Declared resources by kind → name → its GVC (undefined for org-scoped).
63
+ const declared = new Map();
64
+ const declaredKinds = new Set();
65
+ for (const [entityName, entity] of ctx.entities) {
66
+ const kind = kindByTypeName(entity.entityType);
67
+ if (!kind)
68
+ continue;
69
+ declaredKinds.add(kind.kind);
70
+ const byName = declared.get(kind.kind) ?? new Map();
71
+ byName.set(readString(entity, "name") ?? entityName, readString(entity, "gvc"));
72
+ declared.set(kind.kind, byName);
73
+ }
74
+ const knownKinds = new Set(KINDS.map((k) => k.kind));
75
+ for (const [entityName, entity] of ctx.entities) {
76
+ const kind = kindByTypeName(entity.entityType);
77
+ if (!kind)
78
+ continue;
79
+ const ownGvc = readString(entity, "gvc");
80
+ for (const value of strings(propsOf(entity))) {
81
+ const link = parseLink(value);
82
+ if (!link || !knownKinds.has(link.kind))
83
+ continue;
84
+ // Only assert against kinds this stack actually declares. Referencing a
85
+ // secret managed by another team is ordinary, not a defect.
86
+ if (!declaredKinds.has(link.kind))
87
+ continue;
88
+ const byName = declared.get(link.kind);
89
+ if (!byName.has(link.name)) {
90
+ diagnostics.push({
91
+ checkId: "CPL029",
92
+ severity: "warning",
93
+ message: `${kind.kind} "${entityName}" links to "${value}", but no ${link.kind} named "${link.name}" ` +
94
+ `is declared in this stack. Control Plane accepts a dangling link without error.`,
95
+ entity: entityName,
96
+ lexicon: "cpln",
97
+ });
98
+ continue;
99
+ }
100
+ const targetGvc = byName.get(link.name);
101
+ if (link.gvc && targetGvc && link.gvc !== targetGvc) {
102
+ diagnostics.push({
103
+ checkId: "CPL029",
104
+ severity: "error",
105
+ message: `${kind.kind} "${entityName}" links to "${value}", but ${link.kind} "${link.name}" is ` +
106
+ `declared in GVC "${targetGvc}".`,
107
+ entity: entityName,
108
+ lexicon: "cpln",
109
+ });
110
+ }
111
+ }
112
+ }
113
+ // A GVC-scoped resource whose GVC is declared here must name one that exists.
114
+ if (declaredKinds.has("gvc")) {
115
+ const gvcNames = new Set();
116
+ for (const [entityName, entity] of entitiesOfType(ctx.entities, GVC)) {
117
+ gvcNames.add(readString(entity, "name") ?? entityName);
118
+ }
119
+ for (const [entityName, entity] of ctx.entities) {
120
+ const kind = kindByTypeName(entity.entityType);
121
+ if (!kind?.gvcScoped)
122
+ continue;
123
+ const gvc = readString(entity, "gvc");
124
+ if (!gvc || gvcNames.has(gvc))
125
+ continue;
126
+ diagnostics.push({
127
+ checkId: "CPL029",
128
+ severity: "warning",
129
+ message: `${kind.kind} "${entityName}" is placed in GVC "${gvc}", which this stack does not declare. ` +
130
+ `If the GVC is managed elsewhere this is fine; if it is a typo, the apply fails with a ` +
131
+ `not-found on the parent.`,
132
+ entity: entityName,
133
+ lexicon: "cpln",
134
+ });
135
+ }
136
+ }
137
+ diagnostics.push(...identityGvcCheck(ctx.entities));
138
+ return diagnostics;
139
+ },
140
+ };
@@ -0,0 +1,114 @@
1
+ import { readArray, readPath, readString } from "../../entity-props.js";
2
+ import { DOMAIN, WORKLOAD, entitiesOfType, parseLink } from "./helpers.js";
3
+ /** An apex domain has exactly two labels: `example.com`, not `api.example.com`. */
4
+ function isApex(name) {
5
+ return name.split(".").filter(Boolean).length === 2;
6
+ }
7
+ export const domainRoutingCheck = {
8
+ id: "CPL030",
9
+ description: "Domain DNS mode, certificate challenge, and routing target must be a valid combination",
10
+ check(ctx) {
11
+ const diagnostics = [];
12
+ // Workload types, so a `workloadLink` route can be checked for the stateful
13
+ // requirement that replica-direct routing carries.
14
+ const workloadTypes = new Map();
15
+ for (const [entityName, entity] of entitiesOfType(ctx.entities, WORKLOAD)) {
16
+ workloadTypes.set(readString(entity, "name") ?? entityName, readString(entity, "spec", "type") ?? "serverless");
17
+ }
18
+ for (const [entityName, entity] of entitiesOfType(ctx.entities, DOMAIN)) {
19
+ const name = readString(entity, "name") ?? entityName;
20
+ const dnsMode = readString(entity, "spec", "dnsMode") ?? "cname";
21
+ const challenge = readString(entity, "spec", "certChallengeType");
22
+ const gvcLink = readString(entity, "spec", "gvcLink");
23
+ const workloadLink = readString(entity, "spec", "workloadLink");
24
+ const ports = readArray(entity, "spec", "ports");
25
+ const routed = ports.some((port) => readArray(port, "routes").length > 0);
26
+ if (dnsMode === "ns" && isApex(name)) {
27
+ diagnostics.push({
28
+ checkId: "CPL030",
29
+ severity: "error",
30
+ message: `Domain "${name}" is an apex domain with dnsMode "ns". NS mode does not support apex domains — ` +
31
+ `use "cname".`,
32
+ entity: entityName,
33
+ lexicon: "cpln",
34
+ });
35
+ }
36
+ if (dnsMode === "ns" && challenge === "http01") {
37
+ diagnostics.push({
38
+ checkId: "CPL030",
39
+ severity: "error",
40
+ message: `Domain "${name}" uses dnsMode "ns" with certChallengeType "http01", which is rejected. ` +
41
+ `NS mode requires "dns01".`,
42
+ entity: entityName,
43
+ lexicon: "cpln",
44
+ });
45
+ }
46
+ const targets = [gvcLink && "gvcLink", workloadLink && "workloadLink", routed && "ports[].routes"].filter(Boolean);
47
+ if (targets.length > 1) {
48
+ diagnostics.push({
49
+ checkId: "CPL030",
50
+ severity: "error",
51
+ message: `Domain "${name}" sets ${targets.join(" and ")}. These are mutually exclusive ways of saying ` +
52
+ `where traffic goes — keep exactly one.`,
53
+ entity: entityName,
54
+ lexicon: "cpln",
55
+ });
56
+ }
57
+ if (targets.length === 0) {
58
+ diagnostics.push({
59
+ checkId: "CPL030",
60
+ severity: "warning",
61
+ message: `Domain "${name}" names no routing target. Set one of gvcLink, workloadLink, or ports[].routes, ` +
62
+ `or the domain resolves to nothing.`,
63
+ entity: entityName,
64
+ lexicon: "cpln",
65
+ });
66
+ }
67
+ // `workloadLink` is replica-direct routing, which only stateful supports.
68
+ if (workloadLink) {
69
+ const link = parseLink(workloadLink);
70
+ const type = link && workloadTypes.get(link.name);
71
+ if (type && type !== "stateful") {
72
+ diagnostics.push({
73
+ checkId: "CPL030",
74
+ severity: "error",
75
+ message: `Domain "${name}" routes via workloadLink to "${link.name}", which is type "${type}". ` +
76
+ `workloadLink is replica-direct routing and is supported on stateful workloads only.`,
77
+ entity: entityName,
78
+ lexicon: "cpln",
79
+ });
80
+ }
81
+ }
82
+ // Every route in a domain must target workloads in one GVC.
83
+ const routeGvcs = new Set();
84
+ for (const port of ports) {
85
+ for (const route of readArray(port, "routes")) {
86
+ const target = readString(route, "workloadLink");
87
+ const link = target ? parseLink(target) : undefined;
88
+ if (link?.gvc)
89
+ routeGvcs.add(link.gvc);
90
+ }
91
+ }
92
+ if (routeGvcs.size > 1) {
93
+ diagnostics.push({
94
+ checkId: "CPL030",
95
+ severity: "error",
96
+ message: `Domain "${name}" routes to workloads in ${routeGvcs.size} GVCs (${[...routeGvcs].sort().join(", ")}). ` +
97
+ `All routes in a domain must target workloads in the same GVC.`,
98
+ entity: entityName,
99
+ lexicon: "cpln",
100
+ });
101
+ }
102
+ if (readPath(entity, "spec", "acceptAllHosts") === true && readPath(entity, "spec", "acceptAllSubdomains") === true) {
103
+ diagnostics.push({
104
+ checkId: "CPL030",
105
+ severity: "error",
106
+ message: `Domain "${name}" sets both acceptAllHosts and acceptAllSubdomains; they are mutually exclusive.`,
107
+ entity: entityName,
108
+ lexicon: "cpln",
109
+ });
110
+ }
111
+ }
112
+ return diagnostics;
113
+ },
114
+ };
@@ -0,0 +1,41 @@
1
+ import { readArray, readString } from "../../entity-props.js";
2
+ import { WORKLOAD, entitiesOfType, imageTag } from "./helpers.js";
3
+ /** A digest pin — `@sha256:…` — is reproducible regardless of tag. */
4
+ const DIGEST = /@sha\d+:[0-9a-f]+$/i;
5
+ /**
6
+ * CPL040: an image reference that is not pinned.
7
+ *
8
+ * `:latest`, or no tag at all (which *means* `:latest`), makes what runs a
9
+ * function of when the workload last happened to pull. On a serverless
10
+ * workload that scales to zero and cold-starts, that can be several times a
11
+ * day, so two replicas of "the same" deploy can be different builds.
12
+ */
13
+ export const pinnedImageCheck = {
14
+ id: "CPL040",
15
+ description: "Container images should be pinned to a specific tag or digest",
16
+ check(ctx) {
17
+ const diagnostics = [];
18
+ for (const [name, entity] of entitiesOfType(ctx.entities, WORKLOAD)) {
19
+ for (const container of readArray(entity, "spec", "containers")) {
20
+ const containerName = readString(container, "name") ?? "?";
21
+ const image = readString(container, "image");
22
+ if (!image || DIGEST.test(image))
23
+ continue;
24
+ const tag = imageTag(image);
25
+ if (tag && tag !== "latest")
26
+ continue;
27
+ diagnostics.push({
28
+ checkId: "CPL040",
29
+ severity: "warning",
30
+ message: `Workload "${name}" container "${containerName}" uses image "${image}", which is ` +
31
+ (tag === "latest" ? `tagged \`latest\`` : `untagged (implicitly \`latest\`)`) +
32
+ `. Pin a version or a digest — a scale-from-zero cold start re-pulls, so what runs can change ` +
33
+ `without a deploy.`,
34
+ entity: name,
35
+ lexicon: "cpln",
36
+ });
37
+ }
38
+ }
39
+ return diagnostics;
40
+ },
41
+ };