@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anthony Brignano
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,310 @@
1
+ # driftwood
2
+
3
+ **Architecture as code, reconciled with live infrastructure.**
4
+
5
+ A versioned architecture *model* in git that is continuously checked against reality — Terraform state today, cloud APIs and telemetry later. When the model and the infrastructure diverge, that divergence becomes a pull request instead of a diagram nobody trusts.
6
+
7
+ > Status: **proof of concept.** The core loop works end to end. See [Where this is going](#where-this-is-going) for what is deliberately not built yet.
8
+
9
+ ## Why
10
+
11
+ Two problems that are usually treated separately share one root cause:
12
+
13
+ - **Diagrams-as-code tools** (Structurizr, C4, `mingrammer/diagrams`, D2) move the picture into git but don't stop it rotting. A hand-maintained model decays exactly like a Visio file, just with better blame.
14
+ - **Live topology tools** (Dynatrace Smartscape, Datadog Service Map, Kiali) show real discovered topology but produce no artifact — nothing to version, nothing to review, no expression of design *intent*, and nothing a coding agent can edit.
15
+
16
+ Nobody owns the middle. driftwood is the middle.
17
+
18
+ ### Graphviz: bundled, not required
19
+
20
+ `mingrammer/diagrams` requires the Graphviz **system binary**, which is often impossible to get approved inside a corporate environment. driftwood ships Graphviz instead of requiring it.
21
+
22
+ `@hpcc-js/wasm-graphviz` is real Graphviz compiled to WebAssembly — same DOT semantics, same layouts, zero transitive dependencies, WASM inlined into the JS. It is a **regular dependency**, so a plain `npm install` gives you working Graphviz on any machine, with no system package and no admin rights.
23
+
24
+ | Engine | Needs | Output |
25
+ |---|---|---|
26
+ | `graphviz` (native) | `dot` on PATH | SVG. Preferred when present — faster on very large graphs, honours a site's own Graphviz build |
27
+ | `graphviz` (WASM) | **nothing — bundled** | SVG. The default |
28
+ | `dot` | nothing | DOT source (it's just text) |
29
+ | `mermaid` | nothing | Mermaid, renders natively in GitHub |
30
+
31
+ Out of the box:
32
+
33
+ ```
34
+ $ driftwood engines
35
+ graphviz available bundled @hpcc-js/wasm-graphviz
36
+ mermaid available built-in
37
+ dot available built-in
38
+
39
+ auto would use: graphviz (bundled @hpcc-js/wasm-graphviz)
40
+ ```
41
+
42
+ The one environment where Graphviz still can't run is a runtime with WebAssembly switched off — a hardened container, or `node --jitless`. There `auto` degrades to Mermaid rather than failing:
43
+
44
+ ```
45
+ $ node --jitless dist/cli.js engines
46
+ graphviz unavailable WebAssembly is disabled in this runtime, so the bundled Graphviz cannot load - install the `dot` binary or use --engine mermaid
47
+ ...
48
+ auto would use: mermaid (built-in)
49
+ ```
50
+
51
+ Rare, but real — which is why the fallback isn't vestigial. Both paths are asserted in CI.
52
+
53
+ For size context, the bundled Graphviz is **smaller than `zod`**, which driftwood already depends on:
54
+
55
+ | Package | Size | Transitive deps |
56
+ |---|---|---|
57
+ | `zod` | 5.2 MB | — |
58
+ | `@hpcc-js/wasm-graphviz` | 2.1 MB (804 KB runtime) | none |
59
+ | `yaml` | 1.4 MB | — |
60
+
61
+ ## How it fits together
62
+
63
+ ```mermaid
64
+ flowchart LR
65
+ subgraph Providers["Providers — observe"]
66
+ TF["Terraform state"]
67
+ SOON["cloud APIs · OTel<br/>(not yet)"]
68
+ end
69
+ subgraph Core["Core"]
70
+ MODEL["architecture.yaml<br/>entities · edges · views<br/>(versioned in git)"]
71
+ REC["Reconciler<br/>declared vs observed"]
72
+ end
73
+ subgraph Renderers["Renderers — present"]
74
+ MMD["Mermaid"]
75
+ LATER["2D live · 3D<br/>(not yet)"]
76
+ end
77
+ TF --> REC
78
+ SOON -.-> REC
79
+ MODEL --> REC
80
+ REC -->|"divergence"| MODEL
81
+ MODEL --> MMD
82
+ MODEL -.-> LATER
83
+ ```
84
+
85
+ The model is the only thing in git. Providers write into it, renderers read from it, the reconciler diffs it. Because everything routes through one model, "static diagram vs. live health" and "2D vs. 3D" are rendering modes rather than rewrites — health is just an attribute on a node, and blast radius is a traversal over edges that already exist.
86
+
87
+ ## Install
88
+
89
+ ```bash
90
+ npm install
91
+ npm run build
92
+ ```
93
+
94
+ Requires Node 20+. Runtime dependencies are `commander`, `yaml`, `zod`, and `@hpcc-js/wasm-graphviz` — all pure JavaScript/WebAssembly, no native builds and no system packages.
95
+
96
+ ## Usage
97
+
98
+ ### Import a model from Terraform state
99
+
100
+ ```bash
101
+ npx tsx src/cli.ts import terraform examples/aws-config.tfstate.json \
102
+ --name aws-config -o examples/architecture.yaml
103
+ ```
104
+
105
+ Entity ids are Terraform addresses (`aws_s3_bucket.emails`). That's deliberate: the address is stable across plans, readable in a diff, and sidesteps the identity-resolution problem that kills CMDBs. Edges come from Terraform's own `dependencies`.
106
+
107
+ ### Validate
108
+
109
+ ```bash
110
+ npx tsx src/cli.ts validate examples/architecture.yaml
111
+ ```
112
+
113
+ Catches schema errors, duplicate ids, edges pointing at entities that don't exist, and views that match nothing. This is what makes agent edits safe to accept — a coding agent can rewrite the model and CI proves it's still coherent.
114
+
115
+ ### Render
116
+
117
+ ```bash
118
+ npx tsx src/cli.ts render examples/architecture.yaml --view email
119
+ ```
120
+
121
+ ```mermaid
122
+ flowchart LR
123
+ subgraph n_lambda["lambda"]
124
+ n_aws_lambda_function_email_forwarder["email-forwarder<br/>aws_lambda_function"]
125
+ end
126
+ subgraph n_s3["s3"]
127
+ n_aws_s3_bucket_emails[("brignano.io-emails<br/>aws_s3_bucket")]
128
+ end
129
+ subgraph n_ses["ses"]
130
+ n_aws_ses_receipt_rule_set_main["main<br/>aws_ses_receipt_rule_set"]
131
+ n_aws_ses_receipt_rule_archive["archive-hi<br/>aws_ses_receipt_rule"]
132
+ n_aws_ses_receipt_rule_forward["forward-hi<br/>aws_ses_receipt_rule"]
133
+ n_aws_ses_receipt_rule_noreply["bounce-noreply<br/>aws_ses_receipt_rule"]
134
+ end
135
+ n_aws_lambda_function_email_forwarder --> n_aws_s3_bucket_emails
136
+ n_aws_ses_receipt_rule_archive --> n_aws_s3_bucket_emails
137
+ n_aws_ses_receipt_rule_archive --> n_aws_ses_receipt_rule_set_main
138
+ n_aws_ses_receipt_rule_forward --> n_aws_lambda_function_email_forwarder
139
+ n_aws_ses_receipt_rule_forward --> n_aws_ses_receipt_rule_set_main
140
+ n_aws_ses_receipt_rule_noreply --> n_aws_ses_receipt_rule_set_main
141
+ ```
142
+
143
+ **Views exist from v1, not as a later optimization.** Flat Mermaid becomes unreadable past roughly 150 nodes, and any real enterprise graph blows through that immediately. A view is a scoped slice matching entity ids or groups, with a trailing `*` wildcard.
144
+
145
+ ### Reconcile — the point of the whole thing
146
+
147
+ ```bash
148
+ npx tsx src/cli.ts reconcile examples/architecture.yaml \
149
+ --terraform examples/aws-config.drifted.tfstate.json
150
+ ```
151
+
152
+ ```markdown
153
+ ## Architecture drift detected
154
+
155
+ ### Present in infrastructure, missing from the model (2)
156
+ - `aws_cloudfront_distribution.cdn` - aws_cloudfront_distribution (d123.cloudfront.net)
157
+ - `aws_sqs_queue.dlq` - aws_sqs_queue (email-forwarder-dlq)
158
+
159
+ ### Declared in the model, not found in infrastructure (1)
160
+ - `aws_ses_receipt_rule.noreply` - aws_ses_receipt_rule (bounce-noreply)
161
+
162
+ ### Relationships
163
+ - **added** `aws_cloudfront_distribution.cdn` -> `aws_s3_bucket.emails`
164
+ - **added** `aws_sqs_queue.dlq` -> `aws_lambda_function.email_forwarder`
165
+ - **removed** `aws_ses_receipt_rule.noreply` -> `aws_ses_receipt_rule_set.main`
166
+ ```
167
+
168
+ Exits **1** on drift and **0** when clean, so it works directly as a CI gate. Output is markdown because its destination is a pull request body.
169
+
170
+ ## Extensible by design
171
+
172
+ Providers and renderers are both registries. A third-party plugin registers exactly the way a built-in does — there is no separate plugin API.
173
+
174
+ ### Providers — where facts come from
175
+
176
+ | Provider | Kind | Platforms | Status |
177
+ |---|---|---|---|
178
+ | `terraform` | declarative | **any** (AWS, GCP, Azure, vSphere, on-prem) | built in |
179
+ | `dynatrace` | runtime | aws, gcp, azure, onprem, kubernetes | built in |
180
+ | Splunk, cloud APIs, OTel, Kubernetes | — | — | extension point ready |
181
+
182
+ Terraform is platform-agnostic on purpose: one provider covers every target, because the platform is whatever the state file declares.
183
+
184
+ The `declarative` / `runtime` split matters. Terraform says what *should* exist; Dynatrace says what is *actually running*. A service Terraform declares but Dynatrace has never seen is a very different finding from one neither knows about. When both describe the same entity, the declarative source wins on naming and grouping — IaC resource names beat monitoring display names.
185
+
186
+ Adding one is a small, well-defined job: see [`.claude/skills/add-provider/SKILL.md`](.claude/skills/add-provider/SKILL.md).
187
+
188
+ ### Configuration
189
+
190
+ Wiring is declarative, so adding a provider or switching engines is a config edit rather than a code change:
191
+
192
+ ```yaml
193
+ # driftwood.config.yaml
194
+ model: architecture.yaml
195
+
196
+ providers:
197
+ - use: terraform
198
+ with:
199
+ statePath: ./terraform.tfstate
200
+ - use: dynatrace
201
+ with:
202
+ url: https://abc12345.live.dynatrace.com
203
+ tokenEnv: DYNATRACE_API_TOKEN # the env var NAME, never the token
204
+
205
+ render:
206
+ - view: context
207
+ to: docs/context.mmd
208
+ engine: auto
209
+ ```
210
+
211
+ ```bash
212
+ driftwood reconcile architecture.yaml -c driftwood.config.yaml
213
+ ```
214
+
215
+ ### Cross-source identity is explicit, never guessed
216
+
217
+ Terraform calls it `aws_lambda_function.forwarder`; Dynatrace calls it `SERVICE-A1B2`. Nothing in either payload proves they are the same thing, so driftwood **does not guess**:
218
+
219
+ ```yaml
220
+ aliases:
221
+ SERVICE-A1B2: aws_lambda_function.forwarder
222
+ ```
223
+
224
+ Without an alias the two stay separate nodes. That is deliberate — a duplicated node is visible and fixable, whereas a wrongly merged node silently corrupts the graph. Where providers disagree about a merged entity, the disagreement is resolved *and reported*, never hidden.
225
+
226
+ ## The drift policy
227
+
228
+ This is the design decision most likely to sink the project in practice. Report too much and every run becomes noise that gets muted; report too little and the model rots anyway.
229
+
230
+ **Default: structural facts count, metadata doesn't.**
231
+
232
+ | Change | Drift? |
233
+ |---|---|
234
+ | A resource appears or disappears | **Yes** |
235
+ | An edge appears or disappears | **Yes** |
236
+ | `kind`, `name`, or `group` changes | **Yes** |
237
+ | A tag is added or changed | No |
238
+ | Anything matching an `ignore` rule | No |
239
+
240
+ `ignore` holds intentional divergence, reviewed like code. An edge touching an ignored entity is ignored by implication — otherwise ignoring one noisy resource would still surface all of its edges.
241
+
242
+ ## Coverage gaps are explicit
243
+
244
+ Read-only credentials never see everything, and **unknown must never be silently reported as absent**. The model declares its own blind spots:
245
+
246
+ ```yaml
247
+ coverage:
248
+ - scope: aws_secretsmanager_*
249
+ reason: the read-only role used by CI cannot list secrets
250
+ ```
251
+
252
+ These are printed with every drift report. "Always matches live platform data" is a promise no tool can keep; reconciliation with declared blind spots is one it can.
253
+
254
+ ## Project layout
255
+
256
+ ```
257
+ src/
258
+ registry.ts shared name -> implementation registry
259
+ model/schema.ts the model — entities, edges, views, aliases, ignore, coverage
260
+ model/validate.ts schema + referential integrity
261
+ model/merge.ts multi-provider merge, provenance, conflict reporting
262
+ providers/types.ts the provider extension point
263
+ providers/terraform.ts declarative — any platform Terraform manages
264
+ providers/dynatrace.ts runtime — Smartscape topology, read-only
265
+ render/types.ts the renderer extension point (probe + render)
266
+ render/select.ts shared view scoping
267
+ render/mermaid.ts always available
268
+ render/dot.ts DOT source, always available
269
+ render/graphviz.ts SVG via native dot or WASM, with tier detection
270
+ reconcile/index.ts declared vs observed -> drift report
271
+ config.ts driftwood.config.yaml
272
+ cli.ts validate · render · engines · providers · import · reconcile
273
+ examples/ a worked AWS example, a drifted copy, and a config
274
+ .claude/skills/ add-provider and add-renderer walkthroughs for agents
275
+ ```
276
+
277
+ ## Development
278
+
279
+ ```bash
280
+ npm test # 76 tests
281
+ npm run typecheck
282
+ npm run build
283
+ ```
284
+
285
+ ## Where this is going
286
+
287
+ Built:
288
+
289
+ - [x] The model, with a schema and a real validator
290
+ - [x] Pluggable provider registry — Terraform (any platform) and Dynatrace built in
291
+ - [x] Pluggable renderer registry — Graphviz bundled and working out of the box, with Mermaid/DOT fallback
292
+ - [x] Multi-provider merge with provenance, explicit aliases, and conflict reporting
293
+ - [x] Declarative `driftwood.config.yaml` wiring
294
+ - [x] Reconciler with an explicit drift policy, wired as a CI gate
295
+
296
+ Deliberately not built yet, roughly in order:
297
+
298
+ - [ ] Open the drift report as an actual pull request, not just a CI failure
299
+ - [ ] Live cloud API providers (AWS, GCP) to catch resources no IaC owns
300
+ - [ ] A Splunk provider (the extension point is ready; no implementation shipped yet)
301
+ - [ ] Preserve human/agent annotations across regeneration
302
+ - [ ] Health overlay on the 2D graph (the renderer already accepts it)
303
+ - [ ] Interactive viewer, blast-radius traversal
304
+ - [ ] 3D — last, optional, and only if someone actually asks
305
+
306
+ **On 3D:** it's the reward, not the plan. Netflix's Vizceral was the flagship of exactly this concept and is effectively abandoned; Cloudcraft deliberately stopped at 2.5D isometric because it stays readable and screenshot-able. 3D topology demos brilliantly and then goes unused during incidents — it occludes, doesn't diff, doesn't paste into a postmortem, and needs a mouse. Build the model first and a 3D view stays cheap to add later.
307
+
308
+ ## License
309
+
310
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { Command } from 'commander';
4
+ import { dumpModel, loadModel } from './model/validate.js';
5
+ import { importTerraformState, parseTerraformState } from './providers/terraform.js';
6
+ import { providers } from './providers/index.js';
7
+ import { render, renderers, resolveRenderer } from './render/index.js';
8
+ import { formatDrift, reconcile } from './reconcile/index.js';
9
+ import { formatConflicts } from './model/merge.js';
10
+ import { loadConfig, observeAll } from './config.js';
11
+ const program = new Command();
12
+ program
13
+ .name('driftwood')
14
+ .description('Architecture as code, reconciled with live infrastructure.')
15
+ .version('0.0.1');
16
+ function requireModel(path) {
17
+ const result = loadModel(readFileSync(path, 'utf8'));
18
+ for (const issue of result.issues)
19
+ console.error(`${issue.severity}: ${issue.message}`);
20
+ if (!result.ok || !result.model) {
21
+ console.error(`\n${path} is not a valid model.`);
22
+ process.exit(1);
23
+ }
24
+ return result.model;
25
+ }
26
+ program
27
+ .command('validate')
28
+ .description('Check a model for schema and referential-integrity errors')
29
+ .argument('<model>', 'path to architecture.yaml')
30
+ .action((path) => {
31
+ const model = requireModel(path);
32
+ console.log(`ok — ${model.entities.length} entities, ${model.edges.length} edges, ${model.views.length} views`);
33
+ });
34
+ program
35
+ .command('render')
36
+ .description('Render a model with the best available engine')
37
+ .argument('<model>', 'path to architecture.yaml')
38
+ .option('--view <id>', 'render a single named view')
39
+ .option('--engine <name>', 'auto | mermaid | dot | graphviz', 'auto')
40
+ .option('--direction <dir>', 'LR or TD', 'LR')
41
+ .option('-o, --out <file>', 'write to a file instead of stdout')
42
+ .action(async (path, opts) => {
43
+ const model = requireModel(path);
44
+ const direction = opts.direction === 'TD' ? 'TD' : 'LR';
45
+ const result = await render(model, { view: opts.view, direction }, opts.engine);
46
+ if (result.fellBackFrom) {
47
+ console.error(`note: ${result.fellBackFrom} unavailable, using ${result.renderer.name} (${result.via})`);
48
+ }
49
+ if (opts.out) {
50
+ writeFileSync(opts.out, result.output);
51
+ console.error(`wrote ${opts.out} via ${result.renderer.name} (${result.via})`);
52
+ }
53
+ else {
54
+ process.stdout.write(result.output);
55
+ }
56
+ });
57
+ program
58
+ .command('engines')
59
+ .description('Show which render engines are usable in this environment')
60
+ .action(async () => {
61
+ for (const renderer of [...renderers.all()].sort((a, b) => b.priority - a.priority)) {
62
+ const probe = await renderer.probe();
63
+ const mark = probe.available ? 'available' : 'unavailable';
64
+ const detail = probe.available ? probe.via : probe.reason;
65
+ console.log(`${renderer.name.padEnd(10)} ${mark.padEnd(12)} ${detail ?? ''}`);
66
+ }
67
+ const chosen = await resolveRenderer('auto');
68
+ console.log(`\nauto would use: ${chosen.renderer.name} (${chosen.via})`);
69
+ });
70
+ program
71
+ .command('providers')
72
+ .description('List registered providers')
73
+ .action(() => {
74
+ for (const p of providers.all()) {
75
+ console.log(`${p.name.padEnd(12)} ${p.kind.padEnd(12)} ${p.platforms.join(',').padEnd(14)} ${p.description}`);
76
+ }
77
+ });
78
+ const importCmd = program.command('import').description('Build a model from a provider');
79
+ importCmd
80
+ .command('terraform')
81
+ .description('Import entities and edges from Terraform state (format v4)')
82
+ .argument('<state>', 'path to terraform.tfstate or `terraform show -json` output')
83
+ .option('--name <name>', 'model name', 'terraform')
84
+ .option('--include-data-sources', 'include data sources as entities', false)
85
+ .option('-o, --out <file>', 'write to a file instead of stdout')
86
+ .action((statePath, opts) => {
87
+ const state = parseTerraformState(readFileSync(statePath, 'utf8'));
88
+ const model = importTerraformState(state, {
89
+ modelName: opts.name,
90
+ includeDataSources: opts.includeDataSources,
91
+ });
92
+ const yaml = dumpModel(model);
93
+ if (opts.out) {
94
+ writeFileSync(opts.out, yaml);
95
+ console.error(`wrote ${opts.out} — ${model.entities.length} entities, ${model.edges.length} edges`);
96
+ }
97
+ else {
98
+ process.stdout.write(yaml);
99
+ }
100
+ });
101
+ program
102
+ .command('reconcile')
103
+ .description('Diff the committed model against observed infrastructure')
104
+ .argument('<model>', 'path to architecture.yaml')
105
+ .option('-c, --config <file>', 'driftwood.config.yaml describing the providers to observe with')
106
+ .option('--terraform <state>', 'shorthand for a single Terraform state file')
107
+ .option('--include-data-sources', 'include data sources as entities', false)
108
+ .option('-o, --out <file>', 'write the markdown report to a file')
109
+ .option('--exit-zero', 'always exit 0, even when drift is found', false)
110
+ .action(async (modelPath, opts) => {
111
+ const declared = requireModel(modelPath);
112
+ let observed;
113
+ let conflictReport = '';
114
+ if (opts.config) {
115
+ const loaded = loadConfig(readFileSync(opts.config, 'utf8'), opts.config);
116
+ const merged = await observeAll(loaded, declared, (m) => console.error(m));
117
+ observed = merged.model;
118
+ conflictReport = formatConflicts(merged.conflicts);
119
+ }
120
+ else if (opts.terraform) {
121
+ const state = parseTerraformState(readFileSync(opts.terraform, 'utf8'));
122
+ observed = importTerraformState(state, {
123
+ modelName: declared.name,
124
+ includeDataSources: opts.includeDataSources,
125
+ });
126
+ }
127
+ else {
128
+ console.error('provide either --config <file> or --terraform <state>');
129
+ process.exit(2);
130
+ }
131
+ const drift = reconcile(declared, observed);
132
+ const report = [formatDrift(drift), conflictReport].filter(Boolean).join('\n');
133
+ if (opts.out) {
134
+ writeFileSync(opts.out, report);
135
+ console.error(`wrote ${opts.out}`);
136
+ }
137
+ console.log(report);
138
+ if (drift.hasDrift && !opts.exitZero)
139
+ process.exit(1);
140
+ });
141
+ program.parseAsync();
@@ -0,0 +1,117 @@
1
+ import { z } from 'zod';
2
+ import type { ProviderContext } from './providers/types.js';
3
+ import type { Model } from './model/schema.js';
4
+ import type { MergeResult } from './model/merge.js';
5
+ /**
6
+ * `driftwood.config.yaml` — the plug-and-play surface.
7
+ *
8
+ * Which providers to observe with and which renderers to emit are both
9
+ * declared here, so adding Dynatrace alongside Terraform, or switching from
10
+ * Mermaid to Graphviz, is a config edit rather than a code change.
11
+ */
12
+ export declare const ProviderUse: z.ZodObject<{
13
+ use: z.ZodString;
14
+ /** Optional instance label, so the same provider can appear twice. */
15
+ as: z.ZodOptional<z.ZodString>;
16
+ with: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
17
+ }, "strip", z.ZodTypeAny, {
18
+ use: string;
19
+ with: Record<string, unknown>;
20
+ as?: string | undefined;
21
+ }, {
22
+ use: string;
23
+ as?: string | undefined;
24
+ with?: Record<string, unknown> | undefined;
25
+ }>;
26
+ export declare const RenderTarget: z.ZodObject<{
27
+ view: z.ZodOptional<z.ZodString>;
28
+ to: z.ZodString;
29
+ engine: z.ZodDefault<z.ZodString>;
30
+ direction: z.ZodDefault<z.ZodEnum<["LR", "TD"]>>;
31
+ }, "strip", z.ZodTypeAny, {
32
+ to: string;
33
+ engine: string;
34
+ direction: "LR" | "TD";
35
+ view?: string | undefined;
36
+ }, {
37
+ to: string;
38
+ view?: string | undefined;
39
+ engine?: string | undefined;
40
+ direction?: "LR" | "TD" | undefined;
41
+ }>;
42
+ export declare const Config: z.ZodObject<{
43
+ model: z.ZodDefault<z.ZodString>;
44
+ providers: z.ZodDefault<z.ZodArray<z.ZodObject<{
45
+ use: z.ZodString;
46
+ /** Optional instance label, so the same provider can appear twice. */
47
+ as: z.ZodOptional<z.ZodString>;
48
+ with: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
49
+ }, "strip", z.ZodTypeAny, {
50
+ use: string;
51
+ with: Record<string, unknown>;
52
+ as?: string | undefined;
53
+ }, {
54
+ use: string;
55
+ as?: string | undefined;
56
+ with?: Record<string, unknown> | undefined;
57
+ }>, "many">>;
58
+ render: z.ZodDefault<z.ZodArray<z.ZodObject<{
59
+ view: z.ZodOptional<z.ZodString>;
60
+ to: z.ZodString;
61
+ engine: z.ZodDefault<z.ZodString>;
62
+ direction: z.ZodDefault<z.ZodEnum<["LR", "TD"]>>;
63
+ }, "strip", z.ZodTypeAny, {
64
+ to: string;
65
+ engine: string;
66
+ direction: "LR" | "TD";
67
+ view?: string | undefined;
68
+ }, {
69
+ to: string;
70
+ view?: string | undefined;
71
+ engine?: string | undefined;
72
+ direction?: "LR" | "TD" | undefined;
73
+ }>, "many">>;
74
+ }, "strip", z.ZodTypeAny, {
75
+ model: string;
76
+ providers: {
77
+ use: string;
78
+ with: Record<string, unknown>;
79
+ as?: string | undefined;
80
+ }[];
81
+ render: {
82
+ to: string;
83
+ engine: string;
84
+ direction: "LR" | "TD";
85
+ view?: string | undefined;
86
+ }[];
87
+ }, {
88
+ model?: string | undefined;
89
+ providers?: {
90
+ use: string;
91
+ as?: string | undefined;
92
+ with?: Record<string, unknown> | undefined;
93
+ }[] | undefined;
94
+ render?: {
95
+ to: string;
96
+ view?: string | undefined;
97
+ engine?: string | undefined;
98
+ direction?: "LR" | "TD" | undefined;
99
+ }[] | undefined;
100
+ }>;
101
+ export type Config = z.infer<typeof Config>;
102
+ export interface LoadedConfig {
103
+ config: Config;
104
+ /** Directory of the config file; all relative paths resolve against it. */
105
+ baseDir: string;
106
+ }
107
+ export declare function loadConfig(source: string, path: string): LoadedConfig;
108
+ export declare function makeContext(baseDir: string, log?: (m: string) => void): ProviderContext;
109
+ /**
110
+ * Runs every configured provider and merges the results.
111
+ *
112
+ * Providers run concurrently because they're independent network/disk reads,
113
+ * but a single provider failing must not silently produce a half-empty model
114
+ * that the reconciler would then report as mass deletion — so any failure
115
+ * aborts the whole observation.
116
+ */
117
+ export declare function observeAll(loaded: LoadedConfig, declared: Model, log?: (m: string) => void): Promise<MergeResult>;
package/dist/config.js ADDED
@@ -0,0 +1,72 @@
1
+ import { dirname, isAbsolute, resolve } from 'node:path';
2
+ import { parse as parseYaml } from 'yaml';
3
+ import { z } from 'zod';
4
+ import { providers } from './providers/index.js';
5
+ import { mergeModels } from './model/merge.js';
6
+ /**
7
+ * `driftwood.config.yaml` — the plug-and-play surface.
8
+ *
9
+ * Which providers to observe with and which renderers to emit are both
10
+ * declared here, so adding Dynatrace alongside Terraform, or switching from
11
+ * Mermaid to Graphviz, is a config edit rather than a code change.
12
+ */
13
+ export const ProviderUse = z.object({
14
+ use: z.string(),
15
+ /** Optional instance label, so the same provider can appear twice. */
16
+ as: z.string().optional(),
17
+ with: z.record(z.unknown()).default({}),
18
+ });
19
+ export const RenderTarget = z.object({
20
+ view: z.string().optional(),
21
+ to: z.string(),
22
+ engine: z.string().default('auto'),
23
+ direction: z.enum(['LR', 'TD']).default('LR'),
24
+ });
25
+ export const Config = z.object({
26
+ model: z.string().default('architecture.yaml'),
27
+ providers: z.array(ProviderUse).default([]),
28
+ render: z.array(RenderTarget).default([]),
29
+ });
30
+ export function loadConfig(source, path) {
31
+ const raw = parseYaml(source);
32
+ const parsed = Config.safeParse(raw);
33
+ if (!parsed.success) {
34
+ const detail = parsed.error.issues.map((i) => ` ${i.path.join('.') || '(root)'}: ${i.message}`).join('\n');
35
+ throw new Error(`invalid config at ${path}:\n${detail}`);
36
+ }
37
+ return { config: parsed.data, baseDir: dirname(resolve(path)) };
38
+ }
39
+ export function makeContext(baseDir, log = () => { }) {
40
+ return {
41
+ secret: (name) => process.env[name],
42
+ resolvePath: (p) => (isAbsolute(p) ? p : resolve(baseDir, p)),
43
+ log,
44
+ };
45
+ }
46
+ /**
47
+ * Runs every configured provider and merges the results.
48
+ *
49
+ * Providers run concurrently because they're independent network/disk reads,
50
+ * but a single provider failing must not silently produce a half-empty model
51
+ * that the reconciler would then report as mass deletion — so any failure
52
+ * aborts the whole observation.
53
+ */
54
+ export async function observeAll(loaded, declared, log = () => { }) {
55
+ const ctx = makeContext(loaded.baseDir, log);
56
+ const results = await Promise.all(loaded.config.providers.map(async (use) => {
57
+ const provider = providers.get(use.use);
58
+ const parsed = provider.configSchema.safeParse(use.with);
59
+ if (!parsed.success) {
60
+ const detail = parsed.error.issues.map((i) => ` ${i.path.join('.') || '(root)'}: ${i.message}`).join('\n');
61
+ throw new Error(`invalid config for provider '${use.use}':\n${detail}`);
62
+ }
63
+ const model = await provider.observe(parsed.data, ctx);
64
+ const label = use.as ?? use.use;
65
+ log(`${label}: ${model.entities.length} entities, ${model.edges.length} edges`);
66
+ return { provider: label, kind: provider.kind, model };
67
+ }));
68
+ if (results.length === 0) {
69
+ throw new Error('no providers configured — add at least one entry under `providers:`');
70
+ }
71
+ return mergeModels(results, declared.aliases);
72
+ }