@brignano/driftwood 0.0.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/LICENSE +21 -0
- package/README.md +310 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +141 -0
- package/dist/config.d.ts +117 -0
- package/dist/config.js +72 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +21 -0
- package/dist/model/merge.d.ts +32 -0
- package/dist/model/merge.js +96 -0
- package/dist/model/schema.d.ts +322 -0
- package/dist/model/schema.js +78 -0
- package/dist/model/validate.d.ts +21 -0
- package/dist/model/validate.js +76 -0
- package/dist/providers/dynatrace.d.ts +70 -0
- package/dist/providers/dynatrace.js +117 -0
- package/dist/providers/index.d.ts +12 -0
- package/dist/providers/index.js +12 -0
- package/dist/providers/terraform.d.ts +61 -0
- package/dist/providers/terraform.js +132 -0
- package/dist/providers/types.d.ts +41 -0
- package/dist/providers/types.js +4 -0
- package/dist/reconcile/index.d.ts +40 -0
- package/dist/reconcile/index.js +146 -0
- package/dist/registry.d.ts +22 -0
- package/dist/registry.js +43 -0
- package/dist/render/dot.d.ts +4 -0
- package/dist/render/dot.js +92 -0
- package/dist/render/graphviz.d.ts +47 -0
- package/dist/render/graphviz.js +121 -0
- package/dist/render/index.d.ts +28 -0
- package/dist/render/index.js +42 -0
- package/dist/render/mermaid.d.ts +5 -0
- package/dist/render/mermaid.js +107 -0
- package/dist/render/select.d.ts +7 -0
- package/dist/render/select.js +17 -0
- package/dist/render/types.d.ts +33 -0
- package/dist/render/types.js +3 -0
- package/package.json +59 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { Model, Entity, Edge, View, Ignore, Level, emptyModel } from './model/schema.js';
|
|
2
|
+
export { loadModel, dumpModel, validateModel, matches } from './model/validate.js';
|
|
3
|
+
export type { Issue, ValidationResult } from './model/validate.js';
|
|
4
|
+
export { mergeModels, formatConflicts } from './model/merge.js';
|
|
5
|
+
export type { MergeResult, MergeConflict, SourcedModel } from './model/merge.js';
|
|
6
|
+
export { Registry } from './registry.js';
|
|
7
|
+
export { providers, defineProvider, terraformProvider, dynatraceProvider } from './providers/index.js';
|
|
8
|
+
export type { Provider, ProviderContext, ProviderKind } from './providers/index.js';
|
|
9
|
+
export { renderers, render, resolveRenderer } from './render/index.js';
|
|
10
|
+
export { defineRenderer } from './render/types.js';
|
|
11
|
+
export type { Renderer, RenderContext, Availability } from './render/types.js';
|
|
12
|
+
export { importTerraformState, parseTerraformState } from './providers/terraform.js';
|
|
13
|
+
export type { ImportOptions } from './providers/terraform.js';
|
|
14
|
+
export { toModel as dynatraceToModel } from './providers/dynatrace.js';
|
|
15
|
+
export { renderMermaid } from './render/mermaid.js';
|
|
16
|
+
export { renderDot } from './render/dot.js';
|
|
17
|
+
export { renderGraphvizSvg, detectTier, probeNativeDot } from './render/graphviz.js';
|
|
18
|
+
export { selectEntities } from './render/select.js';
|
|
19
|
+
export { reconcile, formatDrift, COMPARED_FIELDS } from './reconcile/index.js';
|
|
20
|
+
export type { Drift, FieldChange, ComparedField } from './reconcile/index.js';
|
|
21
|
+
export { loadConfig, observeAll, makeContext, Config } from './config.js';
|
|
22
|
+
export type { LoadedConfig } from './config.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Model
|
|
2
|
+
export { Model, Entity, Edge, View, Ignore, Level, emptyModel } from './model/schema.js';
|
|
3
|
+
export { loadModel, dumpModel, validateModel, matches } from './model/validate.js';
|
|
4
|
+
export { mergeModels, formatConflicts } from './model/merge.js';
|
|
5
|
+
// Extension points
|
|
6
|
+
export { Registry } from './registry.js';
|
|
7
|
+
export { providers, defineProvider, terraformProvider, dynatraceProvider } from './providers/index.js';
|
|
8
|
+
export { renderers, render, resolveRenderer } from './render/index.js';
|
|
9
|
+
export { defineRenderer } from './render/types.js';
|
|
10
|
+
// Built-in providers
|
|
11
|
+
export { importTerraformState, parseTerraformState } from './providers/terraform.js';
|
|
12
|
+
export { toModel as dynatraceToModel } from './providers/dynatrace.js';
|
|
13
|
+
// Renderers
|
|
14
|
+
export { renderMermaid } from './render/mermaid.js';
|
|
15
|
+
export { renderDot } from './render/dot.js';
|
|
16
|
+
export { renderGraphvizSvg, detectTier, probeNativeDot } from './render/graphviz.js';
|
|
17
|
+
export { selectEntities } from './render/select.js';
|
|
18
|
+
// Reconciliation
|
|
19
|
+
export { reconcile, formatDrift, COMPARED_FIELDS } from './reconcile/index.js';
|
|
20
|
+
// Config
|
|
21
|
+
export { loadConfig, observeAll, makeContext, Config } from './config.js';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Model } from './schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* Combining what several providers observed into one model.
|
|
4
|
+
*
|
|
5
|
+
* The genuinely hard part is identity: Terraform calls it
|
|
6
|
+
* `aws_lambda_function.forwarder`, Dynatrace calls it `SERVICE-A1B2`, and
|
|
7
|
+
* nothing in either payload proves they're the same thing. This module does
|
|
8
|
+
* NOT guess. It applies the explicit `aliases` map from the committed model
|
|
9
|
+
* and otherwise keeps ids separate — a duplicated node is a visible, fixable
|
|
10
|
+
* problem, whereas a wrongly merged node silently corrupts the graph.
|
|
11
|
+
*/
|
|
12
|
+
export interface SourcedModel {
|
|
13
|
+
provider: string;
|
|
14
|
+
kind: 'declarative' | 'runtime';
|
|
15
|
+
model: Model;
|
|
16
|
+
}
|
|
17
|
+
export interface MergeConflict {
|
|
18
|
+
id: string;
|
|
19
|
+
field: 'kind' | 'name' | 'group' | 'platform';
|
|
20
|
+
values: Array<{
|
|
21
|
+
provider: string;
|
|
22
|
+
value: string | undefined;
|
|
23
|
+
}>;
|
|
24
|
+
/** The value that won, and why. */
|
|
25
|
+
resolved: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
export interface MergeResult {
|
|
28
|
+
model: Model;
|
|
29
|
+
conflicts: MergeConflict[];
|
|
30
|
+
}
|
|
31
|
+
export declare function mergeModels(sources: SourcedModel[], aliases?: Record<string, string>): MergeResult;
|
|
32
|
+
export declare function formatConflicts(conflicts: MergeConflict[]): string;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { emptyModel } from './schema.js';
|
|
2
|
+
const CONFLICT_FIELDS = ['kind', 'name', 'group', 'platform'];
|
|
3
|
+
/**
|
|
4
|
+
* Declarative sources describe intent and own naming and grouping; runtime
|
|
5
|
+
* sources describe what's actually live. When both saw the same entity, the
|
|
6
|
+
* declarative value wins on these descriptive fields — a monitoring agent's
|
|
7
|
+
* display name is usually noisier than the IaC resource name.
|
|
8
|
+
*/
|
|
9
|
+
function preferred(candidates) {
|
|
10
|
+
const withValue = candidates.filter((c) => c.value !== undefined);
|
|
11
|
+
if (withValue.length === 0)
|
|
12
|
+
return undefined;
|
|
13
|
+
return (withValue.find((c) => c.kind === 'declarative') ?? withValue[0]).value;
|
|
14
|
+
}
|
|
15
|
+
export function mergeModels(sources, aliases = {}) {
|
|
16
|
+
const resolveId = (id) => aliases[id] ?? id;
|
|
17
|
+
const byId = new Map();
|
|
18
|
+
for (const src of sources) {
|
|
19
|
+
for (const entity of src.model.entities) {
|
|
20
|
+
const id = resolveId(entity.id);
|
|
21
|
+
const list = byId.get(id) ?? [];
|
|
22
|
+
list.push({ provider: src.provider, kind: src.kind, entity });
|
|
23
|
+
byId.set(id, list);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const entities = [];
|
|
27
|
+
const conflicts = [];
|
|
28
|
+
for (const [id, observations] of byId) {
|
|
29
|
+
const first = observations[0].entity;
|
|
30
|
+
const merged = { ...first, id, level: first.level };
|
|
31
|
+
for (const field of CONFLICT_FIELDS) {
|
|
32
|
+
const candidates = observations.map((o) => ({
|
|
33
|
+
provider: o.provider,
|
|
34
|
+
kind: o.kind,
|
|
35
|
+
value: o.entity[field],
|
|
36
|
+
}));
|
|
37
|
+
const distinct = [...new Set(candidates.map((c) => c.value).filter((v) => v !== undefined))];
|
|
38
|
+
const winner = preferred(candidates);
|
|
39
|
+
if (field === 'kind') {
|
|
40
|
+
merged.kind = winner ?? first.kind;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
merged[field] = winner;
|
|
44
|
+
}
|
|
45
|
+
if (distinct.length > 1) {
|
|
46
|
+
conflicts.push({
|
|
47
|
+
id,
|
|
48
|
+
field,
|
|
49
|
+
values: candidates.map((c) => ({ provider: c.provider, value: c.value })),
|
|
50
|
+
resolved: winner,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Provenance: every provider that saw this entity, so a drift report can
|
|
55
|
+
// say "Terraform knows about it, Dynatrace has never seen it run".
|
|
56
|
+
merged.source = [...new Set(observations.map((o) => o.provider))].sort().join('+');
|
|
57
|
+
const tags = {};
|
|
58
|
+
for (const o of observations)
|
|
59
|
+
Object.assign(tags, o.entity.tags ?? {});
|
|
60
|
+
if (Object.keys(tags).length > 0)
|
|
61
|
+
merged.tags = tags;
|
|
62
|
+
entities.push(merged);
|
|
63
|
+
}
|
|
64
|
+
const known = new Set(entities.map((e) => e.id));
|
|
65
|
+
const edgeMap = new Map();
|
|
66
|
+
for (const src of sources) {
|
|
67
|
+
for (const edge of src.model.edges) {
|
|
68
|
+
const from = resolveId(edge.from);
|
|
69
|
+
const to = resolveId(edge.to);
|
|
70
|
+
// Aliasing can collapse two ids into one and turn an edge into a loop.
|
|
71
|
+
if (from === to)
|
|
72
|
+
continue;
|
|
73
|
+
if (!known.has(from) || !known.has(to))
|
|
74
|
+
continue;
|
|
75
|
+
const key = `${from} ${to}`;
|
|
76
|
+
if (!edgeMap.has(key))
|
|
77
|
+
edgeMap.set(key, { ...edge, from, to });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const model = emptyModel(sources[0]?.model.name ?? 'merged');
|
|
81
|
+
model.entities = entities.sort((a, b) => a.id.localeCompare(b.id));
|
|
82
|
+
model.edges = [...edgeMap.values()].sort((a, b) => (a.from + a.to).localeCompare(b.from + b.to));
|
|
83
|
+
model.aliases = aliases;
|
|
84
|
+
return { model, conflicts };
|
|
85
|
+
}
|
|
86
|
+
export function formatConflicts(conflicts) {
|
|
87
|
+
if (conflicts.length === 0)
|
|
88
|
+
return '';
|
|
89
|
+
const out = [`### Provider disagreements (${conflicts.length})`, '', '| Entity | Field | Values | Resolved |', '|---|---|---|---|'];
|
|
90
|
+
for (const c of conflicts) {
|
|
91
|
+
const values = c.values.map((v) => `${v.provider}=${v.value ?? '-'}`).join(', ');
|
|
92
|
+
out.push(`| \`${c.id}\` | ${c.field} | ${values} | ${c.resolved ?? '-'} |`);
|
|
93
|
+
}
|
|
94
|
+
out.push('');
|
|
95
|
+
return out.join('\n');
|
|
96
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* The model is the only thing that lives in git. Providers write into it,
|
|
4
|
+
* renderers read from it, and the reconciler diffs it against reality.
|
|
5
|
+
*
|
|
6
|
+
* `kind` is deliberately an open string rather than an enum — any provider
|
|
7
|
+
* must be able to emit its own vocabulary (`aws_s3_bucket`, `service`,
|
|
8
|
+
* `k8s_deployment`) without a schema change. Renderers map known kinds to
|
|
9
|
+
* shapes and fall back gracefully for the rest.
|
|
10
|
+
*/
|
|
11
|
+
export declare const Level: z.ZodEnum<["context", "container", "component"]>;
|
|
12
|
+
export type Level = z.infer<typeof Level>;
|
|
13
|
+
export declare const Entity: z.ZodObject<{
|
|
14
|
+
/** Stable identity. For Terraform this is the resource address. */
|
|
15
|
+
id: z.ZodString;
|
|
16
|
+
kind: z.ZodString;
|
|
17
|
+
name: z.ZodOptional<z.ZodString>;
|
|
18
|
+
/** Visual + logical grouping — becomes a subgraph when rendered. */
|
|
19
|
+
group: z.ZodOptional<z.ZodString>;
|
|
20
|
+
/** Where it runs: aws, gcp, azure, onprem, kubernetes. */
|
|
21
|
+
platform: z.ZodOptional<z.ZodString>;
|
|
22
|
+
/** Which provider observed it. Provenance, set by the merge step. */
|
|
23
|
+
source: z.ZodOptional<z.ZodString>;
|
|
24
|
+
level: z.ZodDefault<z.ZodEnum<["context", "container", "component"]>>;
|
|
25
|
+
/** Free-form metadata. Excluded from drift comparison by default. */
|
|
26
|
+
tags: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
27
|
+
}, "strip", z.ZodTypeAny, {
|
|
28
|
+
id: string;
|
|
29
|
+
kind: string;
|
|
30
|
+
level: "context" | "container" | "component";
|
|
31
|
+
name?: string | undefined;
|
|
32
|
+
group?: string | undefined;
|
|
33
|
+
platform?: string | undefined;
|
|
34
|
+
source?: string | undefined;
|
|
35
|
+
tags?: Record<string, string> | undefined;
|
|
36
|
+
}, {
|
|
37
|
+
id: string;
|
|
38
|
+
kind: string;
|
|
39
|
+
name?: string | undefined;
|
|
40
|
+
group?: string | undefined;
|
|
41
|
+
platform?: string | undefined;
|
|
42
|
+
source?: string | undefined;
|
|
43
|
+
level?: "context" | "container" | "component" | undefined;
|
|
44
|
+
tags?: Record<string, string> | undefined;
|
|
45
|
+
}>;
|
|
46
|
+
export type Entity = z.infer<typeof Entity>;
|
|
47
|
+
export declare const Edge: z.ZodObject<{
|
|
48
|
+
from: z.ZodString;
|
|
49
|
+
to: z.ZodString;
|
|
50
|
+
kind: z.ZodDefault<z.ZodString>;
|
|
51
|
+
label: z.ZodOptional<z.ZodString>;
|
|
52
|
+
}, "strip", z.ZodTypeAny, {
|
|
53
|
+
kind: string;
|
|
54
|
+
from: string;
|
|
55
|
+
to: string;
|
|
56
|
+
label?: string | undefined;
|
|
57
|
+
}, {
|
|
58
|
+
from: string;
|
|
59
|
+
to: string;
|
|
60
|
+
kind?: string | undefined;
|
|
61
|
+
label?: string | undefined;
|
|
62
|
+
}>;
|
|
63
|
+
export type Edge = z.infer<typeof Edge>;
|
|
64
|
+
/**
|
|
65
|
+
* A scoped slice of the model. Flat Mermaid becomes unreadable past roughly
|
|
66
|
+
* 150 nodes, so views exist from v1 rather than being a later optimization.
|
|
67
|
+
* Patterns match an entity's id or group, and support a trailing `*`.
|
|
68
|
+
*/
|
|
69
|
+
export declare const View: z.ZodObject<{
|
|
70
|
+
id: z.ZodString;
|
|
71
|
+
title: z.ZodOptional<z.ZodString>;
|
|
72
|
+
level: z.ZodDefault<z.ZodEnum<["context", "container", "component"]>>;
|
|
73
|
+
include: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
74
|
+
exclude: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
75
|
+
}, "strip", z.ZodTypeAny, {
|
|
76
|
+
id: string;
|
|
77
|
+
level: "context" | "container" | "component";
|
|
78
|
+
include: string[];
|
|
79
|
+
exclude: string[];
|
|
80
|
+
title?: string | undefined;
|
|
81
|
+
}, {
|
|
82
|
+
id: string;
|
|
83
|
+
level?: "context" | "container" | "component" | undefined;
|
|
84
|
+
title?: string | undefined;
|
|
85
|
+
include?: string[] | undefined;
|
|
86
|
+
exclude?: string[] | undefined;
|
|
87
|
+
}>;
|
|
88
|
+
export type View = z.infer<typeof View>;
|
|
89
|
+
/** Intentional divergence, reviewed like code. */
|
|
90
|
+
export declare const Ignore: z.ZodObject<{
|
|
91
|
+
entities: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
92
|
+
kinds: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
93
|
+
edges: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
94
|
+
from: z.ZodString;
|
|
95
|
+
to: z.ZodString;
|
|
96
|
+
}, "strip", z.ZodTypeAny, {
|
|
97
|
+
from: string;
|
|
98
|
+
to: string;
|
|
99
|
+
}, {
|
|
100
|
+
from: string;
|
|
101
|
+
to: string;
|
|
102
|
+
}>, "many">>;
|
|
103
|
+
}, "strip", z.ZodTypeAny, {
|
|
104
|
+
entities: string[];
|
|
105
|
+
kinds: string[];
|
|
106
|
+
edges: {
|
|
107
|
+
from: string;
|
|
108
|
+
to: string;
|
|
109
|
+
}[];
|
|
110
|
+
}, {
|
|
111
|
+
entities?: string[] | undefined;
|
|
112
|
+
kinds?: string[] | undefined;
|
|
113
|
+
edges?: {
|
|
114
|
+
from: string;
|
|
115
|
+
to: string;
|
|
116
|
+
}[] | undefined;
|
|
117
|
+
}>;
|
|
118
|
+
export type Ignore = z.infer<typeof Ignore>;
|
|
119
|
+
export declare const Model: z.ZodObject<{
|
|
120
|
+
version: z.ZodLiteral<1>;
|
|
121
|
+
name: z.ZodString;
|
|
122
|
+
entities: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
123
|
+
/** Stable identity. For Terraform this is the resource address. */
|
|
124
|
+
id: z.ZodString;
|
|
125
|
+
kind: z.ZodString;
|
|
126
|
+
name: z.ZodOptional<z.ZodString>;
|
|
127
|
+
/** Visual + logical grouping — becomes a subgraph when rendered. */
|
|
128
|
+
group: z.ZodOptional<z.ZodString>;
|
|
129
|
+
/** Where it runs: aws, gcp, azure, onprem, kubernetes. */
|
|
130
|
+
platform: z.ZodOptional<z.ZodString>;
|
|
131
|
+
/** Which provider observed it. Provenance, set by the merge step. */
|
|
132
|
+
source: z.ZodOptional<z.ZodString>;
|
|
133
|
+
level: z.ZodDefault<z.ZodEnum<["context", "container", "component"]>>;
|
|
134
|
+
/** Free-form metadata. Excluded from drift comparison by default. */
|
|
135
|
+
tags: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
136
|
+
}, "strip", z.ZodTypeAny, {
|
|
137
|
+
id: string;
|
|
138
|
+
kind: string;
|
|
139
|
+
level: "context" | "container" | "component";
|
|
140
|
+
name?: string | undefined;
|
|
141
|
+
group?: string | undefined;
|
|
142
|
+
platform?: string | undefined;
|
|
143
|
+
source?: string | undefined;
|
|
144
|
+
tags?: Record<string, string> | undefined;
|
|
145
|
+
}, {
|
|
146
|
+
id: string;
|
|
147
|
+
kind: string;
|
|
148
|
+
name?: string | undefined;
|
|
149
|
+
group?: string | undefined;
|
|
150
|
+
platform?: string | undefined;
|
|
151
|
+
source?: string | undefined;
|
|
152
|
+
level?: "context" | "container" | "component" | undefined;
|
|
153
|
+
tags?: Record<string, string> | undefined;
|
|
154
|
+
}>, "many">>;
|
|
155
|
+
edges: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
156
|
+
from: z.ZodString;
|
|
157
|
+
to: z.ZodString;
|
|
158
|
+
kind: z.ZodDefault<z.ZodString>;
|
|
159
|
+
label: z.ZodOptional<z.ZodString>;
|
|
160
|
+
}, "strip", z.ZodTypeAny, {
|
|
161
|
+
kind: string;
|
|
162
|
+
from: string;
|
|
163
|
+
to: string;
|
|
164
|
+
label?: string | undefined;
|
|
165
|
+
}, {
|
|
166
|
+
from: string;
|
|
167
|
+
to: string;
|
|
168
|
+
kind?: string | undefined;
|
|
169
|
+
label?: string | undefined;
|
|
170
|
+
}>, "many">>;
|
|
171
|
+
views: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
172
|
+
id: z.ZodString;
|
|
173
|
+
title: z.ZodOptional<z.ZodString>;
|
|
174
|
+
level: z.ZodDefault<z.ZodEnum<["context", "container", "component"]>>;
|
|
175
|
+
include: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
176
|
+
exclude: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
177
|
+
}, "strip", z.ZodTypeAny, {
|
|
178
|
+
id: string;
|
|
179
|
+
level: "context" | "container" | "component";
|
|
180
|
+
include: string[];
|
|
181
|
+
exclude: string[];
|
|
182
|
+
title?: string | undefined;
|
|
183
|
+
}, {
|
|
184
|
+
id: string;
|
|
185
|
+
level?: "context" | "container" | "component" | undefined;
|
|
186
|
+
title?: string | undefined;
|
|
187
|
+
include?: string[] | undefined;
|
|
188
|
+
exclude?: string[] | undefined;
|
|
189
|
+
}>, "many">>;
|
|
190
|
+
ignore: z.ZodDefault<z.ZodObject<{
|
|
191
|
+
entities: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
192
|
+
kinds: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
193
|
+
edges: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
194
|
+
from: z.ZodString;
|
|
195
|
+
to: z.ZodString;
|
|
196
|
+
}, "strip", z.ZodTypeAny, {
|
|
197
|
+
from: string;
|
|
198
|
+
to: string;
|
|
199
|
+
}, {
|
|
200
|
+
from: string;
|
|
201
|
+
to: string;
|
|
202
|
+
}>, "many">>;
|
|
203
|
+
}, "strip", z.ZodTypeAny, {
|
|
204
|
+
entities: string[];
|
|
205
|
+
kinds: string[];
|
|
206
|
+
edges: {
|
|
207
|
+
from: string;
|
|
208
|
+
to: string;
|
|
209
|
+
}[];
|
|
210
|
+
}, {
|
|
211
|
+
entities?: string[] | undefined;
|
|
212
|
+
kinds?: string[] | undefined;
|
|
213
|
+
edges?: {
|
|
214
|
+
from: string;
|
|
215
|
+
to: string;
|
|
216
|
+
}[] | undefined;
|
|
217
|
+
}>>;
|
|
218
|
+
/**
|
|
219
|
+
* Cross-provider identity. Maps a provider's native id to the canonical
|
|
220
|
+
* entity id, so Dynatrace's `SERVICE-A1B2` and Terraform's
|
|
221
|
+
* `aws_lambda_function.forwarder` collapse into one node.
|
|
222
|
+
*
|
|
223
|
+
* This is deliberately manual. Automatic identity resolution across
|
|
224
|
+
* declarative and runtime sources is the hard, unsolved part of this
|
|
225
|
+
* problem, and guessing wrong silently corrupts the graph.
|
|
226
|
+
*/
|
|
227
|
+
aliases: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
228
|
+
/**
|
|
229
|
+
* Declared blind spots. Read-only credentials never see everything, and
|
|
230
|
+
* "unknown" must never be silently reported as "absent".
|
|
231
|
+
*/
|
|
232
|
+
coverage: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
233
|
+
scope: z.ZodString;
|
|
234
|
+
reason: z.ZodString;
|
|
235
|
+
}, "strip", z.ZodTypeAny, {
|
|
236
|
+
scope: string;
|
|
237
|
+
reason: string;
|
|
238
|
+
}, {
|
|
239
|
+
scope: string;
|
|
240
|
+
reason: string;
|
|
241
|
+
}>, "many">>;
|
|
242
|
+
}, "strip", z.ZodTypeAny, {
|
|
243
|
+
name: string;
|
|
244
|
+
entities: {
|
|
245
|
+
id: string;
|
|
246
|
+
kind: string;
|
|
247
|
+
level: "context" | "container" | "component";
|
|
248
|
+
name?: string | undefined;
|
|
249
|
+
group?: string | undefined;
|
|
250
|
+
platform?: string | undefined;
|
|
251
|
+
source?: string | undefined;
|
|
252
|
+
tags?: Record<string, string> | undefined;
|
|
253
|
+
}[];
|
|
254
|
+
edges: {
|
|
255
|
+
kind: string;
|
|
256
|
+
from: string;
|
|
257
|
+
to: string;
|
|
258
|
+
label?: string | undefined;
|
|
259
|
+
}[];
|
|
260
|
+
version: 1;
|
|
261
|
+
views: {
|
|
262
|
+
id: string;
|
|
263
|
+
level: "context" | "container" | "component";
|
|
264
|
+
include: string[];
|
|
265
|
+
exclude: string[];
|
|
266
|
+
title?: string | undefined;
|
|
267
|
+
}[];
|
|
268
|
+
ignore: {
|
|
269
|
+
entities: string[];
|
|
270
|
+
kinds: string[];
|
|
271
|
+
edges: {
|
|
272
|
+
from: string;
|
|
273
|
+
to: string;
|
|
274
|
+
}[];
|
|
275
|
+
};
|
|
276
|
+
aliases: Record<string, string>;
|
|
277
|
+
coverage: {
|
|
278
|
+
scope: string;
|
|
279
|
+
reason: string;
|
|
280
|
+
}[];
|
|
281
|
+
}, {
|
|
282
|
+
name: string;
|
|
283
|
+
version: 1;
|
|
284
|
+
entities?: {
|
|
285
|
+
id: string;
|
|
286
|
+
kind: string;
|
|
287
|
+
name?: string | undefined;
|
|
288
|
+
group?: string | undefined;
|
|
289
|
+
platform?: string | undefined;
|
|
290
|
+
source?: string | undefined;
|
|
291
|
+
level?: "context" | "container" | "component" | undefined;
|
|
292
|
+
tags?: Record<string, string> | undefined;
|
|
293
|
+
}[] | undefined;
|
|
294
|
+
edges?: {
|
|
295
|
+
from: string;
|
|
296
|
+
to: string;
|
|
297
|
+
kind?: string | undefined;
|
|
298
|
+
label?: string | undefined;
|
|
299
|
+
}[] | undefined;
|
|
300
|
+
views?: {
|
|
301
|
+
id: string;
|
|
302
|
+
level?: "context" | "container" | "component" | undefined;
|
|
303
|
+
title?: string | undefined;
|
|
304
|
+
include?: string[] | undefined;
|
|
305
|
+
exclude?: string[] | undefined;
|
|
306
|
+
}[] | undefined;
|
|
307
|
+
ignore?: {
|
|
308
|
+
entities?: string[] | undefined;
|
|
309
|
+
kinds?: string[] | undefined;
|
|
310
|
+
edges?: {
|
|
311
|
+
from: string;
|
|
312
|
+
to: string;
|
|
313
|
+
}[] | undefined;
|
|
314
|
+
} | undefined;
|
|
315
|
+
aliases?: Record<string, string> | undefined;
|
|
316
|
+
coverage?: {
|
|
317
|
+
scope: string;
|
|
318
|
+
reason: string;
|
|
319
|
+
}[] | undefined;
|
|
320
|
+
}>;
|
|
321
|
+
export type Model = z.infer<typeof Model>;
|
|
322
|
+
export declare function emptyModel(name: string): Model;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* The model is the only thing that lives in git. Providers write into it,
|
|
4
|
+
* renderers read from it, and the reconciler diffs it against reality.
|
|
5
|
+
*
|
|
6
|
+
* `kind` is deliberately an open string rather than an enum — any provider
|
|
7
|
+
* must be able to emit its own vocabulary (`aws_s3_bucket`, `service`,
|
|
8
|
+
* `k8s_deployment`) without a schema change. Renderers map known kinds to
|
|
9
|
+
* shapes and fall back gracefully for the rest.
|
|
10
|
+
*/
|
|
11
|
+
export const Level = z.enum(['context', 'container', 'component']);
|
|
12
|
+
export const Entity = z.object({
|
|
13
|
+
/** Stable identity. For Terraform this is the resource address. */
|
|
14
|
+
id: z.string().min(1),
|
|
15
|
+
kind: z.string().min(1),
|
|
16
|
+
name: z.string().optional(),
|
|
17
|
+
/** Visual + logical grouping — becomes a subgraph when rendered. */
|
|
18
|
+
group: z.string().optional(),
|
|
19
|
+
/** Where it runs: aws, gcp, azure, onprem, kubernetes. */
|
|
20
|
+
platform: z.string().optional(),
|
|
21
|
+
/** Which provider observed it. Provenance, set by the merge step. */
|
|
22
|
+
source: z.string().optional(),
|
|
23
|
+
level: Level.default('container'),
|
|
24
|
+
/** Free-form metadata. Excluded from drift comparison by default. */
|
|
25
|
+
tags: z.record(z.string()).optional(),
|
|
26
|
+
});
|
|
27
|
+
export const Edge = z.object({
|
|
28
|
+
from: z.string().min(1),
|
|
29
|
+
to: z.string().min(1),
|
|
30
|
+
kind: z.string().default('depends-on'),
|
|
31
|
+
label: z.string().optional(),
|
|
32
|
+
});
|
|
33
|
+
/**
|
|
34
|
+
* A scoped slice of the model. Flat Mermaid becomes unreadable past roughly
|
|
35
|
+
* 150 nodes, so views exist from v1 rather than being a later optimization.
|
|
36
|
+
* Patterns match an entity's id or group, and support a trailing `*`.
|
|
37
|
+
*/
|
|
38
|
+
export const View = z.object({
|
|
39
|
+
id: z.string().min(1),
|
|
40
|
+
title: z.string().optional(),
|
|
41
|
+
level: Level.default('container'),
|
|
42
|
+
include: z.array(z.string()).default([]),
|
|
43
|
+
exclude: z.array(z.string()).default([]),
|
|
44
|
+
});
|
|
45
|
+
/** Intentional divergence, reviewed like code. */
|
|
46
|
+
export const Ignore = z.object({
|
|
47
|
+
entities: z.array(z.string()).default([]),
|
|
48
|
+
kinds: z.array(z.string()).default([]),
|
|
49
|
+
edges: z.array(z.object({ from: z.string(), to: z.string() })).default([]),
|
|
50
|
+
});
|
|
51
|
+
export const Model = z.object({
|
|
52
|
+
version: z.literal(1),
|
|
53
|
+
name: z.string().min(1),
|
|
54
|
+
entities: z.array(Entity).default([]),
|
|
55
|
+
edges: z.array(Edge).default([]),
|
|
56
|
+
views: z.array(View).default([]),
|
|
57
|
+
ignore: Ignore.default({ entities: [], kinds: [], edges: [] }),
|
|
58
|
+
/**
|
|
59
|
+
* Cross-provider identity. Maps a provider's native id to the canonical
|
|
60
|
+
* entity id, so Dynatrace's `SERVICE-A1B2` and Terraform's
|
|
61
|
+
* `aws_lambda_function.forwarder` collapse into one node.
|
|
62
|
+
*
|
|
63
|
+
* This is deliberately manual. Automatic identity resolution across
|
|
64
|
+
* declarative and runtime sources is the hard, unsolved part of this
|
|
65
|
+
* problem, and guessing wrong silently corrupts the graph.
|
|
66
|
+
*/
|
|
67
|
+
aliases: z.record(z.string()).default({}),
|
|
68
|
+
/**
|
|
69
|
+
* Declared blind spots. Read-only credentials never see everything, and
|
|
70
|
+
* "unknown" must never be silently reported as "absent".
|
|
71
|
+
*/
|
|
72
|
+
coverage: z
|
|
73
|
+
.array(z.object({ scope: z.string(), reason: z.string() }))
|
|
74
|
+
.default([]),
|
|
75
|
+
});
|
|
76
|
+
export function emptyModel(name) {
|
|
77
|
+
return Model.parse({ version: 1, name });
|
|
78
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Model } from './schema.js';
|
|
2
|
+
export interface Issue {
|
|
3
|
+
severity: 'error' | 'warning';
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ValidationResult {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
model?: Model;
|
|
9
|
+
issues: Issue[];
|
|
10
|
+
}
|
|
11
|
+
/** Matches an id or group against a pattern supporting a trailing `*`. */
|
|
12
|
+
export declare function matches(pattern: string, value: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Schema validation alone isn't enough — a model can be well-formed YAML and
|
|
15
|
+
* still be nonsense (an edge pointing at an entity that doesn't exist, two
|
|
16
|
+
* entities sharing an id). Those are the failures that would produce a broken
|
|
17
|
+
* diagram silently, so they're errors, not warnings.
|
|
18
|
+
*/
|
|
19
|
+
export declare function validateModel(raw: unknown): ValidationResult;
|
|
20
|
+
export declare function loadModel(source: string): ValidationResult;
|
|
21
|
+
export declare function dumpModel(model: Model): string;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
2
|
+
import { Model } from './schema.js';
|
|
3
|
+
/** Matches an id or group against a pattern supporting a trailing `*`. */
|
|
4
|
+
export function matches(pattern, value) {
|
|
5
|
+
if (pattern === '*')
|
|
6
|
+
return true;
|
|
7
|
+
if (pattern.endsWith('*'))
|
|
8
|
+
return value.startsWith(pattern.slice(0, -1));
|
|
9
|
+
return pattern === value;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Schema validation alone isn't enough — a model can be well-formed YAML and
|
|
13
|
+
* still be nonsense (an edge pointing at an entity that doesn't exist, two
|
|
14
|
+
* entities sharing an id). Those are the failures that would produce a broken
|
|
15
|
+
* diagram silently, so they're errors, not warnings.
|
|
16
|
+
*/
|
|
17
|
+
export function validateModel(raw) {
|
|
18
|
+
const parsed = Model.safeParse(raw);
|
|
19
|
+
if (!parsed.success) {
|
|
20
|
+
return {
|
|
21
|
+
ok: false,
|
|
22
|
+
issues: parsed.error.issues.map((i) => ({
|
|
23
|
+
severity: 'error',
|
|
24
|
+
message: `${i.path.join('.') || '(root)'}: ${i.message}`,
|
|
25
|
+
})),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const model = parsed.data;
|
|
29
|
+
const issues = [];
|
|
30
|
+
const ids = new Set();
|
|
31
|
+
for (const e of model.entities) {
|
|
32
|
+
if (ids.has(e.id)) {
|
|
33
|
+
issues.push({ severity: 'error', message: `duplicate entity id: ${e.id}` });
|
|
34
|
+
}
|
|
35
|
+
ids.add(e.id);
|
|
36
|
+
}
|
|
37
|
+
for (const edge of model.edges) {
|
|
38
|
+
if (!ids.has(edge.from)) {
|
|
39
|
+
issues.push({ severity: 'error', message: `edge references unknown entity: ${edge.from} (in ${edge.from} -> ${edge.to})` });
|
|
40
|
+
}
|
|
41
|
+
if (!ids.has(edge.to)) {
|
|
42
|
+
issues.push({ severity: 'error', message: `edge references unknown entity: ${edge.to} (in ${edge.from} -> ${edge.to})` });
|
|
43
|
+
}
|
|
44
|
+
if (edge.from === edge.to) {
|
|
45
|
+
issues.push({ severity: 'warning', message: `self-referencing edge on ${edge.from}` });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const viewIds = new Set();
|
|
49
|
+
for (const view of model.views) {
|
|
50
|
+
if (viewIds.has(view.id)) {
|
|
51
|
+
issues.push({ severity: 'error', message: `duplicate view id: ${view.id}` });
|
|
52
|
+
}
|
|
53
|
+
viewIds.add(view.id);
|
|
54
|
+
// A view that selects nothing is almost always a typo in a pattern.
|
|
55
|
+
const hit = model.entities.some((e) => view.include.length === 0
|
|
56
|
+
? true
|
|
57
|
+
: view.include.some((p) => matches(p, e.id) || (e.group != null && matches(p, e.group))));
|
|
58
|
+
if (!hit) {
|
|
59
|
+
issues.push({ severity: 'warning', message: `view '${view.id}' matches no entities` });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { ok: !issues.some((i) => i.severity === 'error'), model, issues };
|
|
63
|
+
}
|
|
64
|
+
export function loadModel(source) {
|
|
65
|
+
let raw;
|
|
66
|
+
try {
|
|
67
|
+
raw = parseYaml(source);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
return { ok: false, issues: [{ severity: 'error', message: `invalid YAML: ${err.message}` }] };
|
|
71
|
+
}
|
|
72
|
+
return validateModel(raw);
|
|
73
|
+
}
|
|
74
|
+
export function dumpModel(model) {
|
|
75
|
+
return stringifyYaml(model, { lineWidth: 0 });
|
|
76
|
+
}
|