@intentius/chant-lexicon-k8s 0.98.0 → 0.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/import/embedded.d.ts +27 -0
- package/dist/import/embedded.d.ts.map +1 -0
- package/dist/import/generator.d.ts +2 -0
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/parser.d.ts +8 -2
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/import/testdata/embedded/fixtures.d.ts +21 -0
- package/dist/import/testdata/embedded/fixtures.d.ts.map +1 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +3 -3
- package/src/import/embedded-roundtrip.test.ts +231 -0
- package/src/import/embedded-types.e2e.test.ts +43 -0
- package/src/import/embedded.test.ts +125 -0
- package/src/import/embedded.ts +104 -0
- package/src/import/generator.ts +36 -7
- package/src/import/parser.ts +14 -4
- package/src/import/testdata/embedded/README.md +11 -0
- package/src/import/testdata/embedded/agent-observability.yaml +2905 -0
- package/src/import/testdata/embedded/fixtures.ts +53 -0
- package/src/import/testdata/embedded/grafana-dashboard-configmap.yaml +364 -0
- package/src/import/testdata/embedded/node-exporter-prometheusrule.yaml +425 -0
- package/src/import/testdata/embedded/otel-collector-daemonset.yaml +225 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a Kubernetes manifest embeds another lexicon's content, for
|
|
3
|
+
* `chant import` (#2962).
|
|
4
|
+
*
|
|
5
|
+
* The parser offers two kinds of place to core's embedded-content resolver:
|
|
6
|
+
*
|
|
7
|
+
* - every `data` value of a ConfigMap, as text (a collector's `config.yaml`,
|
|
8
|
+
* a Prometheus rule file, a Grafana dashboard's JSON);
|
|
9
|
+
* - a `monitoring.coreos.com` PrometheusRule's `spec.groups`, as the rule file
|
|
10
|
+
* `{ groups }` those groups would make.
|
|
11
|
+
*
|
|
12
|
+
* Which lexicon imports the content is decided by core at run time, from the
|
|
13
|
+
* lexicons that register an `embeddedImporters()`; this module names none of
|
|
14
|
+
* them as a dependency. The `expectedOwner` hints below are the conventions
|
|
15
|
+
* k8s manifests follow, used only to warn when the owner is not installed.
|
|
16
|
+
*/
|
|
17
|
+
import type { EmbeddedContentResolver } from "@intentius/chant/import/embedded";
|
|
18
|
+
/** The `grafana_dashboard` label the Grafana dashboard sidecar looks for (kube-prometheus-stack, the Grafana Helm chart). */
|
|
19
|
+
export declare const GRAFANA_DASHBOARD_LABEL = "grafana_dashboard";
|
|
20
|
+
/**
|
|
21
|
+
* Offer the embedded content of one resource to `embedded`, replacing each
|
|
22
|
+
* value an owner imports with the reference it returns. `properties` is the
|
|
23
|
+
* resource's IR properties, changed in place (with fresh copies of the
|
|
24
|
+
* objects on the way down).
|
|
25
|
+
*/
|
|
26
|
+
export declare function delegateEmbedded(type: string, kind: string, properties: Record<string, unknown>, embedded: EmbeddedContentResolver): void;
|
|
27
|
+
//# sourceMappingURL=embedded.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"embedded.d.ts","sourceRoot":"","sources":["../../src/import/embedded.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAmB,uBAAuB,EAAE,MAAM,kCAAkC,CAAC;AAOjG,6HAA6H;AAC7H,eAAO,MAAM,uBAAuB,sBAAsB,CAAC;AAwB3D;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnC,QAAQ,EAAE,uBAAuB,GAChC,IAAI,CA2CN"}
|
|
@@ -10,6 +10,8 @@ import type { TemplateIR } from "@intentius/chant/import/parser";
|
|
|
10
10
|
* Generate TypeScript source code from a Kubernetes IR.
|
|
11
11
|
*/
|
|
12
12
|
export declare class K8sGenerator implements TypeScriptGenerator {
|
|
13
|
+
/** The imports for embedded content the current `generate()` call references (#2962). */
|
|
14
|
+
private scope;
|
|
13
15
|
generate(ir: TemplateIR): GeneratedFile[];
|
|
14
16
|
/**
|
|
15
17
|
* Resolve a K8s type name to a constructor class name.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generator.d.ts","sourceRoot":"","sources":["../../src/import/generator.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAC5F,OAAO,KAAK,EAAE,UAAU,EAAc,MAAM,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"generator.d.ts","sourceRoot":"","sources":["../../src/import/generator.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAC5F,OAAO,KAAK,EAAE,UAAU,EAAc,MAAM,gCAAgC,CAAC;AAqG7E;;GAEG;AACH,qBAAa,YAAa,YAAW,mBAAmB;IACtD,yFAAyF;IACzF,OAAO,CAAC,KAAK,CAA+B;IAE5C,QAAQ,CAAC,EAAE,EAAE,UAAU,GAAG,aAAa,EAAE;IAuDzC;;;OAGG;IACH,OAAO,CAAC,YAAY;IAUpB,OAAO,CAAC,yBAAyB;IAmCjC,OAAO,CAAC,SAAS;IAejB,OAAO,CAAC,SAAS;IA0BjB,OAAO,CAAC,oBAAoB;IAuB5B,OAAO,CAAC,WAAW;CA6BpB"}
|
package/dist/import/parser.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* TemplateIR format, mapping K8s resources to the K8s::{Group}::{Kind} type
|
|
6
6
|
* convention.
|
|
7
7
|
*/
|
|
8
|
-
import type { TemplateParser, TemplateIR } from "@intentius/chant/import/parser";
|
|
8
|
+
import type { TemplateParser, TemplateIR, ParseContext } from "@intentius/chant/import/parser";
|
|
9
9
|
/**
|
|
10
10
|
* Kubernetes YAML parser implementation.
|
|
11
11
|
*
|
|
@@ -13,7 +13,13 @@ import type { TemplateParser, TemplateIR } from "@intentius/chant/import/parser"
|
|
|
13
13
|
* to be a standard Kubernetes object with apiVersion, kind, and metadata.
|
|
14
14
|
*/
|
|
15
15
|
export declare class K8sParser implements TemplateParser {
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* With `context.embedded` (`chant import`), content another lexicon owns
|
|
18
|
+
* is offered to it: every ConfigMap `data` value and a PrometheusRule's
|
|
19
|
+
* `spec.groups` (see ./embedded.ts). Without it, everything is kept as
|
|
20
|
+
* written.
|
|
21
|
+
*/
|
|
22
|
+
parse(content: string, context?: ParseContext): TemplateIR;
|
|
17
23
|
private parseDocument;
|
|
18
24
|
}
|
|
19
25
|
//# sourceMappingURL=parser.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../src/import/parser.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAc,MAAM,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../src/import/parser.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAc,YAAY,EAAE,MAAM,gCAAgC,CAAC;AA2H3G;;;;;GAKG;AACH,qBAAa,SAAU,YAAW,cAAc;IAC9C;;;;;OAKG;IACH,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,UAAU;IAoC1D,OAAO,CAAC,aAAa;CAgCtB"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ImportResult } from "@intentius/chant/cli/commands/import";
|
|
2
|
+
export declare const pkgDir: string;
|
|
3
|
+
export declare const repoRoot: string;
|
|
4
|
+
export declare const read: (name: string) => string;
|
|
5
|
+
export interface Imported {
|
|
6
|
+
result: ImportResult;
|
|
7
|
+
/** The project directory; the generated source is in `src/`. The caller removes it. */
|
|
8
|
+
dir: string;
|
|
9
|
+
srcDir: string;
|
|
10
|
+
/** Every generated file by its path under `src/`. */
|
|
11
|
+
files: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* `chant import manifest.yaml --lexicon k8s` into a fresh project, or with
|
|
15
|
+
* `detect`, `chant import manifest.yaml` with the lexicon detected.
|
|
16
|
+
*/
|
|
17
|
+
export declare function importManifest(yaml: string, options?: {
|
|
18
|
+
detect?: boolean;
|
|
19
|
+
}): Promise<Imported>;
|
|
20
|
+
export declare function removeDir(dir: string): void;
|
|
21
|
+
//# sourceMappingURL=fixtures.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fixtures.d.ts","sourceRoot":"","sources":["../../../../src/import/testdata/embedded/fixtures.ts"],"names":[],"mappings":"AASA,OAAO,EAAiB,KAAK,YAAY,EAAE,MAAM,sCAAsC,CAAC;AAExF,eAAO,MAAM,MAAM,QAA8C,CAAC;AAClE,eAAO,MAAM,QAAQ,QAA2B,CAAC;AACjD,eAAO,MAAM,IAAI,GAAI,MAAM,MAAM,WAA2D,CAAC;AAE7F,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,YAAY,CAAC;IACrB,uFAAuF;IACvF,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B;AAUD;;;GAGG;AACH,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAYxG;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAE3C"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "1a8204a45d432f44bf6760aff2619dba20707d1e4c2f5fcb1aea1d5aa53daf96",
|
|
5
5
|
"meta.json": "8b999bc142f448b0b627ecdc9e2388bbe672c8a1f7aae5303135624cf2fe65e3",
|
|
6
6
|
"types/index.d.ts": "5f67de738adb455805e72600948940af9c664283785fb68c9fd8f5ea90d42cc3",
|
|
7
7
|
"rules/argo-appset-single-project.ts": "afa9f310753aa2d475f35012b13135b2dfaebed2a35d44edbe185ebc07673674",
|
|
@@ -69,5 +69,5 @@
|
|
|
69
69
|
"skills/chant-k8s-argo.md": "24c9a85b78a2fc5c6657424baac9cc6d6caff6f47605b8146b2c0082133181cf",
|
|
70
70
|
"skills/chant-k8s-flux.md": "9670efc2e9854bd14686b5adeb707396b9030a4dcaee6529b30abf56efe2c50c"
|
|
71
71
|
},
|
|
72
|
-
"composite": "
|
|
72
|
+
"composite": "5680cfa0b7d42111693a68a763446b0351130e6050a629ddcbf9387075214303"
|
|
73
73
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-k8s",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.99.0",
|
|
4
4
|
"description": "Kubernetes lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"@types/js-yaml": "^4.0.9"
|
|
79
79
|
},
|
|
80
80
|
"optionalDependencies": {
|
|
81
|
-
"@intentius/chant-k8s-client": "^0.
|
|
81
|
+
"@intentius/chant-k8s-client": "^0.99.0"
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
84
|
"@intentius/chant": "*",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
},
|
|
87
87
|
"peerDependencies": {
|
|
88
88
|
"zod": "^4.3.6",
|
|
89
|
-
"@intentius/chant": "^0.
|
|
89
|
+
"@intentius/chant": "^0.99.0",
|
|
90
90
|
"typescript": "^5.9.3"
|
|
91
91
|
}
|
|
92
92
|
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedded content through `chant import` (#2962): a manifest holding a
|
|
3
|
+
* collector config, rule groups or dashboard JSON imports as the owning
|
|
4
|
+
* lexicon's typed declarations, referenced from the k8s resource, and
|
|
5
|
+
* `chant build` gives back the same content.
|
|
6
|
+
*
|
|
7
|
+
* Fixtures (testdata/embedded/, provenance in its README.md): the
|
|
8
|
+
* opentelemetry-collector Helm chart's rendered daemonset-only example, the
|
|
9
|
+
* node-exporter PrometheusRule of kube-prometheus, a kube-prometheus
|
|
10
|
+
* dashboard ConfigMap with the Grafana sidecar label, and the k8s build
|
|
11
|
+
* output of examples/agent-observability.
|
|
12
|
+
*
|
|
13
|
+
* "The same" is per owner: a collector config equal as parsed YAML, rule
|
|
14
|
+
* groups equal as parsed YAML, a dashboard equal after the grafana lexicon's
|
|
15
|
+
* `normalizeDashboard` with its importer's edits applied (the grafana round
|
|
16
|
+
* trip's own measure). Content no lexicon claims must come back as the same
|
|
17
|
+
* text. embedded-types.e2e.test.ts type-checks the generated source.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, expect, test } from "vitest";
|
|
21
|
+
import { join } from "path";
|
|
22
|
+
import { load, loadAll } from "js-yaml";
|
|
23
|
+
import { build } from "@intentius/chant/build";
|
|
24
|
+
import type { SerializerResult } from "@intentius/chant/serializer";
|
|
25
|
+
import { lintCommand } from "@intentius/chant/cli/commands/lint";
|
|
26
|
+
import { otelSerializer } from "@intentius/chant-lexicon-otel";
|
|
27
|
+
import { prometheusSerializer } from "@intentius/chant-lexicon-prometheus";
|
|
28
|
+
import { grafanaSerializer } from "@intentius/chant-lexicon-grafana";
|
|
29
|
+
import { parseGrafana, type DashboardResourceMetadata } from "@intentius/chant-lexicon-grafana/import/parser";
|
|
30
|
+
import { applyEdits } from "@intentius/chant-lexicon-grafana/import/edits";
|
|
31
|
+
import { normalizeDashboard } from "@intentius/chant-lexicon-grafana/import/normalize";
|
|
32
|
+
import { k8sSerializer } from "../serializer";
|
|
33
|
+
import { importManifest, read, removeDir, type Imported } from "./testdata/embedded/fixtures";
|
|
34
|
+
|
|
35
|
+
type Json = Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
interface RoundTrip extends Imported {
|
|
38
|
+
/** The source manifest's documents. */
|
|
39
|
+
input: Json[];
|
|
40
|
+
/** The rebuilt k8s documents. */
|
|
41
|
+
output: Json[];
|
|
42
|
+
buildErrors: unknown[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function primary(out: string | SerializerResult | undefined): string {
|
|
46
|
+
if (out === undefined) return "";
|
|
47
|
+
return typeof out === "string" ? out : out.primary;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function roundTrip(fixture: string): Promise<RoundTrip> {
|
|
51
|
+
const yaml = read(fixture);
|
|
52
|
+
const imported = await importManifest(yaml);
|
|
53
|
+
try {
|
|
54
|
+
expect(imported.result.error).toBeUndefined();
|
|
55
|
+
expect(imported.result.success).toBe(true);
|
|
56
|
+
const result = await build(imported.srcDir, [k8sSerializer, otelSerializer, prometheusSerializer, grafanaSerializer]);
|
|
57
|
+
return {
|
|
58
|
+
...imported,
|
|
59
|
+
input: (loadAll(yaml) as Json[]).filter((d) => d),
|
|
60
|
+
output: (loadAll(primary(result.outputs.get("k8s"))) as Json[]).filter((d) => d),
|
|
61
|
+
buildErrors: result.errors,
|
|
62
|
+
};
|
|
63
|
+
} finally {
|
|
64
|
+
removeDir(imported.dir);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function find(docs: Json[], kind: string, name: string): Json {
|
|
69
|
+
const doc = docs.find((d) => d.kind === kind && (d.metadata as Json | undefined)?.name === name);
|
|
70
|
+
if (!doc) throw new Error(`no ${kind} ${name}`);
|
|
71
|
+
return doc;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A collector config as the collector reads it: an empty component is `{}` whether written `{}` or left null. */
|
|
75
|
+
function collectorConfig(text: string): Json {
|
|
76
|
+
const doc = (load(text) ?? {}) as Json;
|
|
77
|
+
for (const section of ["receivers", "processors", "exporters", "connectors", "extensions"]) {
|
|
78
|
+
const s = doc[section] as Json | undefined;
|
|
79
|
+
if (!s) continue;
|
|
80
|
+
for (const id of Object.keys(s)) if (s[id] === null) s[id] = {};
|
|
81
|
+
}
|
|
82
|
+
return doc;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A dashboard as the grafana round trip compares it: the importer's edits applied to the source, both normalized. */
|
|
86
|
+
function dashboards(source: string, rebuilt: string): { expected: Json; actual: Json } {
|
|
87
|
+
const [resource] = parseGrafana(source).resources;
|
|
88
|
+
const meta = resource.metadata as unknown as DashboardResourceMetadata;
|
|
89
|
+
return {
|
|
90
|
+
expected: normalizeDashboard(applyEdits(meta.source as Json, meta.edits)),
|
|
91
|
+
actual: normalizeDashboard(JSON.parse(rebuilt) as Json),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The directories of the generated source holding an owner's modules. */
|
|
96
|
+
function embeddedDirs(files: Record<string, string>): string[] {
|
|
97
|
+
return [...new Set(Object.keys(files).filter((p) => p.includes("/")).map((p) => p.split("/")[0]))].sort();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("manifest -> TypeScript -> manifest, with embedded content imported by its owner", () => {
|
|
101
|
+
test("a collector's ConfigMap becomes typed otel declarations, and the DaemonSet comes back whole", async () => {
|
|
102
|
+
const out = await roundTrip("otel-collector-daemonset.yaml");
|
|
103
|
+
expect(out.buildErrors).toEqual([]);
|
|
104
|
+
expect(out.result.warnings).toEqual([]);
|
|
105
|
+
|
|
106
|
+
const main = out.files["main.ts"];
|
|
107
|
+
expect(main).toContain('import { collectorYaml } from "@intentius/chant-lexicon-otel";');
|
|
108
|
+
expect(main).toMatch(/relay: collectorYaml\(\[\s+jaeger,/);
|
|
109
|
+
expect(main).toContain('from "./example-opentelemetry-collector-agent/receivers";');
|
|
110
|
+
expect(out.files["example-opentelemetry-collector-agent/receivers.ts"]).toContain("new OtlpReceiver(");
|
|
111
|
+
expect(out.files["example-opentelemetry-collector-agent/pipelines.ts"]).toContain("new Pipeline(");
|
|
112
|
+
|
|
113
|
+
const name = "example-opentelemetry-collector-agent";
|
|
114
|
+
const before = find(out.input, "ConfigMap", name).data as Json;
|
|
115
|
+
const after = find(out.output, "ConfigMap", name).data as Json;
|
|
116
|
+
expect(collectorConfig(after.relay as string)).toEqual(collectorConfig(before.relay as string));
|
|
117
|
+
|
|
118
|
+
// The whole DaemonSet comes back, pod template included. The chart renders
|
|
119
|
+
// `spec:` followed by a whitespace-only line, which core's YAML reader
|
|
120
|
+
// used to read as the end of the template's spec, moving its keys up to
|
|
121
|
+
// the DaemonSet's spec (#2991).
|
|
122
|
+
const daemonSet = find(out.output, "DaemonSet", name);
|
|
123
|
+
expect(daemonSet).toEqual(find(out.input, "DaemonSet", name));
|
|
124
|
+
const template = (daemonSet.spec as Json).template as Json;
|
|
125
|
+
expect((template.spec as Json).serviceAccountName).toBe("example-opentelemetry-collector");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a PrometheusRule's spec.groups become prometheus RuleGroups", async () => {
|
|
129
|
+
const out = await roundTrip("node-exporter-prometheusrule.yaml");
|
|
130
|
+
expect(out.buildErrors).toEqual([]);
|
|
131
|
+
expect(out.result.warnings).toEqual([]);
|
|
132
|
+
|
|
133
|
+
const main = out.files["main.ts"];
|
|
134
|
+
expect(main).toContain('from "./node-exporter-rules/rules";');
|
|
135
|
+
expect(main).toMatch(/groups: \[[\s\S]*nodeExporter/);
|
|
136
|
+
expect(out.files["node-exporter-rules/rules.ts"]).toContain("new RuleGroup(");
|
|
137
|
+
|
|
138
|
+
const before = find(out.input, "PrometheusRule", "node-exporter-rules");
|
|
139
|
+
const after = find(out.output, "PrometheusRule", "node-exporter-rules");
|
|
140
|
+
expect(after).toEqual(before);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a dashboard ConfigMap with the sidecar label becomes a grafana Dashboard", async () => {
|
|
144
|
+
const out = await roundTrip("grafana-dashboard-configmap.yaml");
|
|
145
|
+
expect(out.buildErrors).toEqual([]);
|
|
146
|
+
|
|
147
|
+
const main = out.files["main.ts"];
|
|
148
|
+
expect(main).toContain('import { dashboardJson } from "@intentius/chant-lexicon-grafana";');
|
|
149
|
+
expect(main).toMatch(/"alertmanager-overview\.json": dashboardJson\(\w+\)/);
|
|
150
|
+
expect(out.files["grafana-dashboard-alertmanager-overview/dashboard.ts"]).toContain("new Dashboard(");
|
|
151
|
+
|
|
152
|
+
const name = "grafana-dashboard-alertmanager-overview";
|
|
153
|
+
const before = find(out.input, "ConfigMap", name);
|
|
154
|
+
const after = find(out.output, "ConfigMap", name);
|
|
155
|
+
expect(after.metadata).toEqual(before.metadata);
|
|
156
|
+
const { expected, actual } = dashboards(
|
|
157
|
+
(before.data as Json)["alertmanager-overview.json"] as string,
|
|
158
|
+
(after.data as Json)["alertmanager-overview.json"] as string,
|
|
159
|
+
);
|
|
160
|
+
expect(actual).toEqual(expected);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("examples/agent-observability's k8s output: every ConfigMap value comes back, the owned ones typed", async () => {
|
|
164
|
+
const out = await roundTrip("agent-observability.yaml");
|
|
165
|
+
expect(out.buildErrors).toEqual([]);
|
|
166
|
+
expect(embeddedDirs(out.files)).toEqual([
|
|
167
|
+
"grafana-dashboard-genai-agents",
|
|
168
|
+
"grafana-dashboard-red-traces-span-metrics",
|
|
169
|
+
"grafana-dashboard-slo-support-agent-runs",
|
|
170
|
+
"otel-agent-config",
|
|
171
|
+
"otel-gateway-config",
|
|
172
|
+
"prometheus-config-rules",
|
|
173
|
+
]);
|
|
174
|
+
// The rule group an Slo() built comes back as that Slo, referenced by its rules.
|
|
175
|
+
expect(out.files["prometheus-config-rules/slos.ts"]).toContain("Slo(");
|
|
176
|
+
expect(Object.values(out.files).join("\n")).toMatch(/ruleFileYaml\(\[\w+\.rules\]\)/);
|
|
177
|
+
|
|
178
|
+
const configMaps = out.input.filter((d) => d.kind === "ConfigMap");
|
|
179
|
+
expect(configMaps.length).toBeGreaterThanOrEqual(7);
|
|
180
|
+
for (const before of configMaps) {
|
|
181
|
+
const name = (before.metadata as Json).name as string;
|
|
182
|
+
const after = find(out.output, "ConfigMap", name);
|
|
183
|
+
const data = before.data as Record<string, string>;
|
|
184
|
+
expect(Object.keys(after.data as Json).sort(), name).toEqual(Object.keys(data).sort());
|
|
185
|
+
for (const [key, text] of Object.entries(data)) {
|
|
186
|
+
const rebuilt = (after.data as Record<string, string>)[key];
|
|
187
|
+
const at = `${name} ${key}`;
|
|
188
|
+
if (key.endsWith(".json")) {
|
|
189
|
+
const { expected, actual } = dashboards(text, rebuilt);
|
|
190
|
+
expect(actual, at).toEqual(expected);
|
|
191
|
+
} else if (name.startsWith("otel-")) {
|
|
192
|
+
expect(collectorConfig(rebuilt), at).toEqual(collectorConfig(text));
|
|
193
|
+
} else {
|
|
194
|
+
expect(load(rebuilt), at).toEqual(load(text));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("a detected manifest delegates the same way", async () => {
|
|
201
|
+
const imported = await importManifest(read("node-exporter-prometheusrule.yaml"), { detect: true });
|
|
202
|
+
try {
|
|
203
|
+
expect(imported.result.lexicon).toBe("k8s");
|
|
204
|
+
expect(imported.result.detected).toBe(true);
|
|
205
|
+
expect(imported.result.generatedFiles).toEqual(["main.ts", "node-exporter-rules/rules.ts"]);
|
|
206
|
+
} finally {
|
|
207
|
+
removeDir(imported.dir);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("the owners' modules lint clean", async () => {
|
|
212
|
+
const yaml = [
|
|
213
|
+
read("otel-collector-daemonset.yaml"),
|
|
214
|
+
read("node-exporter-prometheusrule.yaml"),
|
|
215
|
+
read("grafana-dashboard-configmap.yaml"),
|
|
216
|
+
].join("\n---\n");
|
|
217
|
+
const imported = await importManifest(yaml);
|
|
218
|
+
try {
|
|
219
|
+
const dirs = embeddedDirs(imported.files);
|
|
220
|
+
expect(dirs).toEqual(["example-opentelemetry-collector-agent", "grafana-dashboard-alertmanager-overview", "node-exporter-rules"]);
|
|
221
|
+
for (const dir of dirs) {
|
|
222
|
+
const lint = await lintCommand({ path: join(imported.srcDir, dir), format: "stylish" });
|
|
223
|
+
if (lint.errorCount + lint.warningCount > 0) console.log(lint.output);
|
|
224
|
+
expect(lint.errorCount, dir).toBe(0);
|
|
225
|
+
expect(lint.warningCount, dir).toBe(0);
|
|
226
|
+
}
|
|
227
|
+
} finally {
|
|
228
|
+
removeDir(imported.dir);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `chant import` generates for manifests with embedded content (#2962)
|
|
3
|
+
* type-checks: the k8s module that references the owners' declarations, and
|
|
4
|
+
* the owners' modules. Compiling takes longer than the unit-test budget
|
|
5
|
+
* allows, so this runs with the e2e tests.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import * as ts from "typescript";
|
|
10
|
+
import { importManifest, read, removeDir, repoRoot } from "./testdata/embedded/fixtures";
|
|
11
|
+
|
|
12
|
+
/** Type errors in the generated files, compiled with the repo's compiler options. */
|
|
13
|
+
function typeErrors(srcDir: string, paths: string[]): string[] {
|
|
14
|
+
const files = paths.map((p) => join(srcDir, p));
|
|
15
|
+
const configFile = ts.readConfigFile(join(repoRoot, "tsconfig.json"), ts.sys.readFile);
|
|
16
|
+
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, repoRoot);
|
|
17
|
+
const program = ts.createProgram(files, { ...parsed.options, noEmit: true });
|
|
18
|
+
const real = (f: string) => ts.sys.realpath?.(f) ?? f;
|
|
19
|
+
const fileSet = new Set(files.map(real));
|
|
20
|
+
// A program that saw none of the files would pass vacuously.
|
|
21
|
+
expect(files.every((f) => program.getSourceFile(f) !== undefined)).toBe(true);
|
|
22
|
+
return ts
|
|
23
|
+
.getPreEmitDiagnostics(program)
|
|
24
|
+
.filter((d) => d.file && fileSet.has(real(d.file.fileName)))
|
|
25
|
+
.map((d) => `${real(d.file!.fileName).slice(real(srcDir).length + 1)}: ${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("the source generated for embedded content type-checks", () => {
|
|
29
|
+
test.each([
|
|
30
|
+
"otel-collector-daemonset.yaml",
|
|
31
|
+
"node-exporter-prometheusrule.yaml",
|
|
32
|
+
"grafana-dashboard-configmap.yaml",
|
|
33
|
+
"agent-observability.yaml",
|
|
34
|
+
])("%s", async (fixture) => {
|
|
35
|
+
const imported = await importManifest(read(fixture));
|
|
36
|
+
try {
|
|
37
|
+
expect(imported.result.success).toBe(true);
|
|
38
|
+
expect(typeErrors(imported.srcDir, Object.keys(imported.files))).toEqual([]);
|
|
39
|
+
} finally {
|
|
40
|
+
removeDir(imported.dir);
|
|
41
|
+
}
|
|
42
|
+
}, 180_000);
|
|
43
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { EmbeddedImports, type EmbeddedContent, type EmbeddedContentImporter } from "@intentius/chant/import/embedded";
|
|
3
|
+
import { K8sParser } from "./parser";
|
|
4
|
+
import { K8sGenerator } from "./generator";
|
|
5
|
+
|
|
6
|
+
const MANIFEST = `
|
|
7
|
+
apiVersion: v1
|
|
8
|
+
kind: ConfigMap
|
|
9
|
+
metadata:
|
|
10
|
+
name: otel-agent
|
|
11
|
+
labels:
|
|
12
|
+
app.kubernetes.io/name: otel-agent
|
|
13
|
+
data:
|
|
14
|
+
config.yaml: |
|
|
15
|
+
receivers:
|
|
16
|
+
otlp: {}
|
|
17
|
+
exporters:
|
|
18
|
+
debug: {}
|
|
19
|
+
service:
|
|
20
|
+
pipelines:
|
|
21
|
+
traces:
|
|
22
|
+
receivers: [otlp]
|
|
23
|
+
exporters: [debug]
|
|
24
|
+
plain.txt: just text
|
|
25
|
+
---
|
|
26
|
+
apiVersion: v1
|
|
27
|
+
kind: ConfigMap
|
|
28
|
+
metadata:
|
|
29
|
+
name: dashboards
|
|
30
|
+
labels:
|
|
31
|
+
grafana_dashboard: "1"
|
|
32
|
+
data:
|
|
33
|
+
api.json: '{"title": "API", "panels": [], "schemaVersion": 41}'
|
|
34
|
+
---
|
|
35
|
+
apiVersion: monitoring.coreos.com/v1
|
|
36
|
+
kind: PrometheusRule
|
|
37
|
+
metadata:
|
|
38
|
+
name: api-rules
|
|
39
|
+
spec:
|
|
40
|
+
groups:
|
|
41
|
+
- name: api
|
|
42
|
+
rules:
|
|
43
|
+
- alert: Down
|
|
44
|
+
expr: up == 0
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
/** Claims every offered piece of content, recording it, as `fake.ts` exporting `thing`. */
|
|
48
|
+
function claimAll(seen: EmbeddedContent[]): EmbeddedContentImporter {
|
|
49
|
+
return {
|
|
50
|
+
what: "anything",
|
|
51
|
+
matches: () => true,
|
|
52
|
+
import: (c) => {
|
|
53
|
+
seen.push(c);
|
|
54
|
+
return {
|
|
55
|
+
files: [{ path: "fake.ts", content: "const thing = 1;\n\nexport { thing };\n" }],
|
|
56
|
+
value: { bindings: [{ from: "fake.ts", name: "thing" }], shape: "single", through: { from: "@acme/fake", name: "render" } },
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("k8s import offers embedded content (#2962)", () => {
|
|
63
|
+
test("without a resolver, everything is kept as written", () => {
|
|
64
|
+
const ir = new K8sParser().parse(MANIFEST);
|
|
65
|
+
expect((ir.resources[0].properties.data as Record<string, unknown>)["config.yaml"]).toContain("receivers:");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("ConfigMap values that parse as documents, and a PrometheusRule's groups, are offered", () => {
|
|
69
|
+
const seen: EmbeddedContent[] = [];
|
|
70
|
+
const embedded = new EmbeddedImports([{ lexicon: "acme", importer: claimAll(seen) }]);
|
|
71
|
+
new K8sParser().parse(MANIFEST, { embedded });
|
|
72
|
+
|
|
73
|
+
// plain.txt is not a document and is not offered.
|
|
74
|
+
expect(seen.map((c) => [c.location, c.directory, c.select, c.expectedOwner?.lexicon])).toEqual([
|
|
75
|
+
['ConfigMap otel-agent data["config.yaml"]', "otel-agent-config", undefined, "otel"],
|
|
76
|
+
['ConfigMap dashboards data["api.json"]', "dashboards", undefined, "grafana"],
|
|
77
|
+
["PrometheusRule api-rules spec.groups", "api-rules", "groups", "prometheus"],
|
|
78
|
+
]);
|
|
79
|
+
expect(seen[1].labels).toEqual({ grafana_dashboard: "1" });
|
|
80
|
+
expect(seen[2].document).toEqual({ groups: [{ name: "api", rules: [{ alert: "Down", expr: "up == 0" }] }] });
|
|
81
|
+
expect(embedded.files.map((f) => f.path)).toEqual(["otel-agent-config/fake.ts", "dashboards/fake.ts", "api-rules/fake.ts"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("the generator references what the owner declared, and quotes keys that are not identifiers", () => {
|
|
85
|
+
const embedded = new EmbeddedImports([{ lexicon: "acme", importer: claimAll([]) }]);
|
|
86
|
+
const ir = new K8sParser().parse(MANIFEST, { embedded });
|
|
87
|
+
const [main] = new K8sGenerator().generate(ir);
|
|
88
|
+
expect(main.content).toContain('import { render } from "@acme/fake";');
|
|
89
|
+
expect(main.content).toContain('import { thing } from "./otel-agent-config/fake";');
|
|
90
|
+
expect(main.content).toContain('import { thing as thing2 } from "./dashboards/fake";');
|
|
91
|
+
expect(main.content).toContain('"config.yaml": render(thing),');
|
|
92
|
+
expect(main.content).toContain('"api.json": render(thing2),');
|
|
93
|
+
expect(main.content).toContain("groups: render(thing3),");
|
|
94
|
+
expect(main.content).toContain('"plain.txt": "just text",');
|
|
95
|
+
expect(main.content).toContain('"app.kubernetes.io/name": "otel-agent",');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("when no installed lexicon imports it, the content is kept and the expected owner is named", () => {
|
|
99
|
+
const embedded = new EmbeddedImports([]);
|
|
100
|
+
const ir = new K8sParser().parse(MANIFEST, { embedded });
|
|
101
|
+
expect((ir.resources[0].properties.data as Record<string, unknown>)["config.yaml"]).toContain("receivers:");
|
|
102
|
+
expect(embedded.warnings).toHaveLength(3);
|
|
103
|
+
expect(embedded.warnings[0]).toMatch(/^ConfigMap otel-agent data\["config.yaml"\] looks like an OpenTelemetry Collector config/);
|
|
104
|
+
expect(embedded.warnings[0]).toContain("@intentius/chant-lexicon-otel");
|
|
105
|
+
expect(embedded.warnings[1]).toContain("@intentius/chant-lexicon-grafana");
|
|
106
|
+
expect(embedded.warnings[2]).toContain("@intentius/chant-lexicon-prometheus");
|
|
107
|
+
const [main] = new K8sGenerator().generate(ir);
|
|
108
|
+
expect(main.content).not.toContain("import { render");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("the k8s generator's imports", () => {
|
|
113
|
+
test("a constructor named inside a string value is not imported", () => {
|
|
114
|
+
const ir = new K8sParser().parse(`
|
|
115
|
+
apiVersion: v1
|
|
116
|
+
kind: ConfigMap
|
|
117
|
+
metadata:
|
|
118
|
+
name: app
|
|
119
|
+
data:
|
|
120
|
+
main.js: 'const x = new Widget("a");'
|
|
121
|
+
`);
|
|
122
|
+
const [main] = new K8sGenerator().generate(ir);
|
|
123
|
+
expect(main.content.split("\n")[0]).toBe('import { ConfigMap } from "@intentius/chant-lexicon-k8s";');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a Kubernetes manifest embeds another lexicon's content, for
|
|
3
|
+
* `chant import` (#2962).
|
|
4
|
+
*
|
|
5
|
+
* The parser offers two kinds of place to core's embedded-content resolver:
|
|
6
|
+
*
|
|
7
|
+
* - every `data` value of a ConfigMap, as text (a collector's `config.yaml`,
|
|
8
|
+
* a Prometheus rule file, a Grafana dashboard's JSON);
|
|
9
|
+
* - a `monitoring.coreos.com` PrometheusRule's `spec.groups`, as the rule file
|
|
10
|
+
* `{ groups }` those groups would make.
|
|
11
|
+
*
|
|
12
|
+
* Which lexicon imports the content is decided by core at run time, from the
|
|
13
|
+
* lexicons that register an `embeddedImporters()`; this module names none of
|
|
14
|
+
* them as a dependency. The `expectedOwner` hints below are the conventions
|
|
15
|
+
* k8s manifests follow, used only to warn when the owner is not installed.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { EmbeddedContent, EmbeddedContentResolver } from "@intentius/chant/import/embedded";
|
|
19
|
+
import { embeddedDocument } from "@intentius/chant/import/embedded";
|
|
20
|
+
|
|
21
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
22
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The `grafana_dashboard` label the Grafana dashboard sidecar looks for (kube-prometheus-stack, the Grafana Helm chart). */
|
|
26
|
+
export const GRAFANA_DASHBOARD_LABEL = "grafana_dashboard";
|
|
27
|
+
|
|
28
|
+
function looksLikeRuleGroups(groups: unknown): boolean {
|
|
29
|
+
return Array.isArray(groups) && groups.length > 0 && groups.every((g) => isObject(g) && typeof g.name === "string" && Array.isArray(g.rules));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whose content a ConfigMap value looks like, by the conventions manifests follow. */
|
|
33
|
+
function hintFor(key: string, doc: unknown, labels: Record<string, string>): EmbeddedContent["expectedOwner"] {
|
|
34
|
+
if (!isObject(doc)) return undefined;
|
|
35
|
+
if (Array.isArray(doc.panels) && (GRAFANA_DASHBOARD_LABEL in labels || key.endsWith(".json"))) {
|
|
36
|
+
return { lexicon: "grafana", what: "a Grafana dashboard" };
|
|
37
|
+
}
|
|
38
|
+
if (isObject(doc.service) && isObject(doc.service.pipelines)) {
|
|
39
|
+
return { lexicon: "otel", what: "an OpenTelemetry Collector config" };
|
|
40
|
+
}
|
|
41
|
+
if (looksLikeRuleGroups(doc.groups)) return { lexicon: "prometheus", what: "a Prometheus rule file" };
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stringLabels(metadata: unknown): Record<string, string> {
|
|
46
|
+
const labels = isObject(metadata) && isObject(metadata.labels) ? metadata.labels : {};
|
|
47
|
+
return Object.fromEntries(Object.entries(labels).filter((e): e is [string, string] => typeof e[1] === "string"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Offer the embedded content of one resource to `embedded`, replacing each
|
|
52
|
+
* value an owner imports with the reference it returns. `properties` is the
|
|
53
|
+
* resource's IR properties, changed in place (with fresh copies of the
|
|
54
|
+
* objects on the way down).
|
|
55
|
+
*/
|
|
56
|
+
export function delegateEmbedded(
|
|
57
|
+
type: string,
|
|
58
|
+
kind: string,
|
|
59
|
+
properties: Record<string, unknown>,
|
|
60
|
+
embedded: EmbeddedContentResolver,
|
|
61
|
+
): void {
|
|
62
|
+
const metadata = properties.metadata;
|
|
63
|
+
const name = isObject(metadata) && typeof metadata.name === "string" ? metadata.name : kind.toLowerCase();
|
|
64
|
+
const labels = stringLabels(metadata);
|
|
65
|
+
|
|
66
|
+
if (type === "K8s::Core::ConfigMap" && isObject(properties.data)) {
|
|
67
|
+
const keys = Object.keys(properties.data);
|
|
68
|
+
const data: Record<string, unknown> = { ...properties.data };
|
|
69
|
+
for (const key of keys) {
|
|
70
|
+
const text = data[key];
|
|
71
|
+
if (typeof text !== "string") continue;
|
|
72
|
+
const doc = embeddedDocument(text);
|
|
73
|
+
if (!isObject(doc)) continue;
|
|
74
|
+
const ref = embedded.resolve({
|
|
75
|
+
host: "k8s",
|
|
76
|
+
hostType: type,
|
|
77
|
+
location: `ConfigMap ${name} data[${JSON.stringify(key)}]`,
|
|
78
|
+
directory: keys.length === 1 ? name : `${name}-${key.replace(/\.[^.]*$/, "")}`,
|
|
79
|
+
text,
|
|
80
|
+
document: doc,
|
|
81
|
+
labels,
|
|
82
|
+
expectedOwner: hintFor(key, doc, labels),
|
|
83
|
+
});
|
|
84
|
+
if (ref) data[key] = ref;
|
|
85
|
+
}
|
|
86
|
+
properties.data = data;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (type === "K8s::Monitoring::PrometheusRule" && isObject(properties.spec) && Array.isArray(properties.spec.groups)) {
|
|
91
|
+
const groups = properties.spec.groups;
|
|
92
|
+
const ref = embedded.resolve({
|
|
93
|
+
host: "k8s",
|
|
94
|
+
hostType: type,
|
|
95
|
+
location: `PrometheusRule ${name} spec.groups`,
|
|
96
|
+
directory: name,
|
|
97
|
+
document: { groups },
|
|
98
|
+
select: "groups",
|
|
99
|
+
labels,
|
|
100
|
+
expectedOwner: { lexicon: "prometheus", what: "Prometheus rule groups" },
|
|
101
|
+
});
|
|
102
|
+
if (ref) properties.spec = { ...properties.spec, groups: ref };
|
|
103
|
+
}
|
|
104
|
+
}
|