@intentius/chant-lexicon-grafana 0.96.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/README.md +50 -0
- package/dist/build.d.ts +116 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/catalog.d.ts +26 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/codegen/docs-cli.d.ts +3 -0
- package/dist/codegen/docs-cli.d.ts.map +1 -0
- package/dist/codegen/docs.d.ts +8 -0
- package/dist/codegen/docs.d.ts.map +1 -0
- package/dist/codegen/generate-cli.d.ts +3 -0
- package/dist/codegen/generate-cli.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts +21 -0
- package/dist/codegen/generate.d.ts.map +1 -0
- package/dist/codegen/package.d.ts +7 -0
- package/dist/codegen/package.d.ts.map +1 -0
- package/dist/codegen/schema-types.d.ts +41 -0
- package/dist/codegen/schema-types.d.ts.map +1 -0
- package/dist/composites/agent-dashboard.d.ts +70 -0
- package/dist/composites/agent-dashboard.d.ts.map +1 -0
- package/dist/composites/catalog.d.ts +3 -0
- package/dist/composites/catalog.d.ts.map +1 -0
- package/dist/composites/index.d.ts +12 -0
- package/dist/composites/index.d.ts.map +1 -0
- package/dist/composites/red-dashboard.d.ts +58 -0
- package/dist/composites/red-dashboard.d.ts.map +1 -0
- package/dist/composites/shared.d.ts +68 -0
- package/dist/composites/shared.d.ts.map +1 -0
- package/dist/composites/slo-dashboard.d.ts +62 -0
- package/dist/composites/slo-dashboard.d.ts.map +1 -0
- package/dist/dashboard.d.ts +84 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/datasource.d.ts +67 -0
- package/dist/datasource.d.ts.map +1 -0
- package/dist/detect.d.ts +20 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/integrity.json +22 -0
- package/dist/lint/audit-catalog.d.ts +13 -0
- package/dist/lint/audit-catalog.d.ts.map +1 -0
- package/dist/lint/post-synth/graf101.d.ts +8 -0
- package/dist/lint/post-synth/graf101.d.ts.map +1 -0
- package/dist/lint/post-synth/graf102.d.ts +8 -0
- package/dist/lint/post-synth/graf102.d.ts.map +1 -0
- package/dist/lint/post-synth/graf103.d.ts +8 -0
- package/dist/lint/post-synth/graf103.d.ts.map +1 -0
- package/dist/lint/post-synth/graf104.d.ts +8 -0
- package/dist/lint/post-synth/graf104.d.ts.map +1 -0
- package/dist/lint/post-synth/graf105.d.ts +8 -0
- package/dist/lint/post-synth/graf105.d.ts.map +1 -0
- package/dist/lint/post-synth/graf106.d.ts +8 -0
- package/dist/lint/post-synth/graf106.d.ts.map +1 -0
- package/dist/lint/post-synth/graf107.d.ts +8 -0
- package/dist/lint/post-synth/graf107.d.ts.map +1 -0
- package/dist/lint/post-synth/grafana-helpers.d.ts +16 -0
- package/dist/lint/post-synth/grafana-helpers.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts +3 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -0
- package/dist/lint/rules/grafana-ast.d.ts +21 -0
- package/dist/lint/rules/grafana-ast.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +6 -0
- package/dist/lint/rules/index.d.ts.map +1 -0
- package/dist/lint/rules/literal-secret.d.ts +12 -0
- package/dist/lint/rules/literal-secret.d.ts.map +1 -0
- package/dist/lint/rules/uid-syntax.d.ts +12 -0
- package/dist/lint/rules/uid-syntax.d.ts.map +1 -0
- package/dist/lsp/completions.d.ts +4 -0
- package/dist/lsp/completions.d.ts.map +1 -0
- package/dist/lsp/hover.d.ts +4 -0
- package/dist/lsp/hover.d.ts.map +1 -0
- package/dist/manifest.json +8 -0
- package/dist/meta.json +107 -0
- package/dist/okf/index.md +39 -0
- package/dist/okf/rules/GRAF001.md +16 -0
- package/dist/okf/rules/GRAF002.md +15 -0
- package/dist/okf/rules/GRAF101.md +15 -0
- package/dist/okf/rules/GRAF102.md +15 -0
- package/dist/okf/rules/GRAF103.md +11 -0
- package/dist/okf/rules/GRAF104.md +15 -0
- package/dist/okf/rules/GRAF105.md +11 -0
- package/dist/okf/rules/GRAF106.md +11 -0
- package/dist/okf/rules/GRAF107.md +11 -0
- package/dist/okf/types/ConstantVariable.md +9 -0
- package/dist/okf/types/CustomVariable.md +9 -0
- package/dist/okf/types/Dashboard.md +14 -0
- package/dist/okf/types/DashboardProvider.md +9 -0
- package/dist/okf/types/Datasource.md +16 -0
- package/dist/okf/types/DatasourceVariable.md +9 -0
- package/dist/okf/types/GaugePanel.md +9 -0
- package/dist/okf/types/HeatmapPanel.md +9 -0
- package/dist/okf/types/IntervalVariable.md +9 -0
- package/dist/okf/types/LogsPanel.md +9 -0
- package/dist/okf/types/LokiQuery.md +9 -0
- package/dist/okf/types/PromQuery.md +9 -0
- package/dist/okf/types/QueryVariable.md +9 -0
- package/dist/okf/types/Row.md +9 -0
- package/dist/okf/types/StatPanel.md +9 -0
- package/dist/okf/types/TablePanel.md +9 -0
- package/dist/okf/types/TempoQuery.md +9 -0
- package/dist/okf/types/TextPanel.md +9 -0
- package/dist/okf/types/TextboxVariable.md +9 -0
- package/dist/okf/types/TimeSeriesPanel.md +9 -0
- package/dist/okf/types/TracesPanel.md +9 -0
- package/dist/package-cli.d.ts +3 -0
- package/dist/package-cli.d.ts.map +1 -0
- package/dist/panels.d.ts +138 -0
- package/dist/panels.d.ts.map +1 -0
- package/dist/pin.d.ts +32 -0
- package/dist/pin.d.ts.map +1 -0
- package/dist/plugin.d.ts +12 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/query.d.ts +81 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/rules/graf101.ts +17 -0
- package/dist/rules/graf102.ts +17 -0
- package/dist/rules/graf103.ts +17 -0
- package/dist/rules/graf104.ts +17 -0
- package/dist/rules/graf105.ts +17 -0
- package/dist/rules/graf106.ts +17 -0
- package/dist/rules/graf107.ts +17 -0
- package/dist/rules/grafana-ast.ts +70 -0
- package/dist/rules/grafana-helpers.ts +68 -0
- package/dist/rules/literal-secret.ts +48 -0
- package/dist/rules/uid-syntax.ts +56 -0
- package/dist/schema/dashboard.gen.d.ts +1231 -0
- package/dist/schema/dashboard.gen.d.ts.map +1 -0
- package/dist/schema/gauge.gen.d.ts +57 -0
- package/dist/schema/gauge.gen.d.ts.map +1 -0
- package/dist/schema/heatmap.gen.d.ts +295 -0
- package/dist/schema/heatmap.gen.d.ts.map +1 -0
- package/dist/schema/index.d.ts +18 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/logs.gen.d.ts +29 -0
- package/dist/schema/logs.gen.d.ts.map +1 -0
- package/dist/schema/loki.gen.d.ts +69 -0
- package/dist/schema/loki.gen.d.ts.map +1 -0
- package/dist/schema/prometheus.gen.d.ts +169 -0
- package/dist/schema/prometheus.gen.d.ts.map +1 -0
- package/dist/schema/stat.gen.d.ts +75 -0
- package/dist/schema/stat.gen.d.ts.map +1 -0
- package/dist/schema/table.gen.d.ts +295 -0
- package/dist/schema/table.gen.d.ts.map +1 -0
- package/dist/schema/tempo.gen.d.ts +136 -0
- package/dist/schema/tempo.gen.d.ts.map +1 -0
- package/dist/schema/text.gen.d.ts +16 -0
- package/dist/schema/text.gen.d.ts.map +1 -0
- package/dist/schema/timeseries.gen.d.ts +179 -0
- package/dist/schema/timeseries.gen.d.ts.map +1 -0
- package/dist/schema-validate.d.ts +20 -0
- package/dist/schema-validate.d.ts.map +1 -0
- package/dist/serializer.d.ts +22 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/skill-defs.d.ts +3 -0
- package/dist/skill-defs.d.ts.map +1 -0
- package/dist/skills/chant-grafana-provisioning.md +42 -0
- package/dist/skills/chant-grafana.md +93 -0
- package/dist/spec/fetch-cli.d.ts +3 -0
- package/dist/spec/fetch-cli.d.ts.map +1 -0
- package/dist/spec/schemas.d.ts +25 -0
- package/dist/spec/schemas.d.ts.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/util.d.ts +17 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/validate-cli.d.ts +3 -0
- package/dist/validate-cli.d.ts.map +1 -0
- package/dist/validate-output.d.ts +42 -0
- package/dist/validate-output.d.ts.map +1 -0
- package/dist/validate.d.ts +12 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/variables.d.ts +100 -0
- package/dist/variables.d.ts.map +1 -0
- package/package.json +79 -0
- package/src/build.test.ts +210 -0
- package/src/build.ts +573 -0
- package/src/catalog.ts +116 -0
- package/src/codegen/docs-cli.ts +4 -0
- package/src/codegen/docs.ts +97 -0
- package/src/codegen/generate-cli.ts +8 -0
- package/src/codegen/generate.ts +83 -0
- package/src/codegen/package.ts +41 -0
- package/src/codegen/schema-types.ts +173 -0
- package/src/composites/agent-dashboard.ts +235 -0
- package/src/composites/catalog.test.ts +37 -0
- package/src/composites/catalog.ts +223 -0
- package/src/composites/composites.test.ts +314 -0
- package/src/composites/index.ts +12 -0
- package/src/composites/red-dashboard.ts +159 -0
- package/src/composites/shared.ts +94 -0
- package/src/composites/slo-dashboard.ts +177 -0
- package/src/dashboard.ts +123 -0
- package/src/datasource.ts +93 -0
- package/src/detect.ts +30 -0
- package/src/generated/lexicon-grafana.json +107 -0
- package/src/import.e2e.test.ts +147 -0
- package/src/import.test.ts +45 -0
- package/src/index.ts +178 -0
- package/src/lint/audit-catalog.ts +87 -0
- package/src/lint/post-synth/graf101.ts +17 -0
- package/src/lint/post-synth/graf102.ts +17 -0
- package/src/lint/post-synth/graf103.ts +17 -0
- package/src/lint/post-synth/graf104.ts +17 -0
- package/src/lint/post-synth/graf105.ts +17 -0
- package/src/lint/post-synth/graf106.ts +17 -0
- package/src/lint/post-synth/graf107.ts +17 -0
- package/src/lint/post-synth/grafana-helpers.ts +68 -0
- package/src/lint/post-synth/index.ts +19 -0
- package/src/lint/post-synth/post-synth.test.ts +231 -0
- package/src/lint/rules/grafana-ast.ts +70 -0
- package/src/lint/rules/index.ts +9 -0
- package/src/lint/rules/literal-secret.ts +48 -0
- package/src/lint/rules/rules.test.ts +70 -0
- package/src/lint/rules/uid-syntax.ts +56 -0
- package/src/lsp/completions.test.ts +31 -0
- package/src/lsp/completions.ts +15 -0
- package/src/lsp/hover.test.ts +25 -0
- package/src/lsp/hover.ts +28 -0
- package/src/package-cli.ts +17 -0
- package/src/panels.ts +275 -0
- package/src/pin.ts +68 -0
- package/src/plugin.test.ts +50 -0
- package/src/plugin.ts +116 -0
- package/src/query.ts +160 -0
- package/src/schema/dashboard.gen.ts +1289 -0
- package/src/schema/gauge.gen.ts +64 -0
- package/src/schema/heatmap.gen.ts +321 -0
- package/src/schema/index.ts +17 -0
- package/src/schema/logs.gen.ts +34 -0
- package/src/schema/loki.gen.ts +77 -0
- package/src/schema/prometheus.gen.ts +181 -0
- package/src/schema/stat.gen.ts +86 -0
- package/src/schema/table.gen.ts +334 -0
- package/src/schema/tempo.gen.ts +146 -0
- package/src/schema/text.gen.ts +22 -0
- package/src/schema/timeseries.gen.ts +208 -0
- package/src/schema-validate.ts +143 -0
- package/src/schema.test.ts +76 -0
- package/src/serializer.test.ts +170 -0
- package/src/serializer.ts +37 -0
- package/src/skill-defs.ts +31 -0
- package/src/skills/chant-grafana-provisioning.md +42 -0
- package/src/skills/chant-grafana.md +93 -0
- package/src/spec/fetch-cli.ts +27 -0
- package/src/spec/schemas/dashboard.jsonschema.json +1901 -0
- package/src/spec/schemas/gauge.jsonschema.json +114 -0
- package/src/spec/schemas/heatmap.jsonschema.json +480 -0
- package/src/spec/schemas/logs.jsonschema.json +94 -0
- package/src/spec/schemas/loki.jsonschema.json +118 -0
- package/src/spec/schemas/prometheus.jsonschema.json +276 -0
- package/src/spec/schemas/stat.jsonschema.json +163 -0
- package/src/spec/schemas/table.jsonschema.json +751 -0
- package/src/spec/schemas/tempo.jsonschema.json +225 -0
- package/src/spec/schemas/text.jsonschema.json +72 -0
- package/src/spec/schemas/timeseries.jsonschema.json +471 -0
- package/src/spec/schemas.ts +56 -0
- package/src/util.ts +43 -0
- package/src/validate-cli.ts +7 -0
- package/src/validate-output.ts +380 -0
- package/src/validate.ts +108 -0
- package/src/variables.ts +161 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GRAF107: A dashboard does not match the pinned Grafana schema
|
|
3
|
+
*
|
|
4
|
+
* The dashboard is validated against the dashboard schema at GRAFANA_SCHEMA_PIN, and each panel's options, fieldConfig.defaults.custom and queries against their plugin's schema with required fields relaxed (Grafana fills those in). What fails is a key the schema does not have or a value it does not allow: the edits Grafana would otherwise need on import.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
8
|
+
import { grafanaDiagnostics } from "./grafana-helpers";
|
|
9
|
+
|
|
10
|
+
export const graf107: PostSynthCheck = {
|
|
11
|
+
id: "GRAF107",
|
|
12
|
+
description: "A dashboard does not match the pinned Grafana schema",
|
|
13
|
+
|
|
14
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
15
|
+
return grafanaDiagnostics(ctx, "GRAF107");
|
|
16
|
+
},
|
|
17
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plumbing for the grafana post-synth checks: find the dashboards and
|
|
3
|
+
* provisioned datasources in a build's output, and run the plain-function
|
|
4
|
+
* checks in `validate-output.ts` over them.
|
|
5
|
+
*
|
|
6
|
+
* Any output file shaped like a dashboard or a datasource provisioning file
|
|
7
|
+
* counts, not only the grafana lexicon's own, so a dashboard another lexicon
|
|
8
|
+
* embeds is checked the same way.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import type { SerializerResult } from "@intentius/chant/serializer";
|
|
13
|
+
import { loadAll } from "js-yaml";
|
|
14
|
+
import { looksLikeDashboard, looksLikeDatasourceProvisioning } from "../../detect";
|
|
15
|
+
import type { ProvisionedDatasource } from "../../build";
|
|
16
|
+
import { issuesFor, type GrafanaArtifacts, type GrafanaIssueCode } from "../../validate-output";
|
|
17
|
+
|
|
18
|
+
function parse(text: string, name: string): unknown[] {
|
|
19
|
+
if (name.endsWith(".json") || /^\s*[{[]/.test(text)) {
|
|
20
|
+
try {
|
|
21
|
+
return [JSON.parse(text)];
|
|
22
|
+
} catch {
|
|
23
|
+
// fall through to YAML
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return loadAll(text);
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Every dashboard and provisioned datasource in the build's output. */
|
|
34
|
+
export function grafanaArtifacts(ctx: PostSynthContext): GrafanaArtifacts {
|
|
35
|
+
const out: GrafanaArtifacts = { dashboards: [], datasources: [] };
|
|
36
|
+
for (const [lexicon, output] of ctx.outputs) {
|
|
37
|
+
const texts: Array<[string, string]> =
|
|
38
|
+
typeof output === "string"
|
|
39
|
+
? [[lexicon, output]]
|
|
40
|
+
: [[lexicon, (output as SerializerResult).primary], ...Object.entries((output as SerializerResult).files ?? {})];
|
|
41
|
+
for (const [source, text] of texts) {
|
|
42
|
+
if (!text) continue;
|
|
43
|
+
for (const doc of parse(text, source)) {
|
|
44
|
+
if (looksLikeDashboard(doc)) out.dashboards.push({ source, json: doc });
|
|
45
|
+
else if (looksLikeDatasourceProvisioning(doc)) {
|
|
46
|
+
for (const d of doc.datasources) {
|
|
47
|
+
if (d && typeof d === "object" && typeof (d as ProvisionedDatasource).name === "string") {
|
|
48
|
+
const ds = d as ProvisionedDatasource;
|
|
49
|
+
out.datasources.push({ ...ds, uid: typeof ds.uid === "string" ? ds.uid : ds.name });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Diagnostics for one check across the build's output. */
|
|
60
|
+
export function grafanaDiagnostics(ctx: PostSynthContext, code: GrafanaIssueCode): PostSynthDiagnostic[] {
|
|
61
|
+
return issuesFor(code, grafanaArtifacts(ctx)).map((i) => ({
|
|
62
|
+
checkId: i.code,
|
|
63
|
+
severity: i.severity,
|
|
64
|
+
message: i.message,
|
|
65
|
+
...(i.entity ? { entity: i.entity } : {}),
|
|
66
|
+
lexicon: "grafana",
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Code generated by chant generate. DO NOT EDIT.
|
|
2
|
+
import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { graf101 } from "./graf101";
|
|
4
|
+
import { graf102 } from "./graf102";
|
|
5
|
+
import { graf103 } from "./graf103";
|
|
6
|
+
import { graf104 } from "./graf104";
|
|
7
|
+
import { graf105 } from "./graf105";
|
|
8
|
+
import { graf106 } from "./graf106";
|
|
9
|
+
import { graf107 } from "./graf107";
|
|
10
|
+
|
|
11
|
+
export const postSynthChecks: PostSynthCheck[] = [
|
|
12
|
+
graf101,
|
|
13
|
+
graf102,
|
|
14
|
+
graf103,
|
|
15
|
+
graf104,
|
|
16
|
+
graf105,
|
|
17
|
+
graf106,
|
|
18
|
+
graf107,
|
|
19
|
+
];
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { makePostSynthCtxFromFiles, makePostSynthCtx } from "@intentius/chant-test-utils";
|
|
3
|
+
import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
|
|
4
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
5
|
+
import { grafanaSerializer } from "../../serializer";
|
|
6
|
+
import { Datasource } from "../../datasource";
|
|
7
|
+
import { Dashboard } from "../../dashboard";
|
|
8
|
+
import { Row, StatPanel, TimeSeriesPanel, TablePanel } from "../../panels";
|
|
9
|
+
import { PromQuery, TempoQuery } from "../../query";
|
|
10
|
+
import { DatasourceVariable, QueryVariable } from "../../variables";
|
|
11
|
+
import { graf101 } from "./graf101";
|
|
12
|
+
import { graf102 } from "./graf102";
|
|
13
|
+
import { graf103 } from "./graf103";
|
|
14
|
+
import { graf104 } from "./graf104";
|
|
15
|
+
import { graf105 } from "./graf105";
|
|
16
|
+
import { graf106 } from "./graf106";
|
|
17
|
+
import { graf107 } from "./graf107";
|
|
18
|
+
|
|
19
|
+
/** Serialize entities the way a build does and hand the files to the harness. */
|
|
20
|
+
function ctxOf(entities: Record<string, Declarable>) {
|
|
21
|
+
const out = grafanaSerializer.serialize(new Map(Object.entries(entities)));
|
|
22
|
+
if (typeof out === "string") throw new Error("expected files");
|
|
23
|
+
return makePostSynthCtxFromFiles("grafana", out.files!, out.primary, new Map(Object.entries(entities)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A context from hand-written dashboard JSON and datasources, for shapes the typed API can't produce. */
|
|
27
|
+
function ctxOfJson(dashboard: Record<string, unknown>, datasources: Array<Record<string, unknown>> = []) {
|
|
28
|
+
const files: Record<string, string> = { "dashboards/d.json": JSON.stringify(dashboard) };
|
|
29
|
+
if (datasources.length > 0) files["provisioning/datasources/chant.yaml"] = JSON.stringify({ apiVersion: 1, datasources });
|
|
30
|
+
return makePostSynthCtxFromFiles("grafana", files, "{}");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ids(check: PostSynthCheck, ctx: ReturnType<typeof ctxOf>) {
|
|
34
|
+
return check.check(ctx).map((d) => [d.checkId, d.severity]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const prometheus = new Datasource({ name: "Prometheus", type: "prometheus" });
|
|
38
|
+
const tempo = new Datasource({ name: "Tempo", type: "tempo" });
|
|
39
|
+
const up = new PromQuery({ expr: "sum(up)" });
|
|
40
|
+
|
|
41
|
+
function panelJson(extra: Record<string, unknown>) {
|
|
42
|
+
return { type: "stat", id: 1, title: "p", gridPos: { x: 0, y: 0, w: 6, h: 4 }, options: {}, fieldConfig: { defaults: {}, overrides: [] }, ...extra };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function dashboardJson(panels: unknown[], variables: unknown[] = [], extra: Record<string, unknown> = {}) {
|
|
46
|
+
return { uid: "d", title: "D", schemaVersion: 42, annotations: { list: [] }, templating: { list: variables }, panels, ...extra };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("GRAF101: undeclared datasource", () => {
|
|
50
|
+
test("flags a ref to a uid nobody declares", () => {
|
|
51
|
+
const panel = new StatPanel({ datasource: { type: "prometheus", uid: "mimir" }, targets: [up] });
|
|
52
|
+
const diags = graf101.check(ctxOf({ prometheus, d: new Dashboard({ title: "D", panels: [panel] }) }));
|
|
53
|
+
expect(diags.map((d) => d.checkId)).toEqual(["GRAF101", "GRAF101"]);
|
|
54
|
+
expect(diags[0].message).toContain('"mimir"');
|
|
55
|
+
expect(diags[0].severity).toBe("error");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("warns about queries with no datasource at all", () => {
|
|
59
|
+
const diags = graf101.check(ctxOf({ prometheus, d: new Dashboard({ title: "D", panels: [new StatPanel({ targets: [up] })] }) }));
|
|
60
|
+
expect(diags.map((d) => [d.checkId, d.severity])).toEqual([["GRAF101", "warning"]]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("flags a query variable on an undeclared datasource", () => {
|
|
64
|
+
const v = new QueryVariable({ name: "job", datasource: { type: "prometheus", uid: "gone" }, query: "label_values(job)" });
|
|
65
|
+
expect(ids(graf101, ctxOf({ prometheus, d: new Dashboard({ title: "D", variables: [v] }) }))).toEqual([["GRAF101", "error"]]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("passes declared datasources, datasource variables and Grafana's pseudo-datasources", () => {
|
|
69
|
+
const ds = new DatasourceVariable({ name: "ds", pluginType: "prometheus" });
|
|
70
|
+
const mixed = new TablePanel({ targets: [new PromQuery({ expr: "up", datasource: prometheus }), new TempoQuery({ query: "{}", datasource: tempo })] });
|
|
71
|
+
const d = new Dashboard({ title: "D", variables: [ds], panels: [new StatPanel({ datasource: prometheus, targets: [up] }), new StatPanel({ datasource: ds, targets: [up] }), mixed] });
|
|
72
|
+
expect(graf101.check(ctxOf({ prometheus, tempo, d }))).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("is silent when the build declares no datasource (another build root, chant #1939)", () => {
|
|
76
|
+
const panel = new StatPanel({ datasource: { type: "prometheus", uid: "elsewhere" }, targets: [up] });
|
|
77
|
+
expect(graf101.check(ctxOf({ d: new Dashboard({ title: "D", panels: [panel] }) }))).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("GRAF102: datasource type mismatch", () => {
|
|
82
|
+
test("flags a query sent to a datasource of another type", () => {
|
|
83
|
+
const panel = new StatPanel({ datasource: { type: "prometheus", uid: "tempo" }, targets: [up] });
|
|
84
|
+
const diags = graf102.check(ctxOf({ tempo, d: new Dashboard({ title: "D", panels: [panel] }) }));
|
|
85
|
+
expect(diags.length).toBeGreaterThanOrEqual(1);
|
|
86
|
+
expect(diags[0].message).toContain("which is tempo");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("flags a ref through a datasource variable of another plugin type", () => {
|
|
90
|
+
const dash = dashboardJson(
|
|
91
|
+
[panelJson({ datasource: { type: "prometheus", uid: "${ds}" }, targets: [{ refId: "A", expr: "up", datasource: { type: "prometheus", uid: "${ds}" } }] })],
|
|
92
|
+
[{ type: "datasource", name: "ds", query: "loki" }],
|
|
93
|
+
);
|
|
94
|
+
const diags = graf102.check(ctxOfJson(dash, [{ name: "Loki", type: "loki", uid: "loki" }]));
|
|
95
|
+
expect(diags[0].message).toContain('variable "ds" chooses among loki');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("passes matching types", () => {
|
|
99
|
+
expect(graf102.check(ctxOf({ prometheus, d: new Dashboard({ title: "D", panels: [new StatPanel({ datasource: prometheus, targets: [up] })] }) }))).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("GRAF103: undeclared variables", () => {
|
|
104
|
+
test("flags $name, ${name} and [[name]] the dashboard doesn't declare", () => {
|
|
105
|
+
const q = new PromQuery({ expr: 'rate(x{job="$job", env="${env:regex}"}[$__rate_interval])', legendFormat: "[[instance]]" });
|
|
106
|
+
const diags = graf103.check(ctxOf({ d: new Dashboard({ title: "D", panels: [new StatPanel({ title: "for $svc", datasource: prometheus, targets: [q] })] }) }));
|
|
107
|
+
expect(diags.map((d) => d.message.match(/uses \$(\w+)/)![1]).sort()).toEqual(["env", "instance", "job", "svc"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("flags an undeclared repeat variable and variable query reference", () => {
|
|
111
|
+
const v = new QueryVariable({ name: "instance", datasource: prometheus, query: 'label_values(up{job="$job"}, instance)' });
|
|
112
|
+
const diags = graf103.check(ctxOf({ d: new Dashboard({ title: "D", variables: [v], panels: [new StatPanel({ repeat: "pod" })] }) }));
|
|
113
|
+
expect(diags.map((d) => d.message)).toEqual([expect.stringContaining("repeat uses $pod"), expect.stringContaining('variable "instance" query uses $job')]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("passes declared variables and Grafana's built-ins", () => {
|
|
117
|
+
const job = new QueryVariable({ name: "job", datasource: prometheus, query: "label_values(up, job)" });
|
|
118
|
+
const q = new PromQuery({ expr: 'rate(x{job="$job"}[$__rate_interval]) / $__range_s', legendFormat: "{{job}}" });
|
|
119
|
+
const d = new Dashboard({ title: "D", variables: [job], panels: [new StatPanel({ datasource: prometheus, targets: [q], repeat: job })] });
|
|
120
|
+
expect(graf103.check(ctxOf({ d }))).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("GRAF104: duplicates", () => {
|
|
125
|
+
test("flags duplicate dashboard uids, datasource names and panel ids", () => {
|
|
126
|
+
const again = new Datasource({ name: "Prometheus", type: "prometheus", uid: "prom-2" });
|
|
127
|
+
const d1 = new Dashboard({ title: "A", uid: "same", panels: [new StatPanel({ id: 7 }), new StatPanel({ id: 7 })] });
|
|
128
|
+
const d2 = new Dashboard({ title: "B", uid: "same" });
|
|
129
|
+
const messages = graf104.check(ctxOf({ prometheus, again, d1, d2 })).map((d) => d.message);
|
|
130
|
+
expect(messages).toEqual([
|
|
131
|
+
expect.stringContaining('share the uid "same"'),
|
|
132
|
+
expect.stringContaining('share the name "Prometheus"'),
|
|
133
|
+
expect.stringContaining("2 panels with id 7"),
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("flags duplicate refIds and variable names", () => {
|
|
138
|
+
const dash = dashboardJson(
|
|
139
|
+
[panelJson({ targets: [{ refId: "A", expr: "a" }, { refId: "A", expr: "b" }] })],
|
|
140
|
+
[{ type: "textbox", name: "q" }, { type: "textbox", name: "q" }],
|
|
141
|
+
);
|
|
142
|
+
expect(graf104.check(ctxOfJson(dash)).map((d) => d.message)).toEqual([
|
|
143
|
+
expect.stringContaining('variable "q" 2 times'),
|
|
144
|
+
expect.stringContaining('refId "A"'),
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("passes unique ids", () => {
|
|
149
|
+
expect(graf104.check(ctxOf({ prometheus, tempo, a: new Dashboard({ title: "A", panels: [new StatPanel(), new StatPanel()] }), b: new Dashboard({ title: "B" }) }))).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("GRAF105: grid", () => {
|
|
154
|
+
test("flags a panel past column 24 as an error and an overlap as a warning", () => {
|
|
155
|
+
const d = new Dashboard({
|
|
156
|
+
title: "D",
|
|
157
|
+
panels: [
|
|
158
|
+
new StatPanel({ title: "wide", gridPos: { x: 20, y: 0, w: 8, h: 4 } }),
|
|
159
|
+
new TimeSeriesPanel({ title: "a", gridPos: { x: 0, y: 10, w: 12, h: 8 } }),
|
|
160
|
+
new TimeSeriesPanel({ title: "b", gridPos: { x: 6, y: 12, w: 12, h: 8 } }),
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
expect(ids(graf105, ctxOf({ d }))).toEqual([
|
|
164
|
+
["GRAF105", "error"],
|
|
165
|
+
["GRAF105", "warning"],
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("compares a collapsed row's panels with each other only", () => {
|
|
170
|
+
const d = new Dashboard({
|
|
171
|
+
title: "D",
|
|
172
|
+
panels: [new StatPanel({ title: "top" }), new Row({ title: "r", collapsed: true, panels: [new StatPanel({ gridPos: { x: 0, y: 0, w: 6, h: 4 } })] })],
|
|
173
|
+
});
|
|
174
|
+
expect(graf105.check(ctxOf({ d }))).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("passes a dashboard laid out by chant", () => {
|
|
178
|
+
const panels = [new StatPanel(), new TimeSeriesPanel(), new Row({ title: "r", panels: [new TablePanel(), new TablePanel(), new TablePanel()] })];
|
|
179
|
+
expect(graf105.check(ctxOf({ d: new Dashboard({ title: "D", panels }) }))).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("GRAF106: uids and titles", () => {
|
|
184
|
+
test("flags an over-long uid, a bad datasource uid and an empty title", () => {
|
|
185
|
+
const bad = new Datasource({ name: "Bad", type: "prometheus", uid: "has space" });
|
|
186
|
+
const d = new Dashboard({ title: "", uid: "x".repeat(41) });
|
|
187
|
+
expect(graf106.check(ctxOf({ bad, d })).map((x) => x.message)).toEqual([
|
|
188
|
+
expect.stringContaining("has uid"),
|
|
189
|
+
expect.stringContaining("has no title"),
|
|
190
|
+
expect.stringContaining('Datasource "Bad"'),
|
|
191
|
+
]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("passes valid uids", () => {
|
|
195
|
+
expect(graf106.check(ctxOf({ prometheus, d: new Dashboard({ title: "Fine", uid: "svc_overview-1" }) }))).toEqual([]);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("GRAF107: the pinned schema", () => {
|
|
200
|
+
test("flags an unknown dashboard key, a bad panel option and an unknown query field", () => {
|
|
201
|
+
const dash = dashboardJson(
|
|
202
|
+
[panelJson({ options: { graphMode: "sparkline" }, datasource: { type: "prometheus", uid: "p" }, targets: [{ refId: "A", expr: "up", exprr: "typo" }] })],
|
|
203
|
+
[],
|
|
204
|
+
{ owner: "team-a" },
|
|
205
|
+
);
|
|
206
|
+
const messages = graf107.check(ctxOfJson(dash)).map((d) => d.message);
|
|
207
|
+
expect(messages).toEqual([
|
|
208
|
+
expect.stringContaining('must NOT have additional properties ("owner")'),
|
|
209
|
+
expect.stringContaining("/panels/0/options/graphMode: must be equal to one of the allowed values"),
|
|
210
|
+
expect.stringContaining('/panels/0/targets/0: must NOT have additional properties ("exprr")'),
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("flags a row and a panel missing what the schema requires", () => {
|
|
215
|
+
const dash = dashboardJson([{ type: "row", title: "r" }, { title: "no type" }]);
|
|
216
|
+
const messages = graf107.check(ctxOfJson(dash)).map((d) => d.message);
|
|
217
|
+
expect(messages.some((m) => m.includes("/panels/0: must have required property 'collapsed'"))).toBe(true);
|
|
218
|
+
expect(messages.some((m) => m.includes("/panels/1: must have required property 'type'"))).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("passes everything the typed API emits", () => {
|
|
222
|
+
const d = new Dashboard({ title: "D", panels: [new StatPanel({ datasource: prometheus, targets: [up], options: { graphMode: "none" } })] });
|
|
223
|
+
expect(graf107.check(ctxOf({ prometheus, d }))).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("checks a dashboard in another lexicon's output too", () => {
|
|
227
|
+
const dash = dashboardJson([panelJson({ options: { colorMode: "rainbow" } })]);
|
|
228
|
+
const ctx = makePostSynthCtx("k8s", JSON.stringify(dash));
|
|
229
|
+
expect(graf107.check(ctx).map((d) => d.checkId)).toEqual(["GRAF107"]);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
|
|
3
|
+
/** The constructor name of a `new X(...)` or `X(...)` expression, if it is a plain or dotted name. */
|
|
4
|
+
export function calleeName(node: ts.CallExpression | ts.NewExpression): string | undefined {
|
|
5
|
+
const callee = node.expression;
|
|
6
|
+
if (ts.isIdentifier(callee)) return callee.text;
|
|
7
|
+
if (ts.isPropertyAccessExpression(callee)) return callee.name.text;
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A property's key as text, for identifier and string-literal keys. */
|
|
12
|
+
export function propertyName(prop: ts.ObjectLiteralElementLike): string | undefined {
|
|
13
|
+
if (!ts.isPropertyAssignment(prop)) return undefined;
|
|
14
|
+
if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) return prop.name.text;
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The text of a string literal or substitution-free template, else undefined. */
|
|
19
|
+
export function literalText(node: ts.Expression): string | undefined {
|
|
20
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function position(source: ts.SourceFile, node: ts.Node): { line: number; column: number } {
|
|
25
|
+
const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source));
|
|
26
|
+
return { line: line + 1, column: character + 1 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `const name = <init>` declarations in the file, so a value written as a named const can be followed. */
|
|
30
|
+
export function constInitializers(source: ts.SourceFile): Map<string, ts.Expression> {
|
|
31
|
+
const out = new Map<string, ts.Expression>();
|
|
32
|
+
const visit = (node: ts.Node) => {
|
|
33
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) out.set(node.name.text, node.initializer);
|
|
34
|
+
ts.forEachChild(node, visit);
|
|
35
|
+
};
|
|
36
|
+
visit(source);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Follow an identifier to the object literal it was declared as, when it is one in this file. */
|
|
41
|
+
export function resolveObject(node: ts.Expression, consts: Map<string, ts.Expression>, depth = 0): ts.ObjectLiteralExpression | undefined {
|
|
42
|
+
let n: ts.Expression = node;
|
|
43
|
+
while (ts.isAsExpression(n) || ts.isSatisfiesExpression(n) || ts.isParenthesizedExpression(n)) n = n.expression;
|
|
44
|
+
if (ts.isObjectLiteralExpression(n)) return n;
|
|
45
|
+
if (ts.isIdentifier(n) && depth < 5) {
|
|
46
|
+
const init = consts.get(n.text);
|
|
47
|
+
if (init) return resolveObject(init, consts, depth + 1);
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The first object-literal argument of every `new <Name>(...)` or `<Name>(...)` for the names given. */
|
|
53
|
+
export function constructorArgs(
|
|
54
|
+
source: ts.SourceFile,
|
|
55
|
+
names: (name: string) => boolean,
|
|
56
|
+
): Array<{ name: string; arg: ts.ObjectLiteralExpression }> {
|
|
57
|
+
const consts = constInitializers(source);
|
|
58
|
+
const out: Array<{ name: string; arg: ts.ObjectLiteralExpression }> = [];
|
|
59
|
+
const visit = (node: ts.Node) => {
|
|
60
|
+
if (ts.isNewExpression(node) || ts.isCallExpression(node)) {
|
|
61
|
+
const name = calleeName(node);
|
|
62
|
+
const first = node.arguments?.[0];
|
|
63
|
+
const arg = first ? resolveObject(first, consts) : undefined;
|
|
64
|
+
if (name && arg && names(name)) out.push({ name, arg });
|
|
65
|
+
}
|
|
66
|
+
ts.forEachChild(node, visit);
|
|
67
|
+
};
|
|
68
|
+
visit(source);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { LintRule } from "@intentius/chant/lint/rule";
|
|
2
|
+
import { uidSyntaxRule } from "./uid-syntax";
|
|
3
|
+
import { literalSecretRule } from "./literal-secret";
|
|
4
|
+
|
|
5
|
+
export { uidSyntaxRule } from "./uid-syntax";
|
|
6
|
+
export { literalSecretRule } from "./literal-secret";
|
|
7
|
+
|
|
8
|
+
/** All lint rules provided by this lexicon (imported by plugin.ts's lintRules()). */
|
|
9
|
+
export const rules: LintRule[] = [uidSyntaxRule, literalSecretRule];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
3
|
+
import { constInitializers, constructorArgs, literalText, position, propertyName, resolveObject } from "./grafana-ast";
|
|
4
|
+
|
|
5
|
+
/** How Grafana provisioning reads a value from outside the file: `$VAR`, `${VAR}`, `$__env{…}`, `$__file{…}`, `$__vault{…}`. */
|
|
6
|
+
const EXPANDED = /\$(\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*|__(env|file|vault)\{[^}]+\})/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GRAF002: a datasource secret written as a literal.
|
|
10
|
+
*
|
|
11
|
+
* `secureJsonData` holds passwords, tokens and TLS keys. A literal there is a
|
|
12
|
+
* secret in the repository and in the emitted provisioning file. Grafana
|
|
13
|
+
* expands `$__env{NAME}`, `$__file{/path}` and `${NAME}` when it reads the
|
|
14
|
+
* file, so the declaration never needs the value. A value written as a
|
|
15
|
+
* named `const` in the same file is followed.
|
|
16
|
+
*/
|
|
17
|
+
export const literalSecretRule: LintRule = {
|
|
18
|
+
id: "GRAF002",
|
|
19
|
+
severity: "error",
|
|
20
|
+
category: "security",
|
|
21
|
+
description: "Datasource secureJsonData value declared as a literal instead of $__env{...} or $__file{...}",
|
|
22
|
+
|
|
23
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
24
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
25
|
+
const source = context.sourceFile;
|
|
26
|
+
const consts = constInitializers(source);
|
|
27
|
+
for (const { arg } of constructorArgs(source, (n) => n === "Datasource")) {
|
|
28
|
+
for (const prop of arg.properties) {
|
|
29
|
+
if (propertyName(prop) !== "secureJsonData") continue;
|
|
30
|
+
const secure = resolveObject((prop as ts.PropertyAssignment).initializer, consts);
|
|
31
|
+
if (!secure) continue;
|
|
32
|
+
for (const entry of secure.properties) {
|
|
33
|
+
if (!ts.isPropertyAssignment(entry)) continue;
|
|
34
|
+
const text = literalText(entry.initializer);
|
|
35
|
+
if (text === undefined || text === "" || EXPANDED.test(text)) continue;
|
|
36
|
+
diagnostics.push({
|
|
37
|
+
ruleId: "GRAF002",
|
|
38
|
+
severity: "error",
|
|
39
|
+
message: `secureJsonData.${propertyName(entry) ?? "?"} is a literal secret; write "$__env{NAME}" or "$__file{/path}" and let Grafana read it`,
|
|
40
|
+
file: context.filePath,
|
|
41
|
+
...position(source, entry.initializer),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return diagnostics;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
import type { LintContext } from "@intentius/chant/lint/rule";
|
|
4
|
+
import { uidSyntaxRule } from "./uid-syntax";
|
|
5
|
+
import { literalSecretRule } from "./literal-secret";
|
|
6
|
+
|
|
7
|
+
function ctx(code: string): LintContext {
|
|
8
|
+
const sourceFile = ts.createSourceFile("dashboard.ts", code, ts.ScriptTarget.Latest, true);
|
|
9
|
+
return { sourceFile, entities: [], filePath: "dashboard.ts" };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("GRAF001 uid and variable-name syntax", () => {
|
|
13
|
+
test("flags an over-long or ill-formed uid and an unusable variable name", () => {
|
|
14
|
+
const diags = uidSyntaxRule.check(
|
|
15
|
+
ctx(`
|
|
16
|
+
new Dashboard({ title: "x", uid: "${"a".repeat(41)}" });
|
|
17
|
+
new Datasource({ name: "P", type: "prometheus", uid: "has space" });
|
|
18
|
+
new QueryVariable({ name: "my-var", datasource: p, query: "q" });
|
|
19
|
+
new CustomVariable({ name: "1st", values: [] });
|
|
20
|
+
`),
|
|
21
|
+
);
|
|
22
|
+
expect(diags.map((d) => [d.ruleId, d.line])).toEqual([
|
|
23
|
+
["GRAF001", 2],
|
|
24
|
+
["GRAF001", 3],
|
|
25
|
+
["GRAF001", 4],
|
|
26
|
+
["GRAF001", 5],
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("follows a props object declared as a const", () => {
|
|
31
|
+
const diags = uidSyntaxRule.check(ctx(`const props = { title: "x", uid: "bad/uid" };\nnew Dashboard(props);`));
|
|
32
|
+
expect(diags).toHaveLength(1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("accepts valid values, non-literals and other constructors", () => {
|
|
36
|
+
const diags = uidSyntaxRule.check(
|
|
37
|
+
ctx(`
|
|
38
|
+
new Dashboard({ title: "x", uid: "svc-overview_1" });
|
|
39
|
+
new Dashboard({ title: "x", uid: someUid });
|
|
40
|
+
new TextboxVariable({ name: "trace_id" });
|
|
41
|
+
new Deployment({ uid: "not grafana at all" });
|
|
42
|
+
`),
|
|
43
|
+
);
|
|
44
|
+
expect(diags).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("GRAF002 literal secret", () => {
|
|
49
|
+
test("flags a literal in secureJsonData, inline or through a const", () => {
|
|
50
|
+
const diags = literalSecretRule.check(
|
|
51
|
+
ctx(`
|
|
52
|
+
new Datasource({ name: "P", type: "prometheus", secureJsonData: { basicAuthPassword: "hunter2" } });
|
|
53
|
+
const secrets = { httpHeaderValue1: "Bearer abc" };
|
|
54
|
+
new Datasource({ name: "T", type: "tempo", secureJsonData: secrets });
|
|
55
|
+
`),
|
|
56
|
+
);
|
|
57
|
+
expect(diags.map((d) => d.ruleId)).toEqual(["GRAF002", "GRAF002"]);
|
|
58
|
+
expect(diags[0].message).toContain("basicAuthPassword");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("accepts values Grafana expands and other constructors", () => {
|
|
62
|
+
const diags = literalSecretRule.check(
|
|
63
|
+
ctx(`
|
|
64
|
+
new Datasource({ name: "P", type: "prometheus", secureJsonData: { a: "$__env{PROM_PASSWORD}", b: "$__file{/run/secrets/token}", c: "\${TOKEN}", d: "$TOKEN" } });
|
|
65
|
+
new Secret({ secureJsonData: { password: "not ours" } });
|
|
66
|
+
`),
|
|
67
|
+
);
|
|
68
|
+
expect(diags).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type * as ts from "typescript";
|
|
2
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
3
|
+
import { UID_PATTERN } from "../../util";
|
|
4
|
+
import { VARIABLE_NAME } from "../../variables";
|
|
5
|
+
import { constructorArgs, literalText, position, propertyName } from "./grafana-ast";
|
|
6
|
+
|
|
7
|
+
const UID_OWNERS = new Set(["Dashboard", "Datasource"]);
|
|
8
|
+
const VARIABLE_CLASS = /Variable$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GRAF001: a uid Grafana rejects, or a variable name queries cannot reference.
|
|
12
|
+
*
|
|
13
|
+
* A literal `uid` on a `Dashboard` or `Datasource` must be 1-40 letters,
|
|
14
|
+
* digits, `-` and `_`; Grafana refuses anything else on import. A literal
|
|
15
|
+
* `name` on a `*Variable` must be letters, digits and `_`, starting with a
|
|
16
|
+
* letter or `_`, or `$name` in a query cannot refer to it. Caught in source,
|
|
17
|
+
* before a build; GRAF106 checks the emitted uids too.
|
|
18
|
+
*/
|
|
19
|
+
export const uidSyntaxRule: LintRule = {
|
|
20
|
+
id: "GRAF001",
|
|
21
|
+
severity: "error",
|
|
22
|
+
category: "correctness",
|
|
23
|
+
description: "Dashboard or datasource uid Grafana rejects, or a variable name queries can't reference",
|
|
24
|
+
|
|
25
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
26
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
27
|
+
const source = context.sourceFile;
|
|
28
|
+
for (const { name, arg } of constructorArgs(source, (n) => UID_OWNERS.has(n) || VARIABLE_CLASS.test(n))) {
|
|
29
|
+
for (const prop of arg.properties) {
|
|
30
|
+
const key = propertyName(prop);
|
|
31
|
+
if (!key) continue;
|
|
32
|
+
const text = literalText((prop as ts.PropertyAssignment).initializer);
|
|
33
|
+
if (text === undefined) continue;
|
|
34
|
+
if (UID_OWNERS.has(name) && key === "uid" && !UID_PATTERN.test(text)) {
|
|
35
|
+
diagnostics.push({
|
|
36
|
+
ruleId: "GRAF001",
|
|
37
|
+
severity: "error",
|
|
38
|
+
message: `${name} uid "${text}" is not a Grafana uid; use 1-40 letters, digits, "-" and "_"`,
|
|
39
|
+
file: context.filePath,
|
|
40
|
+
...position(source, prop),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (VARIABLE_CLASS.test(name) && key === "name" && !VARIABLE_NAME.test(text)) {
|
|
44
|
+
diagnostics.push({
|
|
45
|
+
ruleId: "GRAF001",
|
|
46
|
+
severity: "error",
|
|
47
|
+
message: `variable name "${text}" can't be referenced as $name; use letters, digits and "_", not starting with a digit`,
|
|
48
|
+
file: context.filePath,
|
|
49
|
+
...position(source, prop),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return diagnostics;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { completions } from "./completions";
|
|
3
|
+
|
|
4
|
+
describe("grafana LSP completions", () => {
|
|
5
|
+
it("offers the panel classes after new", () => {
|
|
6
|
+
const content = 'import { TimeSeriesPanel } from "@intentius/chant-lexicon-grafana";\nconst p = new Time';
|
|
7
|
+
const items = completions({
|
|
8
|
+
uri: "file:///dashboard.ts",
|
|
9
|
+
content,
|
|
10
|
+
position: { line: 1, character: content.split("\n")[1].length },
|
|
11
|
+
wordAtCursor: "Time",
|
|
12
|
+
linePrefix: "const p = new Time",
|
|
13
|
+
});
|
|
14
|
+
expect(items.map((i) => i.label)).toContain("TimeSeriesPanel");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("offers the query classes by prefix", () => {
|
|
18
|
+
const items = completions({
|
|
19
|
+
uri: "file:///q.ts",
|
|
20
|
+
content: "new Prom",
|
|
21
|
+
position: { line: 0, character: 8 },
|
|
22
|
+
wordAtCursor: "Prom",
|
|
23
|
+
linePrefix: "new Prom",
|
|
24
|
+
});
|
|
25
|
+
expect(items.map((i) => i.label)).toContain("PromQuery");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns an array for an empty context", () => {
|
|
29
|
+
expect(Array.isArray(completions({} as never))).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CompletionContext, CompletionItem } from "@intentius/chant/lsp/types";
|
|
2
|
+
import { LexiconIndex, lexiconCompletions } from "@intentius/chant/lsp/lexicon-providers";
|
|
3
|
+
import { lexiconRegistry } from "../catalog";
|
|
4
|
+
|
|
5
|
+
let cachedIndex: LexiconIndex | null = null;
|
|
6
|
+
|
|
7
|
+
function getIndex(): LexiconIndex {
|
|
8
|
+
cachedIndex ??= new LexiconIndex(lexiconRegistry());
|
|
9
|
+
return cachedIndex;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Completions for the grafana entity classes: dashboards, datasources, panels, queries and variables. */
|
|
13
|
+
export function completions(ctx: CompletionContext): CompletionItem[] {
|
|
14
|
+
return lexiconCompletions(ctx, getIndex(), "Grafana entity");
|
|
15
|
+
}
|