@intentius/chant-lexicon-otel 0.124.30 → 0.124.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/attribution.js +391 -0
  2. package/dist/catalog.js +42 -0
  3. package/dist/codegen/docs-cli.js +3 -0
  4. package/dist/codegen/docs.js +101 -0
  5. package/dist/codegen/generate-cli.js +7 -0
  6. package/dist/codegen/generate.js +43 -0
  7. package/dist/codegen/package.js +28 -0
  8. package/dist/collector.js +150 -0
  9. package/dist/components/common.js +50 -0
  10. package/dist/components/connectors.js +242 -0
  11. package/dist/components/exporters.js +57 -0
  12. package/dist/components/extensions.js +58 -0
  13. package/dist/components/filtering.js +106 -0
  14. package/dist/components/index.js +9 -0
  15. package/dist/components/k8s-receivers.js +36 -0
  16. package/dist/components/processors.js +103 -0
  17. package/dist/components/receivers.js +61 -0
  18. package/dist/components/sampling.js +257 -0
  19. package/dist/composites/catalog.js +368 -0
  20. package/dist/composites/genai-pipeline.js +28 -0
  21. package/dist/composites/index.js +8 -0
  22. package/dist/composites/node-agent.js +156 -0
  23. package/dist/composites/otlp-collector.js +57 -0
  24. package/dist/composites/red-metrics.js +95 -0
  25. package/dist/composites/tail-sampling-tier.js +132 -0
  26. package/dist/config-hygiene.js +464 -0
  27. package/dist/configmap.js +86 -0
  28. package/dist/define.js +204 -0
  29. package/dist/detect.js +22 -0
  30. package/dist/genai.js +569 -0
  31. package/dist/import/embedded.js +57 -0
  32. package/dist/import/generator.js +459 -0
  33. package/dist/import/parser.js +199 -0
  34. package/dist/index.js +34 -0
  35. package/dist/init-templates.js +119 -0
  36. package/dist/integrity.json +2 -2
  37. package/dist/lint/audit-catalog.js +49 -0
  38. package/dist/lint/post-synth/index.js +52 -0
  39. package/dist/lint/post-synth/otel-helpers.js +103 -0
  40. package/dist/lint/post-synth/otel101.js +13 -0
  41. package/dist/lint/post-synth/otel102.js +13 -0
  42. package/dist/lint/post-synth/otel103.js +13 -0
  43. package/dist/lint/post-synth/otel104.js +13 -0
  44. package/dist/lint/post-synth/otel105.js +13 -0
  45. package/dist/lint/post-synth/otel106.js +13 -0
  46. package/dist/lint/post-synth/otel107.js +13 -0
  47. package/dist/lint/post-synth/otel108.js +13 -0
  48. package/dist/lint/post-synth/otel109.js +13 -0
  49. package/dist/lint/post-synth/otel112.js +13 -0
  50. package/dist/lint/post-synth/otel113.js +13 -0
  51. package/dist/lint/post-synth/otel114.js +13 -0
  52. package/dist/lint/post-synth/otel115.js +13 -0
  53. package/dist/lint/post-synth/otel116.js +13 -0
  54. package/dist/lint/post-synth/otel117.js +13 -0
  55. package/dist/lint/post-synth/otel118.js +13 -0
  56. package/dist/lint/post-synth/otel119.js +13 -0
  57. package/dist/lint/post-synth/otel120.js +13 -0
  58. package/dist/lint/post-synth/otel121.js +13 -0
  59. package/dist/lint/post-synth/otel122.js +13 -0
  60. package/dist/lint/post-synth/otel123.js +13 -0
  61. package/dist/lint/post-synth/otel124.js +13 -0
  62. package/dist/lint/post-synth/otel125.js +13 -0
  63. package/dist/lint/post-synth/otel126.js +13 -0
  64. package/dist/lint/post-synth/otel127.js +13 -0
  65. package/dist/lint/rules/component-id-syntax.js +66 -0
  66. package/dist/lint/rules/index.js +6 -0
  67. package/dist/lint/rules/literal-credential.js +91 -0
  68. package/dist/lint/rules/otel-ast.js +30 -0
  69. package/dist/lsp/completions.js +11 -0
  70. package/dist/lsp/hover.js +23 -0
  71. package/dist/manifest.json +1 -1
  72. package/dist/metric-names.js +244 -0
  73. package/dist/model.js +90 -0
  74. package/dist/op/activities/collector-audit.js +213 -0
  75. package/dist/op/activities/collector-health.js +176 -0
  76. package/dist/op/activities/exec.js +16 -0
  77. package/dist/op/activities/github.js +110 -0
  78. package/dist/op/activities/index.js +16 -0
  79. package/dist/op/activities/otelcol.js +173 -0
  80. package/dist/op/activity-contracts.js +64 -0
  81. package/dist/op/audit-op.js +33 -0
  82. package/dist/op/builders.js +38 -0
  83. package/dist/package-cli.js +14 -0
  84. package/dist/pipeline.js +31 -0
  85. package/dist/platform.js +144 -0
  86. package/dist/plugin.js +126 -0
  87. package/dist/semconv.js +50 -0
  88. package/dist/serializer.js +26 -0
  89. package/dist/skill-defs.js +34 -0
  90. package/dist/topology.js +104 -0
  91. package/dist/validate-cli.js +7 -0
  92. package/dist/validate-config.js +706 -0
  93. package/dist/validate.js +85 -0
  94. package/dist/yaml.js +157 -0
  95. package/package.json +8 -8
  96. package/src/codegen/package.ts +2 -2
package/dist/model.js ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The plain-data model of an OpenTelemetry Collector config.
3
+ *
4
+ * Everything else in this lexicon reads or writes this shape: the serializer
5
+ * builds it from declared entities, the YAML emitter prints it, the checks
6
+ * validate it, and `collectorTopology()` reads it back for other tools. It is
7
+ * exactly the collector's own top-level layout, so a parsed collector YAML
8
+ * file is already a `CollectorConfig` and needs no conversion.
9
+ */
10
+ /** The three signals a collector pipeline can carry. */
11
+ export const SIGNALS = ["traces", "metrics", "logs"];
12
+ /**
13
+ * The component kinds this lexicon types. A connector is an exporter in one
14
+ * pipeline and a receiver in another, which is how one pipeline feeds another
15
+ * (traces into span metrics, say).
16
+ */
17
+ export const COMPONENT_KINDS = ["receiver", "processor", "exporter", "connector", "extension"];
18
+ /** The top-level config section each component kind lives under. */
19
+ export const SECTION_OF = {
20
+ receiver: "receivers",
21
+ processor: "processors",
22
+ exporter: "exporters",
23
+ connector: "connectors",
24
+ extension: "extensions",
25
+ };
26
+ /** The collector's id grammar: a type, then optionally `/` and a non-empty name. */
27
+ const ID_PATTERN = /^([A-Za-z0-9_]+)(?:\/([^\s/][^\s]*))?$/;
28
+ /** Build a component id from a type and an optional instance name. */
29
+ export function componentId(type, name) {
30
+ return name === undefined || name === "" ? type : `${type}/${name}`;
31
+ }
32
+ /** Split a component id into its type and name, or `undefined` when it isn't valid id syntax. */
33
+ export function parseComponentId(id) {
34
+ const m = ID_PATTERN.exec(id);
35
+ if (!m)
36
+ return undefined;
37
+ return m[2] === undefined ? { type: m[1] } : { type: m[1], name: m[2] };
38
+ }
39
+ /** True when `id` is a valid `type[/name]` component id. */
40
+ export function isComponentId(id) {
41
+ return ID_PATTERN.test(id);
42
+ }
43
+ /** The signal a pipeline id names, e.g. `traces` for `traces/backend`. */
44
+ export function pipelineSignal(pipelineId) {
45
+ const slash = pipelineId.indexOf("/");
46
+ return slash === -1 ? pipelineId : pipelineId.slice(0, slash);
47
+ }
48
+ /** True when a parsed document has the shape of a collector config. */
49
+ export function looksLikeCollectorConfig(value) {
50
+ if (typeof value !== "object" || value === null || Array.isArray(value))
51
+ return false;
52
+ const v = value;
53
+ const service = v.service;
54
+ if (typeof service !== "object" || service === null)
55
+ return false;
56
+ const pipelines = service.pipelines;
57
+ if (typeof pipelines !== "object" || pipelines === null)
58
+ return false;
59
+ return "receivers" in v || "exporters" in v;
60
+ }
61
+ /**
62
+ * The renamed built-ins. chant emits `builtin`, which the pinned collector
63
+ * (`COLLECTOR_PIN`, v0.130.0) knows and newer ones still accept. A config
64
+ * written for a newer collector may use `type`, so code that reads a config
65
+ * (the importer, the checks, the k8s composites) accepts both.
66
+ */
67
+ export const COMPONENT_TYPE_ALIASES = [
68
+ { kind: "exporter", type: "otlp_grpc", builtin: "otlp", since: "v0.148.0", repo: "core" },
69
+ { kind: "exporter", type: "otlp_http", builtin: "otlphttp", since: "v0.148.0", repo: "core" },
70
+ { kind: "processor", type: "k8s_attributes", builtin: "k8sattributes", since: "v0.148.0", repo: "contrib" },
71
+ { kind: "connector", type: "signal_to_metrics", builtin: "signaltometrics", since: "v0.148.0", repo: "contrib" },
72
+ { kind: "receiver", type: "file_log", builtin: "filelog", since: "v0.149.0", repo: "contrib" },
73
+ { kind: "connector", type: "span_metrics", builtin: "spanmetrics", since: "v0.151.0", repo: "contrib" },
74
+ { kind: "connector", type: "service_graph", builtin: "servicegraph", since: "v0.151.0", repo: "contrib" },
75
+ { kind: "receiver", type: "host_metrics", builtin: "hostmetrics", since: "v0.151.0", repo: "contrib" },
76
+ { kind: "receiver", type: "kubelet_stats", builtin: "kubeletstats", since: "v0.152.0", repo: "contrib" },
77
+ { kind: "processor", type: "resource_detection", builtin: "resourcedetection", since: "v0.153.0", repo: "contrib" },
78
+ { kind: "exporter", type: "load_balancing", builtin: "loadbalancing", since: "v0.153.0", repo: "contrib" },
79
+ { kind: "processor", type: "delta_to_cumulative", builtin: "deltatocumulative", since: "v0.158.0", repo: "contrib" },
80
+ ];
81
+ const ALIAS_TO_BUILTIN = new Map(COMPONENT_TYPE_ALIASES.map((a) => [`${a.kind}:${a.type}`, a.builtin]));
82
+ /** The type chant declares for `kind` + `type`: the old name of a renamed built-in, otherwise `type` itself. */
83
+ export function canonicalComponentType(kind, type) {
84
+ return ALIAS_TO_BUILTIN.get(`${kind}:${type}`) ?? type;
85
+ }
86
+ /** The type of component id `id`, with a renamed built-in's new name read as the old one: `span_metrics/genai` is `spanmetrics`. */
87
+ export function canonicalTypeOf(kind, id) {
88
+ const slash = id.indexOf("/");
89
+ return canonicalComponentType(kind, slash === -1 ? id : id.slice(0, slash));
90
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * `collectorAudit`: the otel lexicon's pins against what upstream has
3
+ * released since (#3369), the activity behind `CollectorAuditOp`.
4
+ *
5
+ * Three readings, each against a moving upstream:
6
+ *
7
+ * - the contrib release list against `COLLECTOR_PIN`: how many releases
8
+ * the pin is behind, and the newest one.
9
+ * - component stability from each built-in component's `metadata.yaml`, at
10
+ * the pin and at the newest release: a signal whose stability level
11
+ * changed between the two, and any signal that is `deprecated` or
12
+ * `unmaintained` at the newest release. A component's file is looked up
13
+ * in contrib, then in core, at `<kind>/<type><kind>/metadata.yaml`; one
14
+ * found in neither is listed as unchecked, never as a finding.
15
+ * - `GENAI_SEMCONV_PIN` against the semantic-conventions releases.
16
+ *
17
+ * `mode` says what to do with the findings: `report` returns them;
18
+ * `issue` keeps one open issue current; `pull-request` also bumps the pins
19
+ * in the lexicon's `src/define.ts` on a proposal branch and opens (or
20
+ * edits) a pull request with the findings as its body. Only the pin values
21
+ * change: the config types written against the old pin are the reviewer's
22
+ * to move, which the body says.
23
+ */
24
+ import { mkdtempSync, readFileSync } from "node:fs";
25
+ import { tmpdir } from "node:os";
26
+ import { join, relative, resolve } from "node:path";
27
+ import { load } from "js-yaml";
28
+ import { COLLECTOR_PIN, GENAI_SEMCONV_PIN } from "../../define.js";
29
+ import * as components from "../../components/index.js";
30
+ import { compareSemver, defaultRunner, fetchReleases, proposePullRequest, stickyIssue } from "./github.js";
31
+ export const CONTRIB_REPO = "open-telemetry/opentelemetry-collector-contrib";
32
+ export const CORE_REPO = "open-telemetry/opentelemetry-collector";
33
+ export const SEMCONV_REPO = "open-telemetry/semantic-conventions";
34
+ export function builtinComponents() {
35
+ const out = [];
36
+ const seen = new Set();
37
+ for (const value of Object.values(components)) {
38
+ if (typeof value !== "function")
39
+ continue;
40
+ const def = value.definition;
41
+ if (!def || !def.builtin || typeof def.type !== "string")
42
+ continue;
43
+ const key = `${def.kind}/${def.type}`;
44
+ if (seen.has(key))
45
+ continue;
46
+ seen.add(key);
47
+ out.push({ kind: def.kind, type: def.type });
48
+ }
49
+ return out.sort((a, b) => `${a.kind}/${a.type}`.localeCompare(`${b.kind}/${b.type}`));
50
+ }
51
+ /** The directory a component lives in upstream: `receiver/k8s_cluster` is `receiver/k8sclusterreceiver`. */
52
+ export function componentDir(c) {
53
+ return `${c.kind}/${c.type.replace(/_/g, "")}${c.kind}`;
54
+ }
55
+ export function parseStability(text) {
56
+ let doc;
57
+ try {
58
+ doc = load(text);
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ const status = doc?.status;
64
+ const stability = status?.stability;
65
+ if (!stability || typeof stability !== "object")
66
+ return undefined;
67
+ const out = {};
68
+ for (const [level, signals] of Object.entries(stability)) {
69
+ for (const s of Array.isArray(signals) ? signals : [])
70
+ if (typeof s === "string")
71
+ out[s] = level;
72
+ }
73
+ return out;
74
+ }
75
+ async function fetchText(f, url) {
76
+ const res = await f(url);
77
+ if (res.status === 404)
78
+ return undefined;
79
+ if (!res.ok)
80
+ throw new Error(`${url}: HTTP ${res.status}`);
81
+ return res.text();
82
+ }
83
+ /** A component's stability at `tag`, from contrib or core; undefined when neither has its metadata.yaml. */
84
+ async function stabilityAt(f, c, tag, budget) {
85
+ for (const repo of [CONTRIB_REPO, CORE_REPO]) {
86
+ if (budget.left <= 0)
87
+ return undefined;
88
+ budget.left--;
89
+ const text = await fetchText(f, `https://raw.githubusercontent.com/${repo}/${tag}/${componentDir(c)}/metadata.yaml`);
90
+ if (text !== undefined)
91
+ return parseStability(text);
92
+ }
93
+ return undefined;
94
+ }
95
+ const RETIRED = new Set(["deprecated", "unmaintained"]);
96
+ /** Findings for one component, from its stability at the pin and at the newest release. */
97
+ export function stabilityFindings(c, atPin, atLatest, pin, latest) {
98
+ const subject = `${c.kind}/${c.type}`;
99
+ const out = [];
100
+ const retired = Object.entries(atLatest).filter(([, level]) => RETIRED.has(level));
101
+ if (retired.length > 0) {
102
+ out.push({ kind: "deprecated", subject, detail: `${retired.map(([s, l]) => `${s} ${l}`).join(", ")} at ${latest}` });
103
+ }
104
+ if (atPin && pin !== latest) {
105
+ const changed = [...new Set([...Object.keys(atPin), ...Object.keys(atLatest)])]
106
+ .filter((s) => atPin[s] !== atLatest[s] && !RETIRED.has(atLatest[s] ?? ""))
107
+ .map((s) => `${s} ${atPin[s] ?? "absent"} to ${atLatest[s] ?? "absent"}`);
108
+ if (changed.length > 0)
109
+ out.push({ kind: "stability-changed", subject, detail: `${changed.join(", ")} (${pin} to ${latest})` });
110
+ }
111
+ return out;
112
+ }
113
+ function behindCount(releases, pin) {
114
+ return releases.filter((r) => compareSemver(r.tag, pin) > 0).length;
115
+ }
116
+ /** `src/define.ts` with `COLLECTOR_PIN` and `GENAI_SEMCONV_PIN` moved to the given versions. */
117
+ export function bumpPins(text, to) {
118
+ let out = text;
119
+ const bump = (name, version) => {
120
+ const re = new RegExp(`(export const ${name}: SchemaPin = Object\\.freeze\\(\\{[^}]*?version:\\s*")([^"]+)(")`);
121
+ if (!re.test(out))
122
+ throw new Error(`define.ts has no ${name} with a version to bump`);
123
+ out = out.replace(re, `$1${version}$3`);
124
+ };
125
+ if (to.collector)
126
+ bump("COLLECTOR_PIN", to.collector);
127
+ if (to.genai)
128
+ bump("GENAI_SEMCONV_PIN", to.genai);
129
+ return out;
130
+ }
131
+ export function renderCollectorAuditSummary(r) {
132
+ let out = "## Collector audit\n\n";
133
+ out += `- COLLECTOR_PIN ${r.collector.pin}: ${r.collector.latest === null ? "release list unreadable" : r.collector.behind === 0 ? "current" : `${r.collector.behind} release(s) behind ${r.collector.latest}`}\n`;
134
+ out += `- GENAI_SEMCONV_PIN ${r.semconv.pin}: ${r.semconv.latest === null ? "release list unreadable" : r.semconv.behind === 0 ? "current" : `${r.semconv.behind} release(s) behind ${r.semconv.latest}`}\n`;
135
+ const comps = r.findings.filter((f) => f.kind === "deprecated" || f.kind === "stability-changed");
136
+ if (comps.length > 0) {
137
+ out += "\n| Component | Finding | Detail |\n|---|---|---|\n";
138
+ for (const f of comps)
139
+ out += `| ${f.subject} | ${f.kind} | ${f.detail} |\n`;
140
+ }
141
+ else {
142
+ out += "\nNo component stability findings.\n";
143
+ }
144
+ if (r.unchecked.length > 0)
145
+ out += `\nUnchecked (no metadata.yaml found, or past the budget): ${r.unchecked.join(", ")}\n`;
146
+ return out;
147
+ }
148
+ /** Audit the otel lexicon's pins and its built-in components against upstream releases. */
149
+ export async function collectorAudit(args = {}) {
150
+ const f = args._fetch ?? fetch;
151
+ const run = args._run ?? defaultRunner;
152
+ const mode = args.mode ?? "report";
153
+ const findings = [];
154
+ const unchecked = [];
155
+ const contrib = await fetchReleases(CONTRIB_REPO, f).catch(() => null);
156
+ const semconv = await fetchReleases(SEMCONV_REPO, f).catch(() => null);
157
+ const collector = { pin: COLLECTOR_PIN.version, latest: contrib?.[0]?.tag ?? null, behind: contrib ? behindCount(contrib, COLLECTOR_PIN.version) : 0 };
158
+ const sem = { pin: GENAI_SEMCONV_PIN.version, latest: semconv?.[0]?.tag ?? null, behind: semconv ? behindCount(semconv, GENAI_SEMCONV_PIN.version) : 0 };
159
+ if (collector.latest && collector.behind > 0) {
160
+ findings.push({ kind: "collector-pin-behind", subject: "COLLECTOR_PIN", detail: `${collector.pin} is ${collector.behind} release(s) behind ${collector.latest}` });
161
+ }
162
+ if (sem.latest && sem.behind > 0) {
163
+ findings.push({ kind: "semconv-pin-behind", subject: "GENAI_SEMCONV_PIN", detail: `${sem.pin} is ${sem.behind} release(s) behind ${sem.latest}` });
164
+ }
165
+ if (args.stability !== false) {
166
+ const latest = collector.latest ?? collector.pin;
167
+ const budget = { left: args.stabilityBudget ?? 200 };
168
+ for (const c of builtinComponents()) {
169
+ const atLatest = await stabilityAt(f, c, latest, budget).catch(() => undefined);
170
+ if (!atLatest) {
171
+ unchecked.push(`${c.kind}/${c.type}`);
172
+ continue;
173
+ }
174
+ const atPin = latest === collector.pin ? atLatest : await stabilityAt(f, c, collector.pin, budget).catch(() => undefined);
175
+ findings.push(...stabilityFindings(c, atPin, atLatest, collector.pin, latest));
176
+ }
177
+ }
178
+ const base = { collector, semconv: sem, findings, unchecked };
179
+ const summary = renderCollectorAuditSummary(base);
180
+ const result = { mode, ...base, summary };
181
+ if (findings.length === 0 || mode === "report")
182
+ return result;
183
+ const cwd = process.cwd();
184
+ if (mode === "issue") {
185
+ result.issueUrl = await stickyIssue(run, cwd, "otel: collector audit findings", summary);
186
+ return result;
187
+ }
188
+ // pull-request: bump the pins that are behind.
189
+ const to = {
190
+ ...(collector.latest && collector.behind > 0 ? { collector: collector.latest } : {}),
191
+ ...(sem.latest && sem.behind > 0 ? { genai: sem.latest } : {}),
192
+ };
193
+ if (!to.collector && !to.genai) {
194
+ result.issueUrl = await stickyIssue(run, cwd, "otel: collector audit findings", summary);
195
+ return result;
196
+ }
197
+ const lexiconDir = resolve(args.lexiconDir ?? join(cwd, "lexicons", "otel"));
198
+ const root = (await run("git", ["rev-parse", "--show-toplevel"], lexiconDir)).trim();
199
+ const definePath = join(lexiconDir, "src", "define.ts");
200
+ const content = bumpPins(readFileSync(definePath, "utf8"), to);
201
+ const moved = [to.collector ? `COLLECTOR_PIN to ${to.collector}` : "", to.genai ? `GENAI_SEMCONV_PIN to ${to.genai}` : ""].filter(Boolean).join(" and ");
202
+ result.prUrl = await proposePullRequest(run, {
203
+ root,
204
+ branch: args.branch ?? "chant/otel-pins",
205
+ title: `chore(otel): move ${moved}`,
206
+ body: `${summary}\n\nThis moves the pin values only. The config types, defaults and checks written against ${collector.pin} ` +
207
+ "still need reading against the release notes before this merges.\n",
208
+ files: [{ path: relative(root, definePath), content }],
209
+ commitMessage: `chore(otel): move ${moved}`,
210
+ worktreeDir: join(mkdtempSync(join(tmpdir(), "chant-otel-pins-")), "wt"),
211
+ });
212
+ return result;
213
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Running collectors as resources a `ConvergeOp` observes (#3369).
3
+ *
4
+ * `collectorHealthObserve` returns one resource per collector, from the
5
+ * endpoints its config declares:
6
+ *
7
+ * - the `health_check` extension (`service.extensions`), at its `endpoint`
8
+ * and `path`. At v0.130.0 it answers on `localhost:13133` at `/` by
9
+ * default, 200 while the collector runs, and reports no per-component
10
+ * status, so a 200 is all it says.
11
+ * - the `zpages` extension, when the service enables it: `/debug/servicez`
12
+ * on its endpoint (default `localhost:55679`).
13
+ * - the collector's own metrics, when `service.telemetry.metrics` declares
14
+ * where they are served: a `pull` reader's prometheus exporter
15
+ * (`host`/`port`), or the older `address`. `/metrics` on it.
16
+ *
17
+ * A collector whose service enables no `health_check` is `unknown`, never
18
+ * `drifted`: nothing it declares says whether it is healthy. One that does
19
+ * is `in-sync` when every declared endpoint answers 200, and `drifted`
20
+ * otherwise, with each endpoint that did not in the detail.
21
+ *
22
+ * The config's hosts are where the collector listens, which is often
23
+ * `0.0.0.0` or a pod address. `host` replaces the host of every endpoint (a
24
+ * port-forward, a container's published ports), and `endpoints` replaces
25
+ * whole URLs.
26
+ */
27
+ import { readFileSync } from "node:fs";
28
+ import { resolve } from "node:path";
29
+ import { load } from "js-yaml";
30
+ import { parseComponentId } from "../../model.js";
31
+ const DEFAULTS = {
32
+ healthCheck: { endpoint: "localhost:13133", path: "/" },
33
+ zpages: { endpoint: "localhost:55679", path: "/debug/servicez" },
34
+ };
35
+ const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
36
+ /** `host:port`, `:port` or a URL, as host and port. A `${env:...}` host reads as unknown (empty). */
37
+ export function splitEndpoint(endpoint) {
38
+ let s = endpoint.trim().replace(/^[a-z]+:\/\//i, "");
39
+ s = s.replace(/\/.*$/, "");
40
+ const m = /^(.*):(\d+)$/.exec(s);
41
+ if (!m)
42
+ return undefined;
43
+ const host = m[1].includes("${") ? "" : m[1];
44
+ return { host, port: m[2] };
45
+ }
46
+ function urlOf(endpoint, path, host) {
47
+ const parts = splitEndpoint(endpoint);
48
+ if (!parts)
49
+ return undefined;
50
+ let h = host ?? parts.host;
51
+ if (h === "" || h === "0.0.0.0" || h === "::" || h === "[::]")
52
+ h = "localhost";
53
+ return `http://${h}:${parts.port}${path.startsWith("/") ? path : `/${path}`}`;
54
+ }
55
+ /** The first enabled extension of `type`, and its config. */
56
+ function enabledExtension(config, type) {
57
+ const service = isRecord(config.service) ? config.service : {};
58
+ const enabled = Array.isArray(service.extensions) ? service.extensions : [];
59
+ const extensions = isRecord(config.extensions) ? config.extensions : {};
60
+ for (const id of enabled) {
61
+ if (typeof id !== "string" || parseComponentId(id)?.type !== type)
62
+ continue;
63
+ const body = extensions[id];
64
+ return isRecord(body) ? body : {};
65
+ }
66
+ return undefined;
67
+ }
68
+ /** Where `service.telemetry.metrics` serves the collector's own metrics, as `host:port`, when the config says. */
69
+ function telemetryEndpoint(config) {
70
+ const service = isRecord(config.service) ? config.service : {};
71
+ const telemetry = isRecord(service.telemetry) ? service.telemetry : {};
72
+ const metrics = isRecord(telemetry.metrics) ? telemetry.metrics : undefined;
73
+ if (!metrics || metrics.level === "none")
74
+ return undefined;
75
+ if (typeof metrics.address === "string")
76
+ return metrics.address;
77
+ const readers = Array.isArray(metrics.readers) ? metrics.readers : [];
78
+ for (const r of readers) {
79
+ const prom = isRecord(r) && isRecord(r.pull) && isRecord(r.pull.exporter) ? r.pull.exporter.prometheus : undefined;
80
+ if (isRecord(prom) && (typeof prom.port === "number" || typeof prom.port === "string")) {
81
+ const host = typeof prom.host === "string" ? prom.host : "localhost";
82
+ return `${host}:${prom.port}`;
83
+ }
84
+ }
85
+ return undefined;
86
+ }
87
+ /**
88
+ * The endpoints a collector config declares, as URLs. `healthCheck` is
89
+ * present only when the service enables a `health_check` extension.
90
+ */
91
+ export function declaredEndpoints(config, host, overrides = {}) {
92
+ const out = [];
93
+ const push = (kind, url) => {
94
+ const u = overrides[kind] ?? url;
95
+ if (u)
96
+ out.push({ kind, url: u });
97
+ };
98
+ const hc = enabledExtension(config, "health_check");
99
+ if (hc) {
100
+ const endpoint = typeof hc.endpoint === "string" ? hc.endpoint : DEFAULTS.healthCheck.endpoint;
101
+ const path = typeof hc.path === "string" ? hc.path : DEFAULTS.healthCheck.path;
102
+ push("healthCheck", urlOf(endpoint, path, host));
103
+ }
104
+ const zp = enabledExtension(config, "zpages");
105
+ if (zp) {
106
+ const endpoint = typeof zp.endpoint === "string" ? zp.endpoint : DEFAULTS.zpages.endpoint;
107
+ push("zpages", urlOf(endpoint, DEFAULTS.zpages.path, host));
108
+ }
109
+ const tel = telemetryEndpoint(config);
110
+ if (tel)
111
+ push("telemetry", urlOf(tel, "/metrics", host));
112
+ return out;
113
+ }
114
+ const LABEL = { healthCheck: "health_check", zpages: "zpages", telemetry: "telemetry" };
115
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
116
+ async function probe(f, url, timeoutMs) {
117
+ try {
118
+ const res = await f(url, { signal: AbortSignal.timeout(timeoutMs) });
119
+ await res.body?.cancel().catch(() => undefined);
120
+ return res.status === 200 ? undefined : `answered ${res.status}`;
121
+ }
122
+ catch (err) {
123
+ const cause = err.cause?.code;
124
+ return `did not answer (${cause ?? err.message})`;
125
+ }
126
+ }
127
+ function collectorConfig(c) {
128
+ if (c.configObject)
129
+ return c.configObject;
130
+ if (!c.config)
131
+ throw new Error(`collector "${c.name}" names no config`);
132
+ const value = load(readFileSync(resolve(process.cwd(), c.config), "utf8"));
133
+ if (!isRecord(value))
134
+ throw new Error(`collector "${c.name}": ${c.config} does not hold a YAML mapping`);
135
+ return value;
136
+ }
137
+ /** Observe each collector through the endpoints its config declares: one resource per collector. */
138
+ export async function collectorHealthObserve(args) {
139
+ const f = args._fetch ?? fetch;
140
+ const timeoutMs = args.timeoutMs ?? 5000;
141
+ const probes = Math.max(1, args.probes ?? 2);
142
+ const interval = args.probeIntervalMs ?? 1000;
143
+ const resources = [];
144
+ for (const c of args.collectors) {
145
+ let config;
146
+ try {
147
+ config = collectorConfig(c);
148
+ }
149
+ catch (err) {
150
+ resources.push({ name: c.name, status: "unknown", detail: `config unreadable: ${err.message}` });
151
+ continue;
152
+ }
153
+ const endpoints = declaredEndpoints(config, c.host, c.endpoints);
154
+ if (!endpoints.some((e) => e.kind === "healthCheck")) {
155
+ resources.push({ name: c.name, status: "unknown", detail: "no health_check in service.extensions" });
156
+ continue;
157
+ }
158
+ const failures = [];
159
+ for (const e of endpoints) {
160
+ let why;
161
+ for (let i = 0; i < probes; i++) {
162
+ why = await probe(f, e.url, timeoutMs);
163
+ if (!why)
164
+ break;
165
+ if (i + 1 < probes)
166
+ await sleep(interval);
167
+ }
168
+ if (why)
169
+ failures.push(`${LABEL[e.kind]} ${e.url} ${why}`);
170
+ }
171
+ resources.push(failures.length === 0
172
+ ? { name: c.name, status: "in-sync", detail: endpoints.map((e) => `${LABEL[e.kind]} ok`).join(", ") }
173
+ : { name: c.name, status: "drifted", detail: failures.join("; ") });
174
+ }
175
+ return { resources };
176
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * How the otel activities run a binary: a child process whose exit code,
3
+ * stdout and stderr are returned rather than thrown, so the caller decides
4
+ * what a non-zero exit means. Tests pass their own runner.
5
+ */
6
+ import { execFile } from "node:child_process";
7
+ export const defaultExec = (bin, args, opts = {}) => new Promise((resolve) => {
8
+ execFile(bin, args, { cwd: opts.cwd, timeout: opts.timeoutMs ?? 120000, maxBuffer: 32 * 1024 * 1024, encoding: "utf8" }, (err, stdout, stderr) => {
9
+ if (!err)
10
+ return resolve({ code: 0, stdout, stderr });
11
+ const e = err;
12
+ if (typeof e.code === "number")
13
+ return resolve({ code: e.code, stdout, stderr });
14
+ resolve({ code: null, stdout: stdout ?? "", stderr: stderr ?? "", error: e.message });
15
+ });
16
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * What the audit activities read from GitHub and write back to it: release
3
+ * lists over the REST API, and a sticky issue or a proposal pull request
4
+ * through `git` and `gh`. The prometheus lexicon's rule audit uses the issue
5
+ * half too.
6
+ */
7
+ import { execFile } from "node:child_process";
8
+ import { ghHostEnv } from "@intentius/chant/op/activities/gh-host";
9
+ /**
10
+ * Runs the command in `cwd`. `gh issue` and `gh pr` name no host, so a `gh`
11
+ * call is told the job's forge or `cwd`'s `origin` host (core's
12
+ * `ghHostEnv`, #3780, #3798): a GitHub Enterprise Server checkout is not sent
13
+ * to github.com.
14
+ */
15
+ export const defaultRunner = async (bin, args, cwd) => {
16
+ const env = bin === "gh" ? await ghHostEnv(process.env, cwd) : process.env;
17
+ return new Promise((resolve, reject) => {
18
+ execFile(bin, args, { cwd, env, maxBuffer: 16 * 1024 * 1024, encoding: "utf8" }, (err, stdout, stderr) => {
19
+ if (err)
20
+ reject(new Error(`${bin} ${args.join(" ")} failed: ${(stderr || err.message).trim()}`));
21
+ else
22
+ resolve(stdout);
23
+ });
24
+ });
25
+ };
26
+ /** `v1.2.3` as numbers, or undefined when the tag is not a plain semver. */
27
+ export function semverOf(tag) {
28
+ const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
29
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
30
+ }
31
+ export function compareSemver(a, b) {
32
+ const x = semverOf(a);
33
+ const y = semverOf(b);
34
+ if (!x || !y)
35
+ return a.localeCompare(b);
36
+ for (let i = 0; i < 3; i++)
37
+ if (x[i] !== y[i])
38
+ return x[i] - y[i];
39
+ return 0;
40
+ }
41
+ /**
42
+ * The published, non-prerelease releases of `repo` (`owner/name`) with a
43
+ * plain semver tag, newest first. Reads one page of 100, which covers
44
+ * months of collector releases.
45
+ */
46
+ export async function fetchReleases(repo, f = fetch, token = process.env.GITHUB_TOKEN) {
47
+ const headers = { Accept: "application/vnd.github+json" };
48
+ if (token)
49
+ headers.Authorization = `Bearer ${token}`;
50
+ const res = await f(`https://api.github.com/repos/${repo}/releases?per_page=100`, { headers });
51
+ if (!res.ok)
52
+ throw new Error(`GitHub releases of ${repo}: HTTP ${res.status}`);
53
+ const body = (await res.json());
54
+ return body
55
+ .filter((r) => !r.draft && !r.prerelease && typeof r.tag_name === "string" && semverOf(r.tag_name))
56
+ .map((r) => ({ tag: r.tag_name, ...(r.published_at ? { publishedAt: r.published_at } : {}) }))
57
+ .sort((a, b) => compareSemver(b.tag, a.tag));
58
+ }
59
+ /**
60
+ * Open an issue titled `title`, or edit the body of the open one that
61
+ * already has that title, so a scheduled audit keeps one issue current
62
+ * instead of opening one per run. Returns its URL.
63
+ */
64
+ export async function stickyIssue(run, cwd, title, body, labels = []) {
65
+ const listed = await run("gh", ["issue", "list", "--state", "open", "--search", `"${title}" in:title`, "--json", "number,title,url"], cwd);
66
+ const open = JSON.parse(listed || "[]").find((i) => i.title === title);
67
+ if (open) {
68
+ await run("gh", ["issue", "edit", String(open.number), "--body", body], cwd);
69
+ return open.url;
70
+ }
71
+ const args = ["issue", "create", "--title", title, "--body", body];
72
+ for (const l of labels)
73
+ args.push("--label", l);
74
+ return (await run("gh", args, cwd)).trim();
75
+ }
76
+ /**
77
+ * Commit `files` on `branch`, from the remote's default branch, in a
78
+ * worktree of its own (the checked-out branch and working tree are never
79
+ * touched), push it, and open a pull request, or edit the open one for that
80
+ * branch. Returns the pull request's URL.
81
+ */
82
+ export async function proposePullRequest(run, o) {
83
+ const remote = o.remote ?? "origin";
84
+ await run("git", ["fetch", remote], o.root);
85
+ let base = o.base;
86
+ if (!base) {
87
+ const head = (await run("git", ["symbolic-ref", "--short", `refs/remotes/${remote}/HEAD`], o.root).catch(() => `${remote}/main`)).trim();
88
+ base = head.startsWith(`${remote}/`) ? head.slice(remote.length + 1) : head;
89
+ }
90
+ await run("git", ["worktree", "add", "--force", "-B", o.branch, o.worktreeDir, `${remote}/${base}`], o.root);
91
+ try {
92
+ const { writeFileSync } = await import("node:fs");
93
+ const { join } = await import("node:path");
94
+ for (const f of o.files)
95
+ writeFileSync(join(o.worktreeDir, f.path), f.content);
96
+ await run("git", ["add", ...o.files.map((f) => f.path)], o.worktreeDir);
97
+ await run("git", ["commit", "-m", o.commitMessage], o.worktreeDir);
98
+ await run("git", ["push", "--force", remote, `HEAD:refs/heads/${o.branch}`], o.worktreeDir);
99
+ }
100
+ finally {
101
+ await run("git", ["worktree", "remove", "--force", o.worktreeDir], o.root).catch(() => undefined);
102
+ }
103
+ const listed = await run("gh", ["pr", "list", "--head", o.branch, "--state", "open", "--json", "number,url"], o.root);
104
+ const open = JSON.parse(listed || "[]")[0];
105
+ if (open) {
106
+ await run("gh", ["pr", "edit", String(open.number), "--title", o.title, "--body", o.body], o.root);
107
+ return open.url;
108
+ }
109
+ return (await run("gh", ["pr", "create", "--head", o.branch, "--base", base, "--title", o.title, "--body", o.body], o.root)).trim();
110
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * otel Op activities (#3369), resolved by the core activity registry when a
3
+ * project's `chant.config.ts` lists the `otel` lexicon. Every function this
4
+ * module exports is an activity, bound by its export name, so the helpers
5
+ * behind them stay in their own files.
6
+ *
7
+ * - `otelcolValidate`, `otelcolComponents`: the collector binary's own
8
+ * checks over a config file.
9
+ * - `collectorHealthObserve`: running collectors as the resources of a
10
+ * `ConvergeOp({ observe })`.
11
+ * - `collectorAudit`: the lexicon's pins against upstream releases, the
12
+ * step of `CollectorAuditOp`.
13
+ */
14
+ export { otelcolValidate, otelcolComponents } from "./otelcol.js";
15
+ export { collectorHealthObserve } from "./collector-health.js";
16
+ export { collectorAudit } from "./collector-audit.js";