@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/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
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();
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
+
}
|