diff --git a/.gitignore b/.gitignore index 4cead2b..20ca00c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ demo/ *.zip coverage.out dist/ +examples/datadog-to-grafana/.openexit/ +examples/datadog-to-grafana/migration/ .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d09a42..694e1bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +## 0.1.0 - 2026-07-18 + +- Refocused the v0.1 product on one workflow: `openexit datadog scan`, `plan --target grafana-lgtm`, and `export --out `. +- Added a versioned, endpoint-auditable Datadog observability catalog with GET-only collection, full pagination, redacted evidence, fail-closed partial-scan handling, and stale-plan invalidation. +- Added deterministic per-resource and per-component conversion statuses (`exact`, `approximate`, `manual`, and `unsupported`) with explicit semantic changes and no fake `vector(0)` alert placeholders. +- Added source-linked Grafana dashboards, Prometheus alert candidates, Alloy/OpenTelemetry baselines, a self-contained HTML migration report, and a transparent exit-readiness formula. +- Added schema-backed provenance and validation manifests plus transactional directory export with per-file source references and `SHA256SUMS`. +- Moved the earlier multi-provider engine under `openexit experimental` while retaining hidden root aliases for compatibility. - Added `openexit doctor` for local runtime diagnostics covering version metadata, embedded schemas, and optional validator availability. - Added preview support for fixture-based OpenAI/Anthropic to vLLM/LiteLLM assessment. - Added explicit source/target project initialization and validation consistency checks for all assessment paths. @@ -47,8 +55,6 @@ - Updated push CI to run the same release readiness gate, including smoke pipelines and bundle verification. - Updated CI and release workflows to Node.js 24-native GitHub Actions. -## 0.1.0 - 2026-05-24 - - Initial OpenExit implementation. - Added mocked live Datadog collector coverage and evidence ref hardening. - Added conservative simple Datadog threshold to PromQL candidate conversion. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 872ce68..77a483f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,14 +11,15 @@ make build make release-dist VERSION=0.1.0-dev ``` -Keep changes local-first and deterministic. New collectors must default to read-only behavior, must never store credentials, and must redact raw evidence before it is written to disk. +Keep changes local-first and deterministic. The primary Datadog client must remain GET-only, must never store credentials, and must redact source evidence before it is written to disk. ## Design Principles - Prefer explicit risk and manual-review flags over optimistic conversion. -- Keep AI optional and outside the source of truth. -- Add tests for new analyzer rules, collectors, and generated artifacts. +- Keep AI conversion and automatic deployment out of the primary v0.1 workflow. +- Add tests for catalog endpoints, pagination, redaction, converters, provenance, scoring, and generated artifacts. - Generated target files must be labeled as candidates. +- Never emit executable placeholders for behavior that was not translated. ## Pull Requests diff --git a/Makefile b/Makefile index 4eca33a..b8b4e3b 100644 --- a/Makefile +++ b/Makefile @@ -12,10 +12,10 @@ RELEASE_ASSETS ?= install.sh openexit.bash _openexit openexit.fish openexit.ps1 GOLANGCI_LINT_VERSION ?= v2.12.2 GOLANGCI_LINT ?= bin/golangci-lint EXAMPLE_INPUT ?= examples/datadog-to-grafana/input/datadog-fixture.json -EXAMPLE_DIR ?= examples/datadog-to-grafana/output -EXAMPLE_BUNDLE ?= examples/datadog-to-grafana/openexit-example.zip +EXAMPLE_STATE ?= examples/datadog-to-grafana/.openexit +EXAMPLE_DIR ?= examples/datadog-to-grafana/migration -.PHONY: build test fmt fmt-check lint golangci-lint smoke example example-smoke verify release-dist install-smoke release-check clean +.PHONY: build test fmt fmt-check lint golangci-lint smoke experimental-smoke example example-smoke verify release-dist install-smoke release-check clean build: mkdir -p $(dir $(BINARY)) @@ -41,82 +41,76 @@ smoke: tmp=$$(mktemp -d); \ trap 'rm -rf "$$tmp"' EXIT; \ $(BINARY) doctor; \ - $(BINARY) demo "$$tmp/builtin-demo" --out "$$tmp/builtin-demo.zip"; \ + $(BINARY) datadog scan --fixture ./testdata/datadog/small.json --workdir "$$tmp/.openexit"; \ + $(BINARY) datadog plan --target grafana-lgtm --workdir "$$tmp/.openexit"; \ + $(BINARY) datadog export --out "$$tmp/migration" --workdir "$$tmp/.openexit"; \ + test -s "$$tmp/migration/index.html"; \ + test -s "$$tmp/migration/manifest.json"; \ + test -s "$$tmp/migration/SHA256SUMS"; \ + ! grep -R 'vector(0)' "$$tmp/migration/generated" + +experimental-smoke: + tmp=$$(mktemp -d); \ + trap 'rm -rf "$$tmp"' EXIT; \ + $(BINARY) experimental demo "$$tmp/builtin-demo" --out "$$tmp/builtin-demo.zip"; \ test -s "$$tmp/builtin-demo.zip"; \ $(BINARY) verify-bundle "$$tmp/builtin-demo.zip"; \ - $(BINARY) init "$$tmp/datadog-demo"; \ - $(BINARY) collect fixture --project "$$tmp/datadog-demo" --input ./testdata/datadog/small.json; \ - $(BINARY) assess --project "$$tmp/datadog-demo" --target grafana-lgtm; \ - $(BINARY) map --project "$$tmp/datadog-demo"; \ - $(BINARY) generate --project "$$tmp/datadog-demo" --all; \ - $(BINARY) validate --project "$$tmp/datadog-demo"; \ - $(BINARY) export --project "$$tmp/datadog-demo" --format zip --out "$$tmp/openexit-demo.zip"; \ + $(BINARY) experimental init "$$tmp/datadog-demo"; \ + $(BINARY) experimental collect fixture --project "$$tmp/datadog-demo" --input ./testdata/datadog/small.json; \ + $(BINARY) experimental assess --project "$$tmp/datadog-demo" --target grafana-lgtm; \ + $(BINARY) experimental map --project "$$tmp/datadog-demo"; \ + $(BINARY) experimental generate --project "$$tmp/datadog-demo" --all; \ + $(BINARY) experimental validate --project "$$tmp/datadog-demo"; \ + $(BINARY) experimental export --project "$$tmp/datadog-demo" --format zip --out "$$tmp/openexit-demo.zip"; \ $(BINARY) verify-bundle "$$tmp/openexit-demo.zip"; \ - $(BINARY) init "$$tmp/ghe-demo" --source github-enterprise --target forgejo; \ - $(BINARY) collect github-fixture --project "$$tmp/ghe-demo" --input ./testdata/github-enterprise/small.json; \ - $(BINARY) assess --project "$$tmp/ghe-demo" --target forgejo; \ - $(BINARY) map --project "$$tmp/ghe-demo"; \ - $(BINARY) generate --project "$$tmp/ghe-demo" --all; \ - $(BINARY) validate --project "$$tmp/ghe-demo"; \ - $(BINARY) export --project "$$tmp/ghe-demo" --format zip --out "$$tmp/ghe-demo.zip"; \ + $(BINARY) experimental init "$$tmp/ghe-demo" --source github-enterprise --target forgejo; \ + $(BINARY) experimental collect github-fixture --project "$$tmp/ghe-demo" --input ./testdata/github-enterprise/small.json; \ + $(BINARY) experimental assess --project "$$tmp/ghe-demo" --target forgejo; \ + $(BINARY) experimental map --project "$$tmp/ghe-demo"; \ + $(BINARY) experimental generate --project "$$tmp/ghe-demo" --all; \ + $(BINARY) experimental validate --project "$$tmp/ghe-demo"; \ + $(BINARY) experimental export --project "$$tmp/ghe-demo" --format zip --out "$$tmp/ghe-demo.zip"; \ $(BINARY) verify-bundle "$$tmp/ghe-demo.zip"; \ - $(BINARY) init "$$tmp/identity-demo" --source identity --target keycloak-zitadel; \ - $(BINARY) collect identity-fixture --project "$$tmp/identity-demo" --input ./testdata/identity/small.json; \ - $(BINARY) assess --project "$$tmp/identity-demo" --target keycloak-zitadel; \ - $(BINARY) map --project "$$tmp/identity-demo"; \ - $(BINARY) generate --project "$$tmp/identity-demo" --all; \ - $(BINARY) validate --project "$$tmp/identity-demo"; \ - $(BINARY) export --project "$$tmp/identity-demo" --format zip --out "$$tmp/identity-demo.zip"; \ + $(BINARY) experimental init "$$tmp/identity-demo" --source identity --target keycloak-zitadel; \ + $(BINARY) experimental collect identity-fixture --project "$$tmp/identity-demo" --input ./testdata/identity/small.json; \ + $(BINARY) experimental assess --project "$$tmp/identity-demo" --target keycloak-zitadel; \ + $(BINARY) experimental map --project "$$tmp/identity-demo"; \ + $(BINARY) experimental generate --project "$$tmp/identity-demo" --all; \ + $(BINARY) experimental validate --project "$$tmp/identity-demo"; \ + $(BINARY) experimental export --project "$$tmp/identity-demo" --format zip --out "$$tmp/identity-demo.zip"; \ $(BINARY) verify-bundle "$$tmp/identity-demo.zip"; \ - $(BINARY) init "$$tmp/edge-demo" --source edge --target varnish-haproxy-coraza; \ - $(BINARY) collect edge-fixture --project "$$tmp/edge-demo" --input ./testdata/edge/small.json; \ - $(BINARY) assess --project "$$tmp/edge-demo" --target varnish-haproxy-coraza; \ - $(BINARY) map --project "$$tmp/edge-demo"; \ - $(BINARY) generate --project "$$tmp/edge-demo" --all; \ - $(BINARY) validate --project "$$tmp/edge-demo"; \ - $(BINARY) export --project "$$tmp/edge-demo" --format zip --out "$$tmp/edge-demo.zip"; \ + $(BINARY) experimental init "$$tmp/edge-demo" --source edge --target varnish-haproxy-coraza; \ + $(BINARY) experimental collect edge-fixture --project "$$tmp/edge-demo" --input ./testdata/edge/small.json; \ + $(BINARY) experimental assess --project "$$tmp/edge-demo" --target varnish-haproxy-coraza; \ + $(BINARY) experimental map --project "$$tmp/edge-demo"; \ + $(BINARY) experimental generate --project "$$tmp/edge-demo" --all; \ + $(BINARY) experimental validate --project "$$tmp/edge-demo"; \ + $(BINARY) experimental export --project "$$tmp/edge-demo" --format zip --out "$$tmp/edge-demo.zip"; \ $(BINARY) verify-bundle "$$tmp/edge-demo.zip"; \ - $(BINARY) init "$$tmp/ai-demo" --source ai-provider --target vllm-litellm; \ - $(BINARY) collect ai-fixture --project "$$tmp/ai-demo" --input ./testdata/ai-provider/small.json; \ - $(BINARY) assess --project "$$tmp/ai-demo" --target vllm-litellm; \ - $(BINARY) map --project "$$tmp/ai-demo"; \ - $(BINARY) generate --project "$$tmp/ai-demo" --all; \ - $(BINARY) validate --project "$$tmp/ai-demo"; \ - $(BINARY) export --project "$$tmp/ai-demo" --format zip --out "$$tmp/ai-demo.zip"; \ + $(BINARY) experimental init "$$tmp/ai-demo" --source ai-provider --target vllm-litellm; \ + $(BINARY) experimental collect ai-fixture --project "$$tmp/ai-demo" --input ./testdata/ai-provider/small.json; \ + $(BINARY) experimental assess --project "$$tmp/ai-demo" --target vllm-litellm; \ + $(BINARY) experimental map --project "$$tmp/ai-demo"; \ + $(BINARY) experimental generate --project "$$tmp/ai-demo" --all; \ + $(BINARY) experimental validate --project "$$tmp/ai-demo"; \ + $(BINARY) experimental export --project "$$tmp/ai-demo" --format zip --out "$$tmp/ai-demo.zip"; \ $(BINARY) verify-bundle "$$tmp/ai-demo.zip" example: build - rm -rf $(EXAMPLE_DIR)/openexit.yaml \ - $(EXAMPLE_DIR)/inventory \ - $(EXAMPLE_DIR)/assessment \ - $(EXAMPLE_DIR)/mapping \ - $(EXAMPLE_DIR)/generated-config \ - $(EXAMPLE_DIR)/evidence \ - $(EXAMPLE_DIR)/validation \ - $(EXAMPLE_BUNDLE) - mkdir -p $(EXAMPLE_DIR) - $(BINARY) init $(EXAMPLE_DIR) --source datadog --target grafana-lgtm - $(BINARY) collect fixture --project $(EXAMPLE_DIR) --input $(EXAMPLE_INPUT) - $(BINARY) assess --project $(EXAMPLE_DIR) --target grafana-lgtm - $(BINARY) map --project $(EXAMPLE_DIR) - $(BINARY) generate --project $(EXAMPLE_DIR) --all - $(BINARY) validate --project $(EXAMPLE_DIR) - $(BINARY) export --project $(EXAMPLE_DIR) --format zip --out $(EXAMPLE_BUNDLE) + $(BINARY) datadog scan --fixture $(EXAMPLE_INPUT) --workdir $(EXAMPLE_STATE) + $(BINARY) datadog plan --target grafana-lgtm --workdir $(EXAMPLE_STATE) + $(BINARY) datadog export --force --out $(EXAMPLE_DIR) --workdir $(EXAMPLE_STATE) example-smoke: build tmp=$$(mktemp -d); \ trap 'rm -rf "$$tmp"' EXIT; \ - $(BINARY) init "$$tmp/example" --source datadog --target grafana-lgtm; \ - $(BINARY) collect fixture --project "$$tmp/example" --input ./$(EXAMPLE_INPUT); \ - $(BINARY) assess --project "$$tmp/example" --target grafana-lgtm; \ - $(BINARY) map --project "$$tmp/example"; \ - $(BINARY) generate --project "$$tmp/example" --all; \ - $(BINARY) validate --project "$$tmp/example"; \ - $(BINARY) export --project "$$tmp/example" --format zip --out "$$tmp/openexit-example.zip"; \ - $(BINARY) verify-bundle "$$tmp/openexit-example.zip"; \ - test -s "$$tmp/openexit-example.zip" - -verify: lint test build smoke example-smoke + $(BINARY) datadog scan --fixture ./$(EXAMPLE_INPUT) --workdir "$$tmp/.openexit"; \ + $(BINARY) datadog plan --target grafana-lgtm --workdir "$$tmp/.openexit"; \ + $(BINARY) datadog export --out "$$tmp/migration" --workdir "$$tmp/.openexit"; \ + test -s "$$tmp/migration/index.html"; \ + test -s "$$tmp/migration/manifest.json" + +verify: lint test build smoke experimental-smoke example-smoke release-dist: rm -rf dist diff --git a/README.md b/README.md index 46c3ac1..7e93b07 100644 --- a/README.md +++ b/README.md @@ -1,195 +1,186 @@ # OpenExit -Local-first migration assessments from proprietary SaaS platforms to open-source infrastructure. +**Generate a reviewable, read-only migration plan from Datadog to Grafana, Prometheus, and OpenTelemetry.** -OpenExit collects migration inventory, normalizes it, analyzes migration risks, generates candidate target files, validates outputs, and exports a local evidence bundle. +OpenExit v0.1 does one job: inventory a Datadog organization, translate the deterministic subset, and produce a static migration report that shows exactly what changed and what still needs human work. -The Datadog to Grafana LGTM path includes both fixture import and a read-only live Datadog collector. The GitHub Enterprise to Forgejo path includes fixture import and a read-only GitHub/GitHub Enterprise collector for repository migration inventory. The Okta/Auth0 to Keycloak/Zitadel path includes fixture import and read-only live Okta and Auth0 collectors. The Cloudflare/Akamai to Varnish/HAProxy/Coraza path includes fixture import and read-only live Cloudflare and Akamai collectors. The OpenAI/Anthropic path includes fixture import and read-only OpenAI and Anthropic aggregate usage collectors. +It does not deploy anything. It does not write to Datadog. It does not use AI to guess conversions. -## Safety Model +## Workflow -- No production writes. -- No one-click migration. -- No hidden hosted backend. -- No credential storage. -- No direct SaaS deletion. -- No AI dependency. -- No generated config is production-ready without review. +```bash +openexit datadog scan +openexit datadog plan --target grafana-lgtm +openexit datadog export --out migration/ +``` + +Open `migration/index.html` in any browser. The report is self-contained and can be attached to an issue, reviewed in a pull request artifact, or shared with an observability team without running OpenExit. + +## What You Get + +```text +migration/ +├── index.html # static migration report +├── README.md # reviewer handoff +├── inventory/datadog.inventory.json # catalog/resource inventory and coverage +├── plan/openexit.plan.json # conversion ledger and readiness score +├── generated/ +│ ├── grafana/dashboards/*.json # reviewable Grafana candidates +│ ├── prometheus/rules/*.yaml # safe-subset alert candidates +│ ├── alloy/config.alloy # credential-free Alloy baseline +│ └── opentelemetry/collector.yaml # credential-free OTel baseline +├── evidence/datadog/** # redacted source evidence +├── validation/validation.json # machine-readable validation results +├── manifest.json # file digests and source-resource links +└── SHA256SUMS # bundle integrity checks +``` + +Every Datadog resource gets one conversion status: + +| Status | Meaning | +| --- | --- | +| `exact` | The represented behavior is preserved without a known semantic change. | +| `approximate` | OpenExit emitted a candidate and documented semantics that must be reviewed. | +| `manual` | The resource is inventoried, but no executable guess is emitted for the unsafe part. | +| `unsupported` | The capability is outside the Grafana LGTM v0.1 target. | + +Complex anomaly, outlier, forecast, composite, formula, and unsupported query behavior remains manual. OpenExit never hides missing work behind `vector(0)` or another fake executable result. + +## Why the Output Is Reviewable + +- Each source-derived generated file links back to one or more stable `datadog::` source references; the two credential-free telemetry baselines are explicitly identified as target baselines when no source configuration applies. +- Each source reference links to redacted local evidence and, where possible, the Datadog UI. +- Dashboard conversion is recorded per widget and per query, including mixed converted/manual widgets. +- Alert candidates preserve the source query and carry `openexit_candidate=true` and `production_ready=false`. +- Semantic changes, reason codes, and manual review instructions are part of the machine-readable plan. +- Inventory, plan, validation, and export manifests are checked against embedded JSON Schemas. +- Export recomputes validation and refuses stale plans, failed checks, unsafe paths, symlinks, or secret-like output. + +## Datadog Inventory + +The live scanner uses GET requests only. Its versioned catalog evaluates: + +- dashboards, dashboard lists, and powerpacks; +- monitors, monitor policies, and downtimes; +- SLOs and SLO corrections; +- notebooks and Synthetic Monitoring resources; +- active metric metadata; +- log pipelines, pipeline order, indexes, archives, and log-based metrics; +- APM retention filters and span-based metrics; +- service definitions; +- installed integrations and AWS, Azure, and GCP integration accounts. -## Quick Start +The inventory records status, resource count, and any error for every endpoint. A `401`, `403`, decode failure, detail failure, or interrupted page makes the scan incomplete and the command fails closed. Use `--allow-partial` only when you intentionally want that limitation carried into the plan and readiness score. + +## Exit Readiness + +The report publishes the score inputs and formula: + +```text +score = round(100 × C × (0.9 × T + 0.1 × V)) +``` + +- `C` is completed inventory families divided by catalog families. Endpoint states `complete`, `empty`, and `not_available` count as evaluated; `partial`, `permission_denied`, and `error` do not. +- `T` is `(2 × exact + approximate) / (2 × inventoried resources)`. Manual and unsupported resources contribute zero. +- `V` is passed critical validation checks divided by critical checks. +- Any critical validation failure caps the score at 49 and blocks export. + +The score is migration-plan coverage, not cutover approval or production readiness. + +## Install + +Build from source with Go 1.25 or newer: ```bash +git clone https://github.com/RamazanKara/openexit.git +cd openexit make build -./bin/openexit demo ./demo +./bin/openexit version ``` -`openexit demo` uses built-in redacted fixture data, runs the deterministic workflow, validates the output, and writes `./demo/openexit-demo.zip`. - -## Install Release Binary +Release binaries can also be installed with: ```bash curl -fsSL https://github.com/RamazanKara/openexit/releases/latest/download/install.sh | sh openexit doctor -openexit demo ./demo ``` -The installer detects Linux or macOS plus `amd64` or `arm64`, downloads the matching release binary, verifies it against `SHA256SUMS`, verifies that artifact against `RELEASE_MANIFEST.json`, and installs `openexit` into `/usr/local/bin` when writable or `~/.local/bin` otherwise. Set `OPENEXIT_VERSION=v0.1.0` for a specific release or `BIN_DIR=/path/to/bin` for a custom install location. +The installer verifies the selected binary against `SHA256SUMS` and `RELEASE_MANIFEST.json` before installation. + +## Scan Datadog -## Install From Source +Put credentials in environment variables; OpenExit reads them at runtime and never stores them: ```bash -git clone https://github.com/RamazanKara/openexit.git -cd openexit -make verify -make build VERSION=0.1.0 -./bin/openexit version +export DATADOG_API_KEY='' +export DATADOG_APP_KEY='' + +openexit datadog scan --site datadoghq.eu ``` -Release candidates can be built locally with: +The application key needs read access to every catalog family you want declared complete. Alternate variable names are supported: ```bash -make release-check VERSION=0.1.0 +openexit datadog scan \ + --api-key-env MY_DD_API_KEY \ + --app-key-env MY_DD_APP_KEY ``` -This runs the release gate, writes OS/architecture binaries, writes `dist/SHA256SUMS`, writes `dist/RELEASE_MANIFEST.json`, writes `dist/SBOM.cdx.json`, and verifies the release artifacts. +State is written to `.openexit/` by default. Use `--workdir` on all three commands to select another location. -Refresh the checked-in Datadog example project with: +## Try It Without Datadog Credentials ```bash -make example VERSION=0.1.0-dev +make build +./bin/openexit datadog scan \ + --fixture testdata/datadog/small.json \ + --workdir /tmp/openexit-demo/.openexit +./bin/openexit datadog plan \ + --target grafana-lgtm \ + --workdir /tmp/openexit-demo/.openexit +./bin/openexit datadog export \ + --out /tmp/openexit-demo/migration \ + --workdir /tmp/openexit-demo/.openexit ``` -## Commands - -- `openexit version` -- `openexit doctor [--json] [--strict]` -- `openexit init [--source --target ]` -- `openexit demo [--source ] [--out ] [--force]` -- `openexit status --project [--json]` -- `openexit run --project [--strict] [--export --out ]` -- `openexit collect fixture --project --input ` -- `openexit collect github --project --owner [--base-url https://github.example.com/api/v3] [--token-env GITHUB_TOKEN] [--repo owner/name]` -- `openexit collect github-fixture --project --input ` -- `openexit collect okta --project --org-url https://dev-123456.okta.com [--token-env OKTA_API_TOKEN] [--break-glass-user admin@example.com]` -- `openexit collect auth0 --project --domain https://example.us.auth0.com [--token-env AUTH0_MANAGEMENT_TOKEN] [--break-glass-user admin@example.com]` -- `openexit collect identity-fixture --project --input ` -- `openexit collect cloudflare --project --zone-id [--token-env CLOUDFLARE_API_TOKEN]` -- `openexit collect akamai --project [--zone example.com] [--property-id prp_123] [--security-config-id 123:7]` -- `openexit collect edge-fixture --project --input ` -- `openexit collect openai --project [--admin-key-env OPENAI_ADMIN_KEY] [--days 30] [--owner team@example.com]` -- `openexit collect anthropic --project [--admin-key-env ANTHROPIC_ADMIN_KEY] [--days 30] [--workspace-id wrkspc_...]` -- `openexit collect ai-fixture --project --input ` -- `openexit collect datadog --project --site datadoghq.eu --api-key-env DATADOG_API_KEY --app-key-env DATADOG_APP_KEY` -- `openexit assess --project --target grafana-lgtm` -- `openexit map --project ` -- `openexit generate --project --all` -- `openexit validate --project ` -- `openexit export --project --format zip --out ` -- `openexit verify-bundle [--json]` -- `openexit release-manifest [--dist dist --out dist/RELEASE_MANIFEST.json]` -- `openexit verify-release [--dist dist] [--artifact ] [--require-checksums] [--json]` -- `openexit completion [bash|zsh|fish|powershell]` -- `openexit sbom [--out SBOM.cdx.json]` -- `openexit assist summarize --project --provider noop` - -The Datadog, GitHub, Okta, Auth0, Cloudflare, Akamai, OpenAI, and Anthropic collectors are read-only. API tokens are read from environment variables or local credential files, are not printed, and are not stored. -When `--target` is omitted during `init`, OpenExit selects the standard target for the chosen source. - -## Supported Paths - -| Source | Target | Status | Collector | -| --- | --- | --- | --- | -| Datadog | Grafana LGTM, Prometheus-compatible alerting, OpenTelemetry Collector/Alloy | Primary path | Fixture and read-only live Datadog collector | -| GitHub Enterprise | Forgejo | Repository migration assessment path | Fixture and read-only live GitHub/GitHub Enterprise collector | -| Okta/Auth0 | Keycloak/Zitadel | Identity migration assessment path | Fixture and read-only live Okta/Auth0 collectors | -| Cloudflare/Akamai | Varnish/HAProxy/Coraza | Edge migration assessment path | Fixture and read-only live Cloudflare/Akamai collectors | -| OpenAI/Anthropic | vLLM/LiteLLM | AI provider migration assessment path | Fixture and read-only live OpenAI/Anthropic aggregate usage collectors | - -Fixture workflows run the full local OpenExit workflow with sample or customer-provided JSON fixture data. They are assessment and planning tools for offline review. - -## Current Scope - -Included in the current implementation: - -- CLI skeleton and project init/status. -- Runtime doctor for version metadata, embedded schemas, and optional validator availability. -- Built-in demo project generation for release binaries without repository-local fixture files. -- Project readiness status with pipeline counts, validation state, export readiness, and JSON output for automation. -- One-command deterministic workflow runner for collected projects, with optional evidence bundle export. -- Typed project, inventory, assessment, mapping, and validation manifests. -- Fixture-based Datadog inventory import. -- Read-only Datadog collection for dashboards, monitors, SLOs, installed integration metadata, and referenced metric/tag metadata. -- Read-only GitHub/GitHub Enterprise collection for repositories, teams, branch protection, Actions workflows, secret metadata, runners, deploy keys, and GitHub App installations. -- Read-only Okta collection for applications, groups, policy/rule metadata, org MFA factors, and explicit break-glass user metadata. -- Read-only Auth0 collection for clients, roles, action/rule metadata, Guardian MFA factors, and explicit break-glass user metadata. -- Read-only Cloudflare collection for DNS records, WAF rulesets, cache rules, redirects, inferred origins, TLS settings, bot rules, and page rules. -- Read-only Akamai collection for Edge DNS recordsets, Property Manager hostnames/rules, origins, cache behaviors, redirects, TLS/HSTS metadata, Bot Manager behavior metadata, and optional AppSec custom-rule metadata. -- Read-only OpenAI collection for model-grouped aggregate completions usage, token volumes, available model metadata, and hourly peak estimates. -- Read-only Anthropic collection for model-grouped Messages API token usage, server web-search tool metadata, filters, and hourly peak estimates. -- Deterministic risk assessment. -- Deterministic source-to-target mapping manifest and summary. -- Markdown handover artifacts. -- Grafana dashboard candidate JSON. -- Prometheus alert rule candidate YAML with simple Datadog threshold conversion hints. -- OpenTelemetry Collector sketch. -- ArgoCD starter manifest. -- Typed migration plan manifest and phase-gate Markdown plan. -- Validation report with embedded JSON Schema checks, Grafana dashboard, Prometheus alert, OpenTelemetry collector, ArgoCD, Forgejo migration, identity realm/client, edge VCL/HAProxy/Coraza, and LiteLLM/vLLM candidate checks, YAML/JSON parsing, evidence ref checks, secret scan, and optional `promtool`/`kubeconform` checks. -- Evidence bundle export with README, checksums, and a schema-backed machine-readable manifest. -- Offline evidence bundle verification for manifest schema, checksums, digest/size metadata, and archive path safety. -- Release artifact manifest generation with binary OS/architecture metadata, auxiliary asset metadata, sizes, and SHA-256 digests. -- Offline release artifact verification for binaries and auxiliary assets against `RELEASE_MANIFEST.json` and optional `SHA256SUMS`. -- Release installer script that selects the current platform binary and verifies it before installation. -- Shell completion generation for Bash, Zsh, Fish, and PowerShell, including release-provided completion assets. -- CycloneDX JSON SBOM generation for the OpenExit binary and Go module dependencies. -- Evidence bundle path-safety checks that reject symlinks in exported project sections. -- No-op assist provider and explicit opt-in LiteLLM assist. -- GitHub Enterprise to Forgejo assessment path with fixture import and live repository inventory collection. -- Okta/Auth0 to Keycloak/Zitadel assessment path with fixture import and live Okta/Auth0 identity inventory collection. -- Cloudflare/Akamai to Varnish/HAProxy/Coraza assessment path with fixture import and live Cloudflare/Akamai edge inventory collection. -- OpenAI/Anthropic to vLLM/LiteLLM assessment path with fixture import and live OpenAI/Anthropic aggregate usage inventory collection. - -Not included in the current release: - -- Automatic cutover. -- Production apply. -- Hosted portal. -- Perfect Datadog to Grafana parity. -- AI-required decision making. - -## Optional Assist - -`openexit assist summarize` defaults to the local no-op provider. LiteLLM is available only when `openexit.yaml` explicitly sets `policy.allowAI: true`, `assist.enabled: true`, `assist.provider: litellm`, and `assist.allowExternalProvider: true`. - -Assist inputs are redacted before provider calls, outputs must use `.ai.md`, and deterministic artifacts are never overwritten. - -## Candidate Conversion Policy - -OpenExit is intentionally conservative: - -- Simple Datadog metric thresholds can be translated into PromQL candidates. -- Complex functions such as anomaly, outlier, forecast, timeshift, or composite behavior stay as `vector(0)` placeholders with source queries preserved. -- Every generated alert remains labeled `openexit_candidate=true` and `production_ready=false`. -- Human review and shadowing are required before operational use. - -## Risk Coverage - -The assessment engine includes dashboard, monitor, SLO, cost, scale, identity, edge, repository, and AI provider risk rules from the implementation plan. Findings have stable IDs, severity, affected assets, evidence refs, and recommendations. `openexit map` writes a typed mapping manifest under `mapping/`; `generate --all` refreshes mapping and writes a typed migration plan under `assessment/` with assessment, pilot, shadow, and cutover phase gates. `openexit validate` checks generated manifests against embedded public JSON Schemas as well as typed consistency rules. - -## Release Process - -The release checklist lives in `docs/release.md`. A release build should pass `make verify`, `make release-dist VERSION=0.1.0`, `openexit verify-release dist/RELEASE_MANIFEST.json --dist dist --require-checksums`, `make example VERSION=0.1.0-dev`, the Datadog definition-of-done pipeline, and validation/export for every supported assessment path. - -## Assessment Paths - -GitHub Enterprise to Forgejo collects repository, team, branch protection, Actions workflow, secret metadata, runner, deploy key, and GitHub App installation metadata from live GitHub/GitHub Enterprise APIs or local fixtures. It generates Forgejo migration assessment, CI compatibility, branch protection mapping, runner migration, repository ownership reports, and a validated Forgejo migration candidate YAML. - -Okta/Auth0 to Keycloak/Zitadel collects applications, SAML/OIDC client metadata, groups, policies, MFA settings, redirect URIs, owners, and break-glass account metadata from live Okta/Auth0 APIs or local fixtures. It generates identity migration risk, validated realm/client candidate config, break-glass, cutover, and rollback artifacts. - -Cloudflare/Akamai to Varnish/HAProxy/Coraza collects DNS records, WAF rules, cache rules, redirects, origins, TLS settings, bot rules, and page rules from live Cloudflare/Akamai APIs or local fixtures. The Akamai collector uses read-only EdgeGrid-authenticated calls for Edge DNS, Property Manager, and optional AppSec metadata. It generates validated VCL, HAProxy, Coraza, cache parity, and WAF enforcement review artifacts. +## Safety Contract + +- Datadog access is read-only and implemented through a GET-only client. +- API and application keys are sent only in Datadog request headers. +- Error messages never include response bodies or credentials. +- Evidence is structurally redacted before it is written. +- Generated files are review candidates and contain no source credentials. +- Planning and export are local, deterministic operations. +- There is no automatic deployment, cutover, source deletion, hosted backend, or AI conversion in v0.1. + +See [the Datadog migration details](docs/datadog-to-grafana.md), [security model](docs/security.md), and [public schemas](docs/schemas.md). + +## Experimental Providers + +The earlier multi-provider assessment engine remains in the repository for experimentation: + +- GitHub Enterprise → Forgejo +- Okta/Auth0 → Keycloak/Zitadel +- Cloudflare/Akamai → Varnish/HAProxy/Coraza +- OpenAI/Anthropic → vLLM/LiteLLM + +Discover it with: + +```bash +openexit experimental --help +``` + +Legacy root command aliases remain hidden for compatibility. Experimental providers are not part of the v0.1 product contract and will not be promoted until the Datadog path has real users. + +## Development + +```bash +make test +make verify +``` -OpenAI/Anthropic to vLLM/LiteLLM collects model usage classes, token volumes, latency expectations, sensitive prompt categories, tool usage, and fallback behavior from local fixtures. It can also collect model-grouped aggregate OpenAI completions usage, aggregate Anthropic Messages API usage, available model metadata where exposed, server web-search tool metadata, and hourly peak estimates from live provider APIs without storing prompts or credentials. The path generates self-hosted LLM readiness, validated LiteLLM routing, vLLM sizing, evaluation, and data sensitivity artifacts. +`make verify` runs formatting, static analysis, unit and end-to-end tests, a Datadog scan/plan/export smoke test, and compatibility tests for the experimental engine. ## License -Apache-2.0. +Apache-2.0 diff --git a/docs/architecture.md b/docs/architecture.md index e3b3972..b6da103 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,19 +1,34 @@ # Architecture -OpenExit uses a deterministic pipeline: +The primary OpenExit v0.1 pipeline is intentionally small: ```text -Collect -> Normalize -> Analyze -> Map -> Generate -> Plan -> Validate -> Export +Datadog GET-only scan + ↓ +versioned inventory + redacted evidence + ↓ +deterministic conversion ledger + ↓ +Grafana / Prometheus / Alloy / OpenTelemetry candidates + ↓ +schema + provenance + safety validation + ↓ +static HTML report + transactional directory export ``` -The core engine is written in Go. The primary Datadog live-collector path targets Grafana LGTM with Prometheus-compatible alerting and OpenTelemetry Collector or Alloy sketches. The map stage writes deterministic source-to-target mapping manifests under `mapping/`; `generate --all` refreshes that mapping and also writes a typed migration plan that groups required artifacts into assessment, pilot, shadow, and cutover phase gates. The validator embeds the public JSON Schemas into the release binary so generated manifests can be checked without a checked-out repository. +The engine is written in Go and keeps state under `.openexit/`. Inventory and plan identities are content-derived SHA-256 digests. The inventory timestamp is the scan time; planning reuses that timestamp instead of introducing nondeterministic wall-clock output. -GitHub Enterprise to Forgejo keeps the same local-first collect, normalize, analyze, generate, validate, export pipeline. It supports fixture import and a read-only live GitHub/GitHub Enterprise collector for repository migration inventory. +## Boundaries -Okta/Auth0 to Keycloak/Zitadel reuses the same normalized inventory and evidence model, but generates identity-specific planning artifacts and a candidate realm/client YAML instead of Datadog target configs. It supports fixture import and read-only live Okta/Auth0 collectors. +- The Datadog client exposes GET only and rejects pagination URLs that change scheme or host. +- Scan output is staged before inventory/evidence replacement. A new scan invalidates stale planned output. +- Converters accept only explicitly recognized syntax. Unsupported behavior produces a manual ledger entry, not a guessed executable file. +- Generated target files carry source references and candidate safety metadata. +- Validation checks source-to-output coverage in both directions: every inventory resource has one decision, and every generated file is linked from the plan or declared as a target baseline. +- Export revalidates current disk state, stages a fixed payload, writes per-file provenance/digests, and installs the directory atomically. -Cloudflare/Akamai to Varnish/HAProxy/Coraza generates and validates edge-specific VCL, HAProxy, and Coraza candidate files plus cache and WAF review reports. It supports fixture import plus read-only live Cloudflare and Akamai collectors. +Public Draft 7 JSON Schemas are embedded in release binaries, so inventory, plan, validation, and bundle manifests can be validated without a repository checkout. -OpenAI/Anthropic to vLLM/LiteLLM generates AI provider readiness, validated LiteLLM routing, vLLM sizing, evaluation, and data sensitivity artifacts. It supports fixture import plus read-only live OpenAI/Anthropic aggregate usage collectors. +## Experimental Engine -AI assist is optional and never required for validation or export. +The repository retains the earlier multi-provider collect/normalize/analyze/map/generate/validate/export engine under `openexit experimental`. It supports GitHub Enterprise, identity, edge, AI-provider, and legacy Datadog assessment paths. That engine is maintained for compatibility but is not part of the v0.1 product boundary. diff --git a/docs/cli.md b/docs/cli.md index 544d58f..a46ae3b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,189 +1,73 @@ # CLI -See `openexit --help` for the command tree. - -`openexit init` accepts `--source` and `--target`. If `--target` is omitted, the CLI uses the standard target for the selected source. - -Release builds stamp version metadata: - -```bash -make build VERSION=0.1.0 -./bin/openexit version -``` - -`openexit doctor` checks the local CLI runtime before a project run. It verifies version metadata, embedded schema compilation, and optional validator availability for `promtool` and `kubeconform`. Missing optional validators are warnings by default; pass `--strict` to make warnings fail, or `--json` for automation. - -The minimum local demo is: - -```bash -openexit demo ./demo -``` - -`openexit demo` uses built-in redacted fixture data, so release binaries can create a complete sample project without access to this repository's `testdata/` directory. It initializes the project, collects fixture inventory, runs assessment, mapping, full artifact generation, validation, final status reporting, and evidence bundle export. Pass `--source github-enterprise`, `--source identity`, `--source edge`, or `--source ai-provider` to try another built-in path. - -The CI test suite also runs this definition-of-done pipeline against fixture inventory and checks that the generated project layout and export bundle contain the expected artifacts. Bundle checksums are verified against the archived file bytes. - -The checked-in Datadog example can be refreshed with `make example VERSION=0.1.0-dev`. CI also runs `make example-smoke` as part of `make verify` to ensure the example fixture still completes the full pipeline. - -`openexit run --project ` is the ergonomic path after collection. It runs assessment, mapping, full artifact generation, validation, and final status reporting. Add `--export --out ` to write the evidence bundle after validation passes, or `--strict` to treat validation warnings as failures. - -`openexit status --project ` summarizes the current pipeline state: project layout, source/target pair, inventory counts, assessment finding severity, mapping counts, generated candidate artifacts, validation check totals, export readiness, and the next recommended command. Use `--json` to feed the same readiness data into automation or release gates. - -`openexit validate` performs typed consistency checks, embedded JSON Schema validation, Grafana dashboard candidate validation, Prometheus alert-rule candidate validation, OpenTelemetry collector candidate validation, ArgoCD candidate validation, Forgejo migration candidate validation, identity realm/client candidate validation, edge VCL/HAProxy/Coraza candidate validation, LiteLLM/vLLM candidate validation, YAML/JSON parse checks, evidence reference checks, secret scanning, and optional external tool checks when `promtool` or `kubeconform` are installed. - -`openexit export` refuses to package symlinks from exported project sections, even with `--force`, so evidence bundles cannot accidentally include files from outside the project tree. Exported zips include `openexit-evidence/manifest.json` with build metadata, project source/target, validation totals, and per-file SHA-256 digests, plus `checksums.txt` for archive-level verification. The manifest shape is published as `schemas/openexit.evidence-bundle.schema.json`. - -`openexit verify-bundle ` verifies an exported bundle without requiring the original project directory. It checks archive path safety, required bundle files, manifest schema validity, manifest file size/digest metadata, and `checksums.txt`. Use `--json` to feed the verification report into a handover gate. - -`openexit release-manifest --dist dist --out dist/RELEASE_MANIFEST.json` writes a machine-readable manifest for release artifacts. It records the stamped release version, commit, build date, generation time, each expected OS/architecture binary, and auxiliary assets such as `install.sh` and shell completions with file name, type, relative path, size, and SHA-256 digest. The manifest shape is published as `schemas/openexit.release-manifest.schema.json`. - -`openexit verify-release dist/RELEASE_MANIFEST.json --dist dist --require-checksums` verifies release artifacts after download or before publishing. It validates the manifest schema, rejects unsafe artifact paths, recomputes file sizes and SHA-256 digests, and verifies `SHA256SUMS` when required. Use repeatable `--artifact ` to verify only the current platform artifact after download, or `--json` to feed the verification report into a release gate. - -`openexit completion bash|zsh|fish|powershell` prints shell completion scripts. Release artifacts also include `openexit.bash`, `_openexit`, `openexit.fish`, and `openexit.ps1` so package managers and manual installs can wire completion without rebuilding from source. - -`openexit sbom --out SBOM.cdx.json` writes a CycloneDX JSON SBOM for the current OpenExit binary. It records the stamped OpenExit version, commit, build date, Go toolchain version, and Go module dependencies reported by the binary build info. Release builds include `SBOM.cdx.json` and cover it with both `RELEASE_MANIFEST.json` and `SHA256SUMS`. - -Generate individual artifacts with `openexit generate --artifact `. The primary Datadog path supports `mapping`, `assessment`, `risk-register`, `manual-review`, `cost-drivers`, `target-architecture`, `acceptance-criteria`, `rollback-plan`, `runbook`, `restore-drill-checklist`, `alert-shadowing-plan`, `migration-plan`, `grafana-dashboards`, `prometheus-rules`, `opentelemetry`, and `argocd`. The GitHub Enterprise path also supports `forgejo-migration-candidate`; the identity path also supports `realm-client-candidate`; the edge path also supports `vcl-candidates`, `haproxy-candidates`, and `coraza-rule-candidates`; the AI provider path also supports `litellm-config-candidate`. - -The GitHub Enterprise to Forgejo fixture path uses local JSON metadata: - -```bash -openexit init ./ghe-demo --source github-enterprise --target forgejo -openexit collect github-fixture --project ./ghe-demo --input ./testdata/github-enterprise/small.json -openexit run --project ./ghe-demo -``` - -The same path can collect read-only live GitHub or GitHub Enterprise metadata. Set the token in an environment variable; OpenExit reads it at runtime and does not write it into project files. Organization GitHub App installation metadata is collected when the token can read organization administration metadata; otherwise OpenExit records a warning and continues collecting repository-scoped metadata. - -```bash -export GITHUB_TOKEN= -openexit init ./ghe-live --source github-enterprise --target forgejo -openexit collect github --project ./ghe-live --owner acme --token-env GITHUB_TOKEN -openexit run --project ./ghe-live -``` - -For GitHub Enterprise Server, pass the API root: +## Primary v0.1 Workflow ```bash -openexit collect github --project ./ghe-live --owner acme --base-url https://github.example.com/api/v3 +openexit datadog scan [flags] +openexit datadog plan --target grafana-lgtm [flags] +openexit datadog export --out migration/ [flags] ``` -Use repeatable `--repo` flags to restrict collection to selected repositories: - -```bash -openexit collect github --project ./ghe-live --owner acme --repo acme/platform-api --repo docs-site -``` +### `datadog scan` -The Okta/Auth0 to Keycloak/Zitadel fixture path uses local JSON metadata: +Inventories the versioned Datadog observability catalog through a GET-only client and writes redacted evidence under `.openexit/`. -```bash -openexit init ./identity-demo --source identity --target keycloak-zitadel -openexit collect identity-fixture --project ./identity-demo --input ./testdata/identity/small.json -openexit run --project ./identity-demo +```text +--workdir string state directory (default ".openexit") +--site string Datadog site (default "datadoghq.com") +--api-key-env string API-key environment variable (default "DATADOG_API_KEY") +--app-key-env string application-key environment variable (default "DATADOG_APP_KEY") +--fixture string local fixture instead of the live API +--allow-partial accept explicitly incomplete endpoint coverage ``` -The same path can collect read-only live Okta metadata. Set the token in an environment variable; OpenExit reads it at runtime and does not write it into project files. +Without `--allow-partial`, an incomplete scan is still persisted for diagnosis but the command exits non-zero. Every newly persisted scan—including a fail-closed partial scan—invalidates the previous plan, generated files, validation, and report. -```bash -export OKTA_API_TOKEN= -openexit init ./okta-live --source identity --target keycloak-zitadel -openexit collect okta --project ./okta-live --org-url https://dev-123456.okta.com --token-env OKTA_API_TOKEN -openexit run --project ./okta-live -``` +### `datadog plan` -Use repeatable `--break-glass-user` flags to verify named emergency accounts and capture whether they have active factors: +Reads the current inventory and emits deterministic Grafana, Prometheus, Alloy, and OpenTelemetry candidates, a machine-readable conversion ledger, validation results, and `index.html`. -```bash -openexit collect okta --project ./okta-live --org-url https://dev-123456.okta.com --break-glass-user breakglass-admin@example.com +```text +--workdir string state directory (default ".openexit") +--target string target (default and only v0.1 value: "grafana-lgtm") +--allow-partial plan from an explicitly accepted partial inventory ``` -The same identity path can collect read-only live Auth0 metadata. Set an Auth0 Management API token in an environment variable; OpenExit reads it at runtime and does not write it into project files. +The command exits non-zero if a critical validation check fails. No deployment or source write is performed. -```bash -export AUTH0_MANAGEMENT_TOKEN= -openexit init ./auth0-live --source identity --target keycloak-zitadel -openexit collect auth0 --project ./auth0-live --domain https://example.us.auth0.com --token-env AUTH0_MANAGEMENT_TOKEN -openexit run --project ./auth0-live -``` +### `datadog export` -Use repeatable `--break-glass-user` flags with an email, username, or Auth0 user ID to verify emergency accounts: +Reruns validation and copies the fixed migration payload into a review directory. -```bash -openexit collect auth0 --project ./auth0-live --domain https://example.us.auth0.com --break-glass-user breakglass-admin@example.com +```text +--out string required output directory +--workdir string state directory (default ".openexit") +--force transactionally replace an existing output directory +--allow-partial export an explicitly accepted partial plan ``` -The Cloudflare/Akamai to Varnish/HAProxy/Coraza fixture path uses local JSON metadata: +The export includes a schema-backed `manifest.json` and `SHA256SUMS`. It rejects stale plans, critical validation failures, unsafe paths, symlinks, and secret-like content. -```bash -openexit init ./edge-demo --source edge --target varnish-haproxy-coraza -openexit collect edge-fixture --project ./edge-demo --input ./testdata/edge/small.json -openexit run --project ./edge-demo -``` +## Runtime and Release Utilities -The same path can collect read-only live Cloudflare metadata. Set the API token in an environment variable; OpenExit reads it at runtime and does not write it into project files. +- `openexit version` +- `openexit doctor [--json] [--strict]` +- `openexit completion bash|zsh|fish|powershell` +- `openexit sbom [--out SBOM.cdx.json]` +- `openexit verify-bundle [--json]` +- `openexit release-manifest [flags]` +- `openexit verify-release [flags]` -```bash -export CLOUDFLARE_API_TOKEN= -openexit init ./cloudflare-live --source edge --target varnish-haproxy-coraza -openexit collect cloudflare --project ./cloudflare-live --zone-id --token-env CLOUDFLARE_API_TOKEN -openexit run --project ./cloudflare-live -``` - -The same path can collect read-only live Akamai metadata. The collector reads EdgeGrid credentials from `~/.edgerc` or the `AKAMAI_HOST`, `AKAMAI_CLIENT_TOKEN`, `AKAMAI_ACCESS_TOKEN`, and `AKAMAI_CLIENT_SECRET` environment variables. It records Edge DNS recordsets, Property Manager hostnames and rules, and optional AppSec custom-rule metadata without storing credential values. - -```bash -openexit init ./akamai-live --source edge --target varnish-haproxy-coraza -openexit collect akamai \ - --project ./akamai-live \ - --zone example.com \ - --property-id prp_12345 \ - --contract-id ctr_1-ABCDEF \ - --group-id grp_12345 \ - --security-config-id 12345:7 -openexit run --project ./akamai-live -``` - -Use repeatable `--zone`, `--property-id`, and `--security-config-id` flags to scope collection. Use `--discover-properties` with `--contract-id` and `--group-id` to list accessible Property Manager properties before collection, and `--account-switch-key` when the API client needs to act against another account. +`doctor` verifies build metadata, embedded schema compilation, and optional local validators. Release commands and legacy zip verification are retained for distribution compatibility. -The OpenAI/Anthropic to vLLM/LiteLLM fixture path uses local JSON metadata: +## Experimental Multi-provider Engine -```bash -openexit init ./ai-demo --source ai-provider --target vllm-litellm -openexit collect ai-fixture --project ./ai-demo --input ./testdata/ai-provider/small.json -openexit run --project ./ai-demo -``` - -The same path can collect read-only aggregate OpenAI usage. Set an OpenAI admin key in an environment variable; OpenExit reads it at runtime and does not write it into project files or evidence. The live collector records model-grouped token usage and available model metadata, not raw prompts or responses. +The previous project-oriented commands are grouped under: ```bash -export OPENAI_ADMIN_KEY= -openexit init ./openai-live --source ai-provider --target vllm-litellm -openexit collect openai \ - --project ./openai-live \ - --admin-key-env OPENAI_ADMIN_KEY \ - --workspace acme \ - --owner platform-ai \ - --fallback-strategy manual-queue \ - --fallback-manual-queue -openexit run --project ./openai-live +openexit experimental --help ``` -Use `--days` to change the aggregate usage window and `--peak-days` to change the hourly peak-estimate window. Use `--organization-id` or `--project-id` only when your OpenAI account requires those headers. - -The same path can collect read-only aggregate Anthropic Messages API usage. Set an Anthropic Admin API key in an environment variable; OpenExit reads it at runtime and does not write it into project files or evidence. The live collector records model-grouped token usage and server tool metadata, not raw prompts or responses. - -```bash -export ANTHROPIC_ADMIN_KEY= -openexit init ./anthropic-live --source ai-provider --target vllm-litellm -openexit collect anthropic \ - --project ./anthropic-live \ - --admin-key-env ANTHROPIC_ADMIN_KEY \ - --workspace platform \ - --workspace-id wrkspc_01JwQvzr7rXLA5AGx3HKfFUJ \ - --owner platform-ai \ - --fallback-strategy manual-queue \ - --fallback-manual-queue -openexit run --project ./anthropic-live -``` +This includes `init`, `demo`, `status`, `run`, `collect`, `assess`, `map`, `generate`, `validate`, `export`, and optional `assist` commands for the GitHub Enterprise, identity, edge, AI-provider, and legacy Datadog assessment paths. -Use `--api-key-id`, `--workspace-id`, or `--model` to restrict the Anthropic usage query. Use `--days` to change the daily aggregate usage window and `--peak-days` to change the hourly peak-estimate window. +Hidden root aliases remain executable for backward compatibility, but they are not part of the focused Datadog v0.1 interface. diff --git a/docs/datadog-to-grafana.md b/docs/datadog-to-grafana.md index a373d0d..1d075bb 100644 --- a/docs/datadog-to-grafana.md +++ b/docs/datadog-to-grafana.md @@ -1,42 +1,120 @@ -# Datadog To Grafana LGTM +# Datadog to Grafana LGTM -OpenExit generates candidates only: +OpenExit v0.1 generates a reviewable migration plan from Datadog to Grafana, Prometheus, Grafana Alloy, and the OpenTelemetry Collector. -- Grafana dashboard JSON under `generated-config/grafana`. -- Prometheus rule candidates under `generated-config/prometheus`. -- OpenTelemetry Collector sketch under `generated-config/opentelemetry`. -- ArgoCD starter manifest under `generated-config/argocd`. +## Commands -Every uncertain conversion is marked for manual review. +```bash +openexit datadog scan +openexit datadog plan --target grafana-lgtm +openexit datadog export --out migration/ +``` + +All commands use `.openexit/` as local state unless `--workdir` is provided. A new scan invalidates the previous plan and generated candidates so stale output cannot be mistaken for the current snapshot. + +`scan` supports: + +- `--site` for Datadog sites such as `datadoghq.com`, `datadoghq.eu`, `us3.datadoghq.com`, `us5.datadoghq.com`, `ap1.datadoghq.com`, `ap2.datadoghq.com`, `uk1.datadoghq.com`, `ddog-gov.com`, and `us2.ddog-gov.com`; +- `--api-key-env` and `--app-key-env` to select credential environment variables; +- `--fixture` for an offline, local fixture snapshot; +- `--allow-partial` to explicitly accept incomplete endpoint coverage. + +`plan` supports only `--target grafana-lgtm` in v0.1. `export` writes a directory and refuses to replace an existing target unless `--force` is passed. + +## Versioned Inventory Catalog + +The inventory records catalog version `datadog-observability/v1`, family status, and endpoint-level status/count/message data. + +| Family | GET endpoints | +| --- | --- | +| Dashboards | `/api/v1/dashboard`, `/api/v1/dashboard/lists/manual`, `/api/v2/powerpacks`, plus per-resource detail endpoints | +| Alerting | `/api/v1/monitor`, `/api/v2/monitor/policy`, `/api/v2/downtime` | +| SLOs | `/api/v1/slo`, `/api/v1/slo/correction` | +| Notebooks | `/api/v1/notebooks` | +| Synthetics | `/api/v1/synthetics/tests`, `/api/v1/synthetics/variables`, `/api/v1/synthetics/locations`, plus test details | +| Metrics | `/api/v2/metrics` | +| Logs | pipeline, pipeline-order, index, archive, and log-metric configuration endpoints | +| APM | retention-filter and span-metric configuration endpoints | +| Services | `/api/v2/services/definitions` | +| Integrations | installed integration metadata and AWS, Azure, GCP, and legacy GCP account endpoints | + +List endpoints are fully paginated according to their API style: start/count, page/page-size, offset/limit, bracketed page offset or page number, and cursor pagination are all handled explicitly. Dashboard, Powerpack, and Synthetic test details are fetched after listing. Dashboard-list membership is stored as related evidence. + +Installed-integration inventory excludes uninstalled marketplace entries. A response that cannot prove completeness marks its endpoint and family partial or failed. OpenExit persists the partial inventory for review but returns an error unless `--allow-partial` is explicit. + +## Dashboard Conversion + +OpenExit always emits one Grafana dashboard candidate for each Datadog dashboard. It evaluates every discovered widget query or formula, not just the first expression. + +- Markdown/note content with a deterministic representation is preserved as an `exact` component. +- Simple metric queries using `avg`, `sum`, `min`, `max`, or `count`, exact tag filters (or the unfiltered `{*}` selector), and simple group-by tags become PromQL candidates marked `approximate`. +- Datadog formulas, event/log/APM query syntax, wildcard tag values, negated tag filters, and unsupported widgets become visible text review panels marked `manual`. +- A dashboard with any manual component has overall `manual` status. Every other generated dashboard is `approximate` because OpenExit normalizes layout and adds Grafana candidate/data-source metadata, even when all represented text components are exact. -## Alert Rules +Each generated dashboard contains an `openexit` metadata object with source reference, evidence path, status, ruleset version, and `productionReady: false`. Each executable target preserves its original Datadog query and source path. -OpenExit attempts a conservative conversion for simple threshold monitors such as: +## Monitor Conversion + +Only a deliberately narrow static-threshold grammar becomes a Prometheus rule candidate. It recognizes simple metric windows such as: ```text -sum(last_5m):sum:trace.http.request.errors{env:prod}.as_count() > 10 +sum(last_5m):avg:trace.http.request.errors{env:prod} > 10 +``` + +The result remains `approximate` because metric names, tag-to-label mapping, rollups, missing-data behavior, evaluation delay, routing, and shadowing can differ. Candidate rules preserve the source query and review guidance, and carry these labels: + +```yaml +openexit_candidate: "true" +production_ready: "false" +source: datadog +source_ref: datadog:monitor:... +conversion: approximate ``` -The generated PromQL remains a candidate and includes review annotations. Complex Datadog functions are not converted automatically; the source query is preserved with a `vector(0)` placeholder so the missing work is visible. +Anomaly, outlier, forecast, change, timeshift, composite, and other unsupported monitor behavior remains `manual`. No Prometheus file is emitted for it. In particular, OpenExit does not emit `vector(0)` or another executable placeholder. + +## Alloy and OpenTelemetry + +OpenExit emits credential-free OTLP baselines for Alloy and the OpenTelemetry Collector. Integration, log, APM, and service-definition resources link to both files, but remain `manual`: source-specific receivers, endpoints, authentication, TLS, processors, sizing, and routing cannot be reconstructed safely from Datadog control-plane metadata alone. -Validation checks Grafana dashboard candidate JSON against OpenExit's migration safety contract: mapped dashboard paths must exist, source metadata must match inventory, candidate dashboards must stay marked `productionReady=false`, panels must preserve source query hints, and unsupported widgets must be documented in the Grafana README. +The baseline uses the `OPENEXIT_OTLP_ENDPOINT` environment placeholder. It contains no source credentials and is marked as a review candidate. -Validation also checks Prometheus alert-rule candidates against mapping and inventory. Every generated alert must preserve its Datadog monitor ID and source query, keep `openexit_candidate=true` and `production_ready=false`, include manual review annotations, and keep uncertain conversions on explicit `vector(0)` placeholders. +## Provenance -Validation checks the OpenTelemetry Collector candidate for OTLP receivers, memory limiter and batch processors, metrics and traces pipelines, Mimir and Tempo placeholder exporters, candidate warnings, README production-change guidance, and absence of secret-like content. +Every inventory resource has: -Validation checks the ArgoCD candidate for Application shape, OpenExit candidate labels, placeholder repository URL, destination, absence of automated sync, absence of secret-like content, and README review guidance. +- a stable `datadog::` reference; +- a redacted evidence file and SHA-256 digest; +- a source URL where Datadog exposes a useful UI location; +- dependency references where they are discoverable. -## Live Collection +Every conversion record links the source reference, evidence path, status, reason codes, semantic changes, component decisions, and generated outputs. The export manifest reverses this mapping by listing source references for evidence and generated files. -The live Datadog collector stores redacted evidence for dashboards, monitors, SLOs, and integration installation metadata where the Datadog API exposes it. It records referenced metric names and tag keys from dashboard and monitor queries so cost, cardinality, and target sizing review can use the same metadata as fixture-based assessments. Evidence refs in generated assessments resolve to local files under `evidence/datadog/`. +## Validation and Export Gate -## Risk Rules +Planning validates: -OpenExit v0.1 flags the migration risks listed in the implementation plan: +- inventory and evidence digests; +- inventory-to-plan identity and complete conversion coverage; +- generated-file provenance and path safety; +- Grafana candidate structure and safety metadata; +- Prometheus rule structure, source annotations, and absence of fake placeholders; +- Alloy and OpenTelemetry baseline structure and source links; +- secret-like content and symlinks; +- JSON Schema conformance; +- every local link in the static HTML report. + +Export reruns validation against the current workspace. It refuses a stale plan, incomplete catalog without explicit acceptance, failed critical checks, an existing destination without `--force`, symlinks, unsafe paths, or secret-like output. Replacement with `--force` is transactional. + +## Exit Readiness + +The plan records all score inputs: + +```text +C = completed catalog families / catalog families +T = (2 × exact + approximate) / (2 × resources) +V = passed critical validation checks / critical validation checks +score = round(100 × C × (0.9 × T + 0.1 × V)) +``` -- Dashboard risks: unsupported widgets, large dashboards, missing owners, unknown data sources, and complex template variables. -- Monitor risks: Datadog-specific syntax, anomaly/outlier/forecast functions, composite monitors, missing owners, missing runbooks, unknown notification targets, and manual routing needs. -- SLO risks: unclear SLI mapping, target review, missing burn-rate alert mapping, and missing dashboard mapping. -- Cost and scale risks: high-cardinality tags, unknown retention, many custom metrics, unknown log volume, and unknown trace volume. -- Migration risks: alert shadowing, dashboard parity review, manual query review, notification routing review, and unclear on-call ownership. +Manual and unsupported resources contribute zero translation points. A critical validation failure caps the score at 49 and blocks export. The score measures reviewable migration coverage, not production readiness. diff --git a/docs/release.md b/docs/release.md index 66ff109..15138be 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,111 +1,60 @@ # Release Readiness -This checklist prepares OpenExit for its first public release. +## v0.1 Product Contract -## Scope Audit +The release-blocking workflow is: -The implementation plan names Datadog to Grafana LGTM, Prometheus-compatible alerting, and OpenTelemetry Collector/Alloy as the v0.1 primary release path. - -Release-blocking v0.1 requirements: - -- CLI skeleton, project init, built-in demo, workflow runner, readiness status, version command. -- Runtime doctor for version metadata, embedded schemas, and optional validator availability. -- Datadog fixture collector and read-only live Datadog collector. -- Inventory and assessment manifests with typed validation. -- Source-to-target mapping manifest with candidate paths and manual-review entries. -- Risk and manual-review analyzers. -- Markdown handover artifacts. -- Grafana, Prometheus, OpenTelemetry, and ArgoCD candidate generators. -- Typed migration plan manifest with assessment, pilot, shadow, and cutover phase gates. -- Validation engine with embedded JSON Schema checks, Grafana dashboard, Prometheus alert, OpenTelemetry collector, ArgoCD, Forgejo migration, identity realm/client, edge VCL/HAProxy/Coraza, and LiteLLM/vLLM candidate checks, YAML/JSON parsing, evidence refs, secret scan, optional promtool, and optional kubeconform. -- Evidence bundle export with checksums, a schema-backed machine-readable manifest, and OpenExit version metadata. -- Offline evidence bundle verification for archive path safety, manifest schema, manifest digests, and checksums. -- Machine-readable release manifest generation and offline release artifact verification for OS/architecture binaries, auxiliary release assets, and `SHA256SUMS`. -- Verified release installer for Linux/macOS `amd64` and `arm64` downloads. -- Shell completion generation and release completion assets for Bash, Zsh, Fish, and PowerShell. -- CycloneDX JSON SBOM generation for release binaries and Go module dependencies. -- No-op AI assist and optional external assist behind explicit opt-in. -- Documentation, examples, CI, release draft workflow, and reproducible release artifacts. - -Additional assessment paths present in this repository: - -- GitHub Enterprise to Forgejo, with fixture import and read-only live repository inventory collection. -- Okta/Auth0 to Keycloak/Zitadel, with fixture import and read-only live Okta/Auth0 identity inventory collection. -- Cloudflare/Akamai to Varnish/HAProxy/Coraza, with fixture import and read-only live Cloudflare/Akamai edge inventory collection. -- OpenAI/Anthropic to vLLM/LiteLLM, with fixture import and read-only live OpenAI/Anthropic aggregate usage inventory collection. - -The AI provider path is complete for local fixture assessment workflows and includes read-only live OpenAI and Anthropic collectors for aggregate usage. Datadog, GitHub Enterprise, Okta, Auth0, Cloudflare, Akamai, OpenAI, and Anthropic currently include read-only live SaaS collectors. - -## Release Checklist - -- [ ] `git status --short --branch` is clean and on the intended release branch. -- [ ] GitHub Actions CI passes the same `make release-check VERSION=0.1.0-ci` gate used for local release readiness. -- [ ] `make release-check VERSION=0.1.0` passes locally, including verification, CLI smoke pipelines, bundle verification, release artifact builds, installer smoke, and checksum count checks. -- [ ] `make verify VERSION=0.1.0` passes, including CLI smoke pipelines. -- [ ] `make lint` runs `gofmt`, `golangci-lint`, and `go vet`. -- [ ] `make release-dist VERSION=0.1.0` produces binaries, `dist/SHA256SUMS`, `dist/RELEASE_MANIFEST.json`, `dist/SBOM.cdx.json`, `dist/install.sh`, and completion assets `openexit.bash`, `_openexit`, `openexit.fish`, and `openexit.ps1`. -- [ ] `openexit verify-release dist/RELEASE_MANIFEST.json --dist dist --require-checksums` passes and covers binaries, `install.sh`, and completion assets; it fails when a release artifact is tampered with. -- [ ] `OPENEXIT_VERSION=0.1.0 OPENEXIT_BASE_URL=$PWD/dist BIN_DIR=$(mktemp -d)/bin sh scripts/install.sh` installs a verified local release binary and `openexit version` reports `0.1.0`. -- [ ] `make example VERSION=0.1.0-dev` refreshes `examples/datadog-to-grafana/output/` and exports `examples/datadog-to-grafana/openexit-example.zip`. -- [ ] Datadog definition-of-done pipeline passes: - `init`, `collect fixture`, `assess`, `map`, `generate --all`, `validate`, `export`. -- [ ] GitHub, Okta, Auth0, Cloudflare, Akamai, OpenAI, and Anthropic fixture/live-collector test coverage pass, and supported fixture provider pipelines validate. -- [ ] `openexit version` prints name, version, commit, and date from release build flags. -- [ ] `openexit doctor` reports passing version/schema checks and warns, rather than crashes, when optional validators are absent. -- [ ] `openexit demo ` creates a complete sample project and evidence bundle from built-in fixture data without repository-local `testdata/`. -- [ ] `openexit run --project --export --out ` completes a collected project through assessment, mapping, generation, validation, status reporting, and bundle export. -- [ ] `openexit status --project ` reports inventory, assessment, mapping, generated artifacts, validation status, export readiness, and matching `--json` output. -- [ ] `openexit completion bash`, `zsh`, `fish`, and `powershell` each generate non-empty shell completion scripts. -- [ ] `openexit sbom --out SBOM.cdx.json` generates valid CycloneDX JSON with OpenExit build metadata and Go module dependencies. -- [ ] `README.md`, `docs/cli.md`, `docs/security.md`, and this checklist reflect current behavior. -- [ ] `examples/datadog-to-grafana/README.md` reproduces the primary local demo. -- [ ] `assessment/openexit.migration-plan.yaml`, `.json`, and `migration-plan.md` are generated by the demo pipeline and included in exported bundles. -- [ ] `mapping/openexit.mapping.yaml`, `.json`, and `mapping-summary.md` are generated by the demo pipeline and included in exported bundles. -- [ ] `validation/validation-report.md` includes passing `jsonschema-*` checks for project, inventory, assessment, mapping, migration plan, and the validation report. -- [ ] `validation/validation-report.md` includes `project-path-safety: passed`. -- [ ] `validation/validation-report.md` includes `grafana-dashboard-candidates: passed` for the Datadog definition-of-done pipeline. -- [ ] `validation/validation-report.md` includes `prometheus-rule-candidates: passed` for the Datadog definition-of-done pipeline. -- [ ] `validation/validation-report.md` includes `opentelemetry-candidate: passed` for the Datadog definition-of-done pipeline. -- [ ] `validation/validation-report.md` includes `argocd-candidate: passed` for the Datadog definition-of-done pipeline. -- [ ] `validation/validation-report.md` includes `forgejo-migration-candidate: passed` for the GitHub Enterprise to Forgejo pipeline. -- [ ] `validation/validation-report.md` includes `identity-realm-client-candidate: passed` for the Okta/Auth0 to Keycloak/Zitadel pipeline. -- [ ] `validation/validation-report.md` includes `edge-candidates: passed` for the Cloudflare/Akamai to Varnish/HAProxy/Coraza pipeline. -- [ ] `validation/validation-report.md` includes `litellm-config-candidate: passed` for the OpenAI/Anthropic to vLLM/LiteLLM pipeline. -- [ ] `CHANGELOG.md` has a `0.1.0` section. -- [ ] Exported bundle README includes version, commit, build date, bundle timestamp, and candidate warning. -- [ ] Exported bundle `manifest.json` includes build metadata, project source/target, validation totals, and per-file SHA-256 digests, and validates against `schemas/openexit.evidence-bundle.schema.json`. -- [ ] `openexit verify-bundle ` passes for exported bundles and fails when an archived file is tampered with. -- [ ] Release manifest validates against `schemas/openexit.release-manifest.schema.json` and includes version, commit, build date, generation time, artifact type, binary OS/architecture metadata, sizes, and SHA-256 digests, including `SBOM.cdx.json`. -- [ ] Export refuses symlinks in exported project sections, including when `--force` is used. -- [ ] No credentials, tokens, passwords, or private keys are present in fixtures, generated files, docs, or bundles. -- [ ] Draft release notes have been reviewed. - -## Draft Release Body - -OpenExit `v0.1.0` is the first public release of the local-first SaaS-to-open-source migration assessment CLI. - -Primary supported path: - -- Datadog to Grafana LGTM, Prometheus-compatible alerting, and OpenTelemetry Collector/Alloy candidate artifacts. - -Additional assessment paths: - -- GitHub Enterprise to Forgejo, including a read-only live GitHub/GitHub Enterprise collector for repository migration inventory. -- Okta/Auth0 to Keycloak/Zitadel, including read-only live Okta/Auth0 collectors for identity migration inventory. -- Cloudflare/Akamai to Varnish/HAProxy/Coraza, including read-only live Cloudflare/Akamai collectors for edge migration inventory. -- OpenAI/Anthropic to vLLM/LiteLLM, including read-only live OpenAI/Anthropic aggregate usage collectors. - -Safety model: - -- No production writes. -- No credential storage. -- No hidden hosted backend. -- No AI dependency. -- Generated configs are candidates and require human review. +```bash +openexit datadog scan +openexit datadog plan --target grafana-lgtm +openexit datadog export --out migration/ +``` -Verification before publishing: +Release scope includes: + +- GET-only, paginated Datadog observability inventory with endpoint-level coverage and redacted evidence; +- deterministic `exact`, `approximate`, `manual`, and `unsupported` decisions for every inventoried resource; +- source-linked Grafana dashboards and safe-subset Prometheus alert candidates; +- source-linked, credential-free Alloy and OpenTelemetry baselines; +- explicit semantic changes and no fake executable placeholders; +- static self-contained HTML report and transparent exit-readiness score; +- embedded inventory, plan, validation, and bundle JSON Schemas; +- stale-plan, provenance, candidate-safety, report-link, path, symlink, and secret validation; +- transactional directory export with `manifest.json` and `SHA256SUMS`; +- no AI conversion, source mutation, automatic deployment, or cutover. + +GitHub Enterprise, identity, edge, AI-provider, and legacy project workflows remain experimental and are not release claims for the primary v0.1 product. + +## Checklist + +- [ ] `git status --short --branch` contains only intended release changes. +- [ ] `make verify VERSION=0.1.0` passes formatting, static analysis, all tests, the primary Datadog smoke test, and experimental compatibility smoke tests. +- [ ] `make example VERSION=0.1.0` produces `examples/datadog-to-grafana/migration/index.html`, generated candidates, `manifest.json`, and `SHA256SUMS`. +- [ ] A live least-privilege Datadog account completes every expected endpoint, or any unavailable endpoint is understood and visible in the report. +- [ ] A permission-denied endpoint fails `scan` without `--allow-partial` and remains visible in inventory endpoint coverage. +- [ ] Repeating fixture scan/plan with a fixed timestamp produces byte-identical inventory, plan, candidates, validation, and HTML. +- [ ] Every inventory resource has exactly one plan decision and resolvable evidence. +- [ ] Every non-baseline generated file has one or more source references. +- [ ] Complex monitors emit no Prometheus rule and no generated file contains `vector(0)`. +- [ ] Grafana and Prometheus candidates remain marked non-production-ready. +- [ ] Tampered evidence or generated output blocks export. +- [ ] Export rejects stale plans, symlinks, unsafe paths, failed critical validation, and an existing destination without `--force`. +- [ ] Exported report links resolve offline and `SHA256SUMS` matches every listed file. +- [ ] `openexit doctor` compiles every embedded schema. +- [ ] `make release-dist VERSION=0.1.0` produces platform binaries, installer, completions, SBOM, `RELEASE_MANIFEST.json`, and `SHA256SUMS`. +- [ ] `openexit verify-release dist/RELEASE_MANIFEST.json --dist dist --require-checksums` passes and detects tampering. +- [ ] The installer selects and verifies the current platform binary. +- [ ] README, Datadog details, CLI, security, schemas, changelog, and example instructions match current behavior. +- [ ] No credentials, private keys, tokens, or customer data are present in repository fixtures or generated release assets. + +## Release Commands ```bash -openexit demo ./demo +make verify VERSION=0.1.0 make release-check VERSION=0.1.0 +openexit verify-release dist/RELEASE_MANIFEST.json --dist dist --require-checksums ``` + +## Draft Release Summary + +OpenExit v0.1 generates a deterministic, read-only Datadog-to-Grafana-LGTM migration plan. It inventories the Datadog observability control plane, emits only the safely recognized Grafana and Prometheus subset, creates review baselines for Alloy and OpenTelemetry, and packages a source-linked static report. Anything uncertain is explicit manual work. OpenExit does not use AI conversion, mutate Datadog, or deploy target configuration. diff --git a/docs/schemas.md b/docs/schemas.md index 749d446..bec9a5f 100644 --- a/docs/schemas.md +++ b/docs/schemas.md @@ -1,23 +1,24 @@ # Schemas -OpenExit schemas live under `schemas/` and mirror the typed Go manifests. Release binaries embed these public Draft 7 JSON Schemas, and `openexit validate` checks project, inventory, assessment, mapping, migration-plan, and validation-report manifests against the embedded copies in addition to typed consistency checks and YAML/JSON parse checks. +OpenExit publishes Draft 7 JSON Schemas under `schemas/` and embeds them in release binaries. -Evidence bundle exports include `openexit-evidence/manifest.json`, which follows `schemas/openexit.evidence-bundle.schema.json`. The manifest records OpenExit build metadata, project source/target metadata, validation totals, and SHA-256 digests for exported project files so downstream review tooling can verify a bundle without parsing every human-readable report first. +## Datadog v0.1 -Release builds include `RELEASE_MANIFEST.json`, which follows `schemas/openexit.release-manifest.schema.json`. The manifest records OpenExit build metadata and every release artifact that should be covered by `SHA256SUMS`: platform binaries use `type: binary` with `os` and `arch`, while installer, shell-completion, and SBOM files use `type: asset`. +| Schema | Generated document | +| --- | --- | +| `openexit.datadog-inventory.schema.json` | `.openexit/inventory/datadog.inventory.json` | +| `openexit.datadog-plan.schema.json` | `.openexit/plan/openexit.plan.json` | +| `openexit.datadog-validation.schema.json` | `.openexit/validation/validation.json` | +| `openexit.migration-bundle.schema.json` | `migration/manifest.json` | -Project manifests must use one of the supported source/target pairs: Datadog to Grafana LGTM, GitHub Enterprise to Forgejo, Okta/Auth0 to Keycloak/Zitadel, Cloudflare/Akamai to Varnish/HAProxy/Coraza, or OpenAI/Anthropic to vLLM/LiteLLM. +The Datadog inventory uses `kind: DatadogInventory` and catalog version `datadog-observability/v1`. It records scan metadata, endpoint-level coverage, stable source references, dependencies, redacted specs, and evidence paths/digests. -Inventory dashboards can include optional `dataSources` and `templateVariables` fields so assessment can flag Grafana mapping risk. Datadog fixture and live collectors populate `metrics` from captured dashboard and monitor queries, including referenced tag keys where available. The live Datadog collector also populates `integrations` from the Datadog v2 Integrations API when accessible. SLOs can include optional `sli`, `burnRateMonitorIds`, and `dashboardRefs` fields. The top-level inventory `volumes` section records whether log and trace volume assumptions are known. +The plan uses `kind: DatadogMigrationPlan`, target `grafana-lgtm`, and ruleset `datadog-grafana-lgtm/v1`. It records the deterministic plan ID, inventory digest, conversion summary, transparent readiness factors, and one conversion decision per source resource. Decisions include status, reason codes, semantic changes, component-level results, and output links. -Mapping manifests use `kind: Mapping` and are written to `mapping/openexit.mapping.yaml` and `.json`. They record candidate dashboard paths, alert-rule candidate paths, unsupported source items, and manual-review entries derived from assessment findings. Validation reloads the mapping manifest and checks that source and target types still match inventory and assessment. +The validation document records every critical or advisory internal check. Export is permitted only when critical checks pass. -Migration plans use `kind: MigrationPlan` and are written to `assessment/openexit.migration-plan.yaml` and `.json`. They group generated outputs into assessment, pilot, shadow, and cutover phases, record required artifact paths, and mark phases as `ready` or `incomplete` based on the files present when the plan is generated. Validation reloads the plan when present and verifies that required artifacts still exist. +The migration bundle manifest records OpenExit build metadata and every payload file’s relative path, size, SHA-256 digest, and source references where applicable. `SHA256SUMS` additionally covers the manifest. -For the GitHub Enterprise to Forgejo path, inventory can include `repositories`, `teams`, `branchProtections`, `actionsWorkflows`, `secrets`, `runners`, `deployKeys`, and `githubApps`. The live collector currently populates repository, team, branch protection, Actions workflow, secret metadata, runner, deploy-key, and GitHub App installation assets. Secret entries are metadata only; OpenExit should never collect or store secret values. +## Legacy and Release Schemas -For the Okta/Auth0 to Keycloak/Zitadel path, inventory can include `identityApplications`, `identityGroups`, `identityPolicies`, `mfaSettings`, and `breakGlassAccounts`. The live Okta collector populates application, group, policy, MFA, and explicitly named break-glass account assets. The live Auth0 collector populates client, role, action/rule metadata, Guardian MFA, and explicitly named break-glass account assets. Client entries include redirect URIs, grant types, owners, group assignments where available, and SAML metadata; they do not include client secrets. - -For the Cloudflare/Akamai to Varnish/HAProxy/Coraza path, inventory can include `dnsRecords`, `wafRules`, `cacheRules`, `redirects`, `origins`, `tlsSettings`, `botRules`, and `pageRules`. The live Cloudflare collector currently populates DNS records, WAF/custom/managed ruleset metadata, cache rules, dynamic redirects, inferred origins, TLS settings, bot-related rules, and page rules. The live Akamai collector populates Edge DNS recordsets, Property Manager hostnames/rule metadata, origins, cache behaviors, redirects, TLS/HSTS metadata, Bot Manager behavior metadata, and optional AppSec custom-rule metadata. WAF entries contain rule metadata and expressions only; they should not contain provider credentials. - -For the OpenAI/Anthropic to vLLM/LiteLLM path, inventory can include `aiModelUsageClasses`, `aiTokenVolumes`, `aiLatencyExpectations`, `aiSensitivePromptCategories`, `aiToolUsages`, and `aiFallbackBehaviors`. The live OpenAI and Anthropic collectors populate model usage classes and token volumes from aggregate usage, and can attach owner, latency, streaming, and fallback metadata supplied as CLI flags. Anthropic server web-search usage is represented as tool metadata when reported. Entries contain operational metadata only; OpenExit should never collect provider credentials, raw prompts, or responses. +The earlier project, generic inventory, assessment, mapping, plan, validation, and evidence-bundle schemas remain embedded for the experimental multi-provider engine. `openexit.release-manifest.schema.json` remains the release-artifact contract used with `SHA256SUMS`. diff --git a/docs/security.md b/docs/security.md index aa57303..8988365 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,22 +1,40 @@ # Security -OpenExit is designed for local-first assessment work. - -- Collectors must not make production writes. -- Live Datadog, GitHub, Okta, Auth0, Cloudflare, Akamai, OpenAI, and Anthropic credentials are read from environment variables or local credential files and are never written to project files. -- The Datadog collector records dashboard, monitor, SLO, integration installation, metric, and tag metadata only; it never mutates Datadog resources. -- The GitHub collector records repository, workflow, runner, deploy-key, GitHub App installation, and secret metadata only; it never requests or stores secret values. -- The Okta collector records client and policy metadata only; it never requests or stores client secrets, passwords, factor secrets, or token values. -- The Auth0 collector records client, role, action/rule, Guardian MFA, and explicit break-glass user metadata only; it does not persist client secrets, action code, rule scripts, user passwords, MFA secrets, or token values. -- The Cloudflare collector records zone configuration metadata only; it never mutates DNS, rulesets, page rules, or zone settings. -- The Akamai collector records Edge DNS, Property Manager, and optional AppSec metadata only; it never mutates DNS, properties, activations, purge state, security configs, or rules. -- The OpenAI collector records aggregate usage and model metadata only; it never requests or stores raw prompts, completions, or API key values. -- The Anthropic collector records aggregate Messages API usage and server tool metadata only; it never requests or stores raw prompts, responses, or API key values. -- Raw source evidence is redacted before it is written locally. -- Validation scans JSON, YAML, Markdown, text, VCL, HAProxy, and Coraza-style generated artifacts for secret-like values. -- Exported bundles contain local manifests, generated candidates, validation output, a machine-readable bundle manifest, checksums, and redacted evidence. -- Export refuses symlinks in exported project sections so bundle contents cannot follow paths outside the project tree. -- `openexit verify-bundle ` verifies archive path safety, manifest schema, manifest digests and sizes, and `checksums.txt` without access to the original project directory. -- AI assist is optional, disabled by default, and never part of deterministic validation or export. - -Generated configs are candidates only. Review them before any operational use. +OpenExit v0.1 is a local, read-only Datadog migration planner. + +## Source Safety + +- The primary Datadog client implements GET requests only. +- API and application keys are read from named environment variables and sent only as `DD-API-KEY` and `DD-APPLICATION-KEY` request headers. +- Credentials are never written to inventory, evidence, reports, logs, or generated configuration. +- API errors include the method, request path, and status code, but never include response bodies. +- Pagination is accepted only when it remains on the configured Datadog scheme and host. +- OpenExit performs no Datadog create, update, delete, mute, deploy, or cutover operation. + +## Local Evidence + +API objects are structurally redacted before they are persisted. Credential-bearing keys, secret-variable values, bearer tokens, Datadog-key-like values, and private-key material are replaced with `[REDACTED]`. Secret-variable identity fields remain intact so resources do not collapse into an unusable anonymous record. + +Each evidence file receives a SHA-256 digest recorded in the inventory. Planning and export recompute those digests and fail if evidence was changed. + +Treat evidence as potentially sensitive operational metadata even after credential redaction. Store and share exported migration directories according to your organization’s observability-data policy. + +## Generated Candidate Safety + +- Grafana dashboards carry `productionReady: false` in OpenExit metadata. +- Prometheus rules carry `openexit_candidate=true` and `production_ready=false` and preserve the source query. +- Unsupported monitor behavior emits no executable rule; fake placeholders such as `vector(0)` are rejected. +- Alloy and OpenTelemetry files contain environment placeholders, not source credentials. +- There is no AI conversion, hosted backend, or automatic deployment in the v0.1 path. + +## Validation and Export + +Validation checks evidence integrity, plan identity, conversion coverage, generated-file provenance, candidate safety metadata, YAML/JSON shape, embedded JSON Schemas, report links, secret-like values, path traversal, and symlinks. + +Export reruns validation against current disk state and copies only these fixed sections: inventory, evidence, plan, generated candidates, validation, `index.html`, and `README.md`. It refuses symlinks and unsafe output targets. Existing export directories require explicit `--force` and are replaced transactionally. + +The exported `manifest.json` records size, SHA-256, and source references for payload files. `SHA256SUMS` covers the payload and manifest. + +## Experimental Providers + +The legacy multi-provider engine has separate provider-specific security behavior documented in its collectors and tests. It is exposed under `openexit experimental` and is outside the primary v0.1 security contract. diff --git a/examples/datadog-to-grafana/README.md b/examples/datadog-to-grafana/README.md index 8dd4737..c33c615 100644 --- a/examples/datadog-to-grafana/README.md +++ b/examples/datadog-to-grafana/README.md @@ -1,6 +1,6 @@ -# Datadog To Grafana Example +# Datadog to Grafana LGTM Example -This example uses a small redacted Datadog-like fixture and writes all generated output to a local project directory. +This example uses a small redacted Datadog fixture and runs the focused v0.1 workflow. Run from the repository root: @@ -8,13 +8,16 @@ Run from the repository root: make example VERSION=0.1.0-dev ``` -This refreshes `examples/datadog-to-grafana/output/` and writes an ignored bundle to `examples/datadog-to-grafana/openexit-example.zip`. -The bundle includes `openexit-evidence/manifest.json` for machine-readable audit metadata and per-file digests. +This writes ignored local state to `.openexit/` and a reviewable directory to `migration/`. Open `migration/index.html` to inspect the inventory, conversion ledger, semantic changes, generated candidates, and exit-readiness score. -For a release-binary smoke test that does not depend on repository-local fixtures, run: +The equivalent commands are: ```bash -openexit demo ./demo +openexit datadog scan --fixture input/datadog-fixture.json +openexit datadog plan --target grafana-lgtm +openexit datadog export --out migration/ ``` -The generated files are candidates only. Review every dashboard, alert rule, collector sketch, migration-plan phase gate, and runbook before operational use. +The checked-in `output/` directory is a snapshot from the earlier experimental multi-stage engine. It remains only for historical compatibility and is not the v0.1 output contract. + +Generated files are candidates only. Review every dashboard, alert rule, Alloy/OpenTelemetry baseline, and manual ledger item before operational use. diff --git a/internal/app/command.go b/internal/app/command.go index 86a97e5..776dd1d 100644 --- a/internal/app/command.go +++ b/internal/app/command.go @@ -35,31 +35,52 @@ import ( func NewRootCommand() *cobra.Command { root := &cobra.Command{ Use: "openexit", - Short: "Local-first SaaS-to-open-source migration assessments", + Short: "Reviewable Datadog to Grafana LGTM migration plans", SilenceUsage: true, SilenceErrors: true, } root.AddCommand(newVersionCommand()) root.AddCommand(newDoctorCommand()) - root.AddCommand(newInitCommand()) - root.AddCommand(newDemoCommand()) - root.AddCommand(newStatusCommand()) - root.AddCommand(newRunCommand()) - root.AddCommand(newCollectCommand()) - root.AddCommand(newAssessCommand()) - root.AddCommand(newMapCommand()) - root.AddCommand(newGenerateCommand()) - root.AddCommand(newValidateCommand()) - root.AddCommand(newExportCommand()) + root.AddCommand(newDatadogWorkflowCommand()) + root.AddCommand(newExperimentalCommand()) + for _, legacy := range newLegacyWorkflowCommands() { + legacy.Hidden = true + root.AddCommand(legacy) + } root.AddCommand(newVerifyBundleCommand()) root.AddCommand(newReleaseManifestCommand()) root.AddCommand(newVerifyReleaseCommand()) root.AddCommand(newCompletionCommand(root)) root.AddCommand(newSBOMCommand()) - root.AddCommand(newAssistCommand()) return root } +func newExperimentalCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "experimental", + Short: "Experimental providers and the legacy multi-provider workflow", + Long: "Experimental migration paths are retained for evaluation, but are not part of the focused Datadog-to-Grafana-LGTM v0.1 workflow.", + } + cmd.AddCommand(newLegacyWorkflowCommands()...) + return cmd +} + +func newLegacyWorkflowCommands() []*cobra.Command { + return []*cobra.Command{ + newInitCommand(), + newDemoCommand(), + newStatusCommand(), + newRunCommand(), + newCollectCommand(), + newAssessCommand(), + newMapCommand(), + newGenerateCommand(), + newValidateCommand(), + newExportCommand(), + newAssistCommand(), + } +} + func newVersionCommand() *cobra.Command { return &cobra.Command{ Use: "version", diff --git a/internal/app/datadog.go b/internal/app/datadog.go new file mode 100644 index 0000000..3267c22 --- /dev/null +++ b/internal/app/datadog.go @@ -0,0 +1,120 @@ +package app + +import ( + "fmt" + "path/filepath" + + "github.com/RamazanKara/openexit/internal/datadogplan" + "github.com/RamazanKara/openexit/internal/version" + "github.com/spf13/cobra" +) + +func newDatadogWorkflowCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "datadog", + Short: "Plan a read-only Datadog to Grafana LGTM migration", + Long: "Scan Datadog with read-only API requests, generate deterministic Grafana, Prometheus, Alloy, and OpenTelemetry candidates, and export a reviewable migration bundle.", + } + cmd.AddCommand(newDatadogScanCommand()) + cmd.AddCommand(newDatadogPlanCommand()) + cmd.AddCommand(newDatadogExportCommand()) + return cmd +} + +func newDatadogScanCommand() *cobra.Command { + var workDir, site, apiKeyEnv, appKeyEnv, fixture, baseURL string + var allowPartial bool + cmd := &cobra.Command{ + Use: "scan", + Short: "Inventory Datadog resources using GET-only API requests", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + inventory, err := datadogplan.Scan(cmd.Context(), datadogplan.ScanOptions{ + WorkDir: workDir, + Site: site, + APIKeyEnv: apiKeyEnv, + AppKeyEnv: appKeyEnv, + Fixture: fixture, + BaseURL: baseURL, + Version: version.Version, + AllowPartial: allowPartial, + }) + if inventory != nil { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "inventory: %s\nresources: %d\ncatalog-complete: %t\ndigest: %s\n", + filepath.Join(workDir, filepath.FromSlash(datadogplan.InventoryRel)), len(inventory.Resources), inventory.Catalog.Complete, inventory.Metadata.SnapshotDigest) + } + return err + }, + } + cmd.Flags().StringVar(&workDir, "workdir", datadogplan.DefaultWorkDir, "OpenExit Datadog state directory") + cmd.Flags().StringVar(&site, "site", "datadoghq.com", "Datadog site, such as datadoghq.com or datadoghq.eu") + cmd.Flags().StringVar(&apiKeyEnv, "api-key-env", "DATADOG_API_KEY", "Environment variable containing the read-only Datadog API key") + cmd.Flags().StringVar(&appKeyEnv, "app-key-env", "DATADOG_APP_KEY", "Environment variable containing the read-only Datadog application key") + cmd.Flags().StringVar(&fixture, "fixture", "", "Read a local Datadog fixture instead of calling the API") + cmd.Flags().BoolVar(&allowPartial, "allow-partial", false, "Persist and accept an incomplete catalog scan") + cmd.Flags().StringVar(&baseURL, "base-url", "", "Override the Datadog API base URL") + _ = cmd.Flags().MarkHidden("base-url") + return cmd +} + +func newDatadogPlanCommand() *cobra.Command { + var workDir, target string + var allowPartial bool + cmd := &cobra.Command{ + Use: "plan", + Short: "Generate deterministic migration candidates and a static report", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + plan, validation, err := datadogplan.Plan(datadogplan.PlanOptions{ + WorkDir: workDir, + Target: target, + AllowPartial: allowPartial, + }) + if plan != nil { + validationStatus := "unknown" + if validation != nil { + validationStatus = validation.Status + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "plan: %s\nreport: %s\nexit-readiness: %d/100 (%s)\nvalidation: %s\n", + filepath.Join(workDir, filepath.FromSlash(datadogplan.PlanRel)), filepath.Join(workDir, datadogplan.ReportRel), + plan.Readiness.Score, plan.Readiness.Level, validationStatus) + } + return err + }, + } + cmd.Flags().StringVar(&workDir, "workdir", datadogplan.DefaultWorkDir, "OpenExit Datadog state directory") + cmd.Flags().StringVar(&target, "target", datadogplan.DefaultTarget, "Migration target (v0.1 supports grafana-lgtm)") + cmd.Flags().BoolVar(&allowPartial, "allow-partial", false, "Plan from a scan explicitly accepted as incomplete") + return cmd +} + +func newDatadogExportCommand() *cobra.Command { + var workDir, out string + var force, allowPartial bool + cmd := &cobra.Command{ + Use: "export", + Short: "Export a validated, reviewable migration directory", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + manifest, err := datadogplan.Export(datadogplan.ExportOptions{ + WorkDir: workDir, + Out: out, + Force: force, + AllowPartial: allowPartial, + Version: version.Version, + Commit: version.Commit, + Date: version.Date, + }) + if manifest != nil { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "exported: %s\nplan-id: %s\nfiles: %d\n", out, manifest.PlanID, len(manifest.Files)) + } + return err + }, + } + cmd.Flags().StringVar(&workDir, "workdir", datadogplan.DefaultWorkDir, "OpenExit Datadog state directory") + cmd.Flags().StringVar(&out, "out", "", "Migration output directory") + cmd.Flags().BoolVar(&force, "force", false, "Replace an existing output directory transactionally") + cmd.Flags().BoolVar(&allowPartial, "allow-partial", false, "Export a plan created from an explicitly accepted partial scan") + _ = cmd.MarkFlagRequired("out") + return cmd +} diff --git a/internal/app/datadog_test.go b/internal/app/datadog_test.go new file mode 100644 index 0000000..e43bab2 --- /dev/null +++ b/internal/app/datadog_test.go @@ -0,0 +1,86 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDatadogV01CLIWorkflow(t *testing.T) { + root := t.TempDir() + workDir := filepath.Join(root, ".openexit") + exportDir := filepath.Join(root, "migration") + fixture := filepath.Join("..", "..", "testdata", "datadog", "small.json") + + out, err := executeForTestWithOutput("datadog", "scan", "--fixture", fixture, "--workdir", workDir) + if err != nil { + t.Fatalf("datadog scan: %v\n%s", err, out) + } + for _, marker := range []string{"resources: 7", "catalog-complete: true", "digest:"} { + if !strings.Contains(out, marker) { + t.Fatalf("scan output missing %q:\n%s", marker, out) + } + } + + out, err = executeForTestWithOutput("datadog", "plan", "--target", "grafana-lgtm", "--workdir", workDir) + if err != nil { + t.Fatalf("datadog plan: %v\n%s", err, out) + } + for _, marker := range []string{"exit-readiness:", "validation: passed", "report:"} { + if !strings.Contains(out, marker) { + t.Fatalf("plan output missing %q:\n%s", marker, out) + } + } + + out, err = executeForTestWithOutput("datadog", "export", "--out", exportDir, "--workdir", workDir) + if err != nil { + t.Fatalf("datadog export: %v\n%s", err, out) + } + for _, marker := range []string{"exported:", "plan-id:", "files:"} { + if !strings.Contains(out, marker) { + t.Fatalf("export output missing %q:\n%s", marker, out) + } + } + for _, rel := range []string{ + "inventory/datadog.inventory.json", + "plan/openexit.plan.json", + "generated/grafana/dashboards", + "generated/prometheus/rules", + "generated/alloy/config.alloy", + "generated/opentelemetry/collector.yaml", + "validation/validation.json", + "index.html", + "manifest.json", + "SHA256SUMS", + } { + if _, err := os.Stat(filepath.Join(exportDir, filepath.FromSlash(rel))); err != nil { + t.Fatalf("missing exported %s: %v", rel, err) + } + } +} + +func TestPrimaryCommandSurfaceKeepsLegacyProvidersExperimental(t *testing.T) { + root := NewRootCommand() + visible := map[string]bool{} + for _, command := range root.Commands() { + if !command.Hidden { + visible[command.Name()] = true + } + } + if !visible["datadog"] || !visible["experimental"] { + t.Fatalf("primary command surface is missing datadog or experimental: %#v", visible) + } + for _, legacy := range []string{"init", "demo", "run", "collect", "assess", "map", "generate", "validate", "export", "assist"} { + if visible[legacy] { + t.Fatalf("legacy command %s should be hidden from the primary surface", legacy) + } + } + experimental, _, err := root.Find([]string{"experimental"}) + if err != nil { + t.Fatal(err) + } + if command, _, err := experimental.Find([]string{"collect"}); err != nil || command.Name() != "collect" { + t.Fatalf("experimental provider collector is unavailable: command=%v err=%v", command, err) + } +} diff --git a/internal/datadogplan/catalog.go b/internal/datadogplan/catalog.go new file mode 100644 index 0000000..9607970 --- /dev/null +++ b/internal/datadogplan/catalog.go @@ -0,0 +1,57 @@ +package datadogplan + +type endpointSpec struct { + Family string + Kind string + Path string + ArrayKeys []string + Pagination string + PageSize int + Singleton bool + InstalledOnly bool + DetailPath string + RelatedPath string + RelatedKey string +} + +var catalogEndpointSpecs = []endpointSpec{ + {Family: "dashboards", Kind: "dashboard", Path: "/api/v1/dashboard", ArrayKeys: []string{"dashboards"}, Pagination: "start", PageSize: 100, DetailPath: "/api/v1/dashboard/%s"}, + {Family: "dashboards", Kind: "dashboard_list", Path: "/api/v1/dashboard/lists/manual", ArrayKeys: []string{"dashboard_lists"}, DetailPath: "/api/v1/dashboard/lists/manual/%s", RelatedPath: "/api/v1/dashboard/lists/manual/%s/dashboards", RelatedKey: "dashboards"}, + {Family: "dashboards", Kind: "powerpack", Path: "/api/v2/powerpacks", ArrayKeys: []string{"data"}, Pagination: "page-offset", PageSize: 25, DetailPath: "/api/v2/powerpacks/%s"}, + {Family: "alerting", Kind: "monitor", Path: "/api/v1/monitor", Pagination: "monitor", PageSize: 100}, + {Family: "alerting", Kind: "monitor_policy", Path: "/api/v2/monitor/policy", ArrayKeys: []string{"data"}}, + {Family: "alerting", Kind: "downtime", Path: "/api/v2/downtime", ArrayKeys: []string{"data"}, Pagination: "page-offset", PageSize: 30}, + {Family: "slos", Kind: "slo", Path: "/api/v1/slo", ArrayKeys: []string{"data"}, Pagination: "offset", PageSize: 1000}, + {Family: "slos", Kind: "slo_correction", Path: "/api/v1/slo/correction", ArrayKeys: []string{"data"}, Pagination: "offset", PageSize: 25}, + {Family: "notebooks", Kind: "notebook", Path: "/api/v1/notebooks", ArrayKeys: []string{"data"}, Pagination: "notebook", PageSize: 100}, + {Family: "synthetics", Kind: "synthetic_test", Path: "/api/v1/synthetics/tests", ArrayKeys: []string{"tests"}, Pagination: "page-number", PageSize: 100, DetailPath: "/api/v1/synthetics/tests/%s"}, + {Family: "synthetics", Kind: "synthetic_variable", Path: "/api/v1/synthetics/variables", ArrayKeys: []string{"variables"}}, + {Family: "synthetics", Kind: "synthetic_location", Path: "/api/v1/synthetics/locations", ArrayKeys: []string{"locations"}}, + {Family: "metrics", Kind: "metric", Path: "/api/v2/metrics", ArrayKeys: []string{"data"}, Pagination: "cursor", PageSize: 1000}, + {Family: "logs", Kind: "log_pipeline", Path: "/api/v1/logs/config/pipelines"}, + {Family: "logs", Kind: "log_pipeline_order", Path: "/api/v1/logs/config/pipeline-order", Singleton: true}, + {Family: "logs", Kind: "log_index", Path: "/api/v1/logs/config/indexes", ArrayKeys: []string{"indexes"}}, + {Family: "logs", Kind: "log_archive", Path: "/api/v2/logs/config/archives", ArrayKeys: []string{"data"}}, + {Family: "logs", Kind: "log_metric", Path: "/api/v2/logs/config/metrics", ArrayKeys: []string{"data"}}, + {Family: "apm", Kind: "apm_retention_filter", Path: "/api/v2/apm/config/retention-filters", ArrayKeys: []string{"data"}}, + {Family: "apm", Kind: "span_metric", Path: "/api/v2/apm/config/metrics", ArrayKeys: []string{"data"}}, + {Family: "services", Kind: "service_definition", Path: "/api/v2/services/definitions", ArrayKeys: []string{"data"}, Pagination: "page-bracket-number", PageSize: 100}, + {Family: "integrations", Kind: "integration", Path: "/api/v2/integrations", ArrayKeys: []string{"data"}, InstalledOnly: true}, + {Family: "integrations", Kind: "aws_integration", Path: "/api/v2/integration/aws/accounts", ArrayKeys: []string{"data"}}, + {Family: "integrations", Kind: "azure_integration", Path: "/api/v1/integration/azure"}, + {Family: "integrations", Kind: "gcp_integration", Path: "/api/v2/integration/gcp/accounts", ArrayKeys: []string{"data"}}, + {Family: "integrations", Kind: "gcp_legacy_integration", Path: "/api/v1/integration/gcp"}, +} + +var catalogFamilies = []string{ + "dashboards", + "alerting", + "slos", + "notebooks", + "synthetics", + "metrics", + "logs", + "apm", + "services", + "integrations", +} diff --git a/internal/datadogplan/client.go b/internal/datadogplan/client.go new file mode 100644 index 0000000..fb752ec --- /dev/null +++ b/internal/datadogplan/client.go @@ -0,0 +1,181 @@ +package datadogplan + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type apiClient struct { + baseURL string + apiKey string + appKey string + http *http.Client +} + +type apiError struct { + StatusCode int + Path string +} + +func (err *apiError) Error() string { + return fmt.Sprintf("Datadog GET %s returned HTTP %d", err.Path, err.StatusCode) +} + +func newAPIClient(site, baseURL, apiKey, appKey string, httpClient *http.Client) (*apiClient, error) { + if strings.TrimSpace(apiKey) == "" || strings.TrimSpace(appKey) == "" { + return nil, fmt.Errorf("datadog API and application keys are required") + } + if baseURL == "" { + var err error + baseURL, err = datadogAPIBaseURL(site) + if err != nil { + return nil, err + } + } + parsed, err := url.Parse(baseURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("invalid Datadog API base URL") + } + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + safeHTTP := *httpClient + // Do not let custom Datadog credential headers cross a redirect boundary. + // A redirect is surfaced as a normal non-2xx API result instead. + safeHTTP.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + return &apiClient{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + appKey: appKey, + http: &safeHTTP, + }, nil +} + +func datadogAPIBaseURL(site string) (string, error) { + site = strings.TrimSpace(site) + if site == "" { + site = "datadoghq.com" + } + allowed := map[string]bool{ + "datadoghq.com": true, + "datadoghq.eu": true, + "us3.datadoghq.com": true, + "us5.datadoghq.com": true, + "ap1.datadoghq.com": true, + "ap2.datadoghq.com": true, + "uk1.datadoghq.com": true, + "ddog-gov.com": true, + "us2.ddog-gov.com": true, + } + if !allowed[site] { + return "", fmt.Errorf("unsupported Datadog site %q", site) + } + return "https://api." + site, nil +} + +func (c *apiClient) get(ctx context.Context, endpoint string) ([]byte, error) { + requestURL, err := c.resolve(endpoint) + if err != nil { + return nil, err + } + var lastErr error + for attempt := 0; attempt < 4; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("DD-API-KEY", c.apiKey) + req.Header.Set("DD-APPLICATION-KEY", c.appKey) + req.Header.Set("Accept", "application/json") + resp, err := c.http.Do(req) + if err != nil { + lastErr = err + if err := waitForRetry(ctx, time.Duration(attempt+1)*200*time.Millisecond); err != nil { + return nil, err + } + continue + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) + _ = resp.Body.Close() + if readErr != nil { + return nil, readErr + } + if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { + lastErr = &apiError{StatusCode: resp.StatusCode, Path: requestPath(requestURL)} + delay := retryDelay(resp.Header.Get("Retry-After"), attempt) + if err := waitForRetry(ctx, delay); err != nil { + return nil, err + } + continue + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, &apiError{StatusCode: resp.StatusCode, Path: requestPath(requestURL)} + } + return body, nil + } + if lastErr == nil { + lastErr = fmt.Errorf("datadog GET failed") + } + return nil, lastErr +} + +func (c *apiClient) resolve(endpoint string) (string, error) { + base, _ := url.Parse(c.baseURL) + next, err := url.Parse(endpoint) + if err != nil { + return "", err + } + resolved := base.ResolveReference(next) + if resolved.Scheme != base.Scheme || !strings.EqualFold(resolved.Host, base.Host) { + return "", fmt.Errorf("datadog pagination URL changed host") + } + return resolved.String(), nil +} + +func decodeJSON(data []byte) (any, error) { + var value any + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.UseNumber() + if err := dec.Decode(&value); err != nil { + return nil, err + } + return value, nil +} + +func retryDelay(value string, attempt int) time.Duration { + if seconds, err := strconv.Atoi(value); err == nil && seconds > 0 { + if seconds > 30 { + seconds = 30 + } + return time.Duration(seconds) * time.Second + } + return time.Duration(attempt+1) * 500 * time.Millisecond +} + +func waitForRetry(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func requestPath(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "Datadog API" + } + return u.EscapedPath() +} diff --git a/internal/datadogplan/export.go b/internal/datadogplan/export.go new file mode 100644 index 0000000..5be5cdc --- /dev/null +++ b/internal/datadogplan/export.go @@ -0,0 +1,438 @@ +package datadogplan + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const ( + BundleManifestRel = "manifest.json" + BundleChecksumsRel = "SHA256SUMS" +) + +type ExportOptions struct { + WorkDir string + Out string + Force bool + AllowPartial bool + Version string + Commit string + Date string +} + +func Export(opts ExportOptions) (*BundleManifest, error) { + if opts.WorkDir == "" { + opts.WorkDir = DefaultWorkDir + } + if strings.TrimSpace(opts.Out) == "" { + return nil, fmt.Errorf("export output directory is required") + } + out, err := safeExportTarget(opts.WorkDir, opts.Out) + if err != nil { + return nil, err + } + if _, err := os.Lstat(out); err == nil && !opts.Force { + return nil, fmt.Errorf("export target already exists: %s (use --force to replace it)", out) + } else if err != nil && !os.IsNotExist(err) { + return nil, err + } + + var inventory Inventory + if err := ReadJSON(filepath.Join(opts.WorkDir, filepath.FromSlash(InventoryRel)), &inventory); err != nil { + return nil, fmt.Errorf("read Datadog inventory: %w", err) + } + var plan MigrationPlan + if err := ReadJSON(filepath.Join(opts.WorkDir, filepath.FromSlash(PlanRel)), &plan); err != nil { + return nil, fmt.Errorf("read Datadog migration plan: %w", err) + } + var savedValidation ValidationReport + if err := ReadJSON(filepath.Join(opts.WorkDir, filepath.FromSlash(ValidationRel)), &savedValidation); err != nil { + return nil, fmt.Errorf("read Datadog validation report: %w", err) + } + if !inventory.Catalog.Complete && !opts.AllowPartial { + return nil, &IncompleteScanError{Families: incompleteFamilies(inventory.Catalog)} + } + if savedValidation.Status != "passed" { + return nil, fmt.Errorf("migration plan validation status is %q; regenerate a passing plan before export", savedValidation.Status) + } + if err := validateSchemaFile("openexit.datadog-validation.schema.json", filepath.Join(opts.WorkDir, filepath.FromSlash(ValidationRel))); err != nil { + return nil, fmt.Errorf("saved validation report schema: %w", err) + } + validation := validateWorkspace(opts.WorkDir, opts.WorkDir, &inventory, &plan, opts.AllowPartial) + if validation.Status != "passed" { + return nil, fmt.Errorf("current migration workspace failed export validation: %s", failedValidationChecks(validation)) + } + if err := validateSavedValidation(&savedValidation, validation); err != nil { + return nil, err + } + if err := validateReadiness(&inventory, &plan, validation); err != nil { + return nil, err + } + + parent := filepath.Dir(out) + if err := os.MkdirAll(parent, 0o755); err != nil { + return nil, err + } + stage, err := os.MkdirTemp(parent, ".openexit-export-*") + if err != nil { + return nil, err + } + defer func() { _ = os.RemoveAll(stage) }() + + for _, rel := range []string{"inventory", "evidence", "plan", "generated", "validation", ReportRel, BundleReadmeRel} { + source := filepath.Join(opts.WorkDir, filepath.FromSlash(rel)) + if _, err := os.Lstat(source); err != nil { + return nil, fmt.Errorf("required export content %s: %w", rel, err) + } + if err := copyExportPath(source, filepath.Join(stage, filepath.FromSlash(rel))); err != nil { + return nil, fmt.Errorf("copy %s: %w", rel, err) + } + } + if err := EnsureNoSymlinks(stage); err != nil { + return nil, err + } + + // Re-read and revalidate the staged bytes. This closes the gap between + // validating the workspace and copying it if another process changes local + // state concurrently. + var copiedInventory Inventory + if err := ReadJSON(filepath.Join(stage, filepath.FromSlash(InventoryRel)), &copiedInventory); err != nil { + return nil, fmt.Errorf("read staged Datadog inventory: %w", err) + } + var copiedPlan MigrationPlan + if err := ReadJSON(filepath.Join(stage, filepath.FromSlash(PlanRel)), &copiedPlan); err != nil { + return nil, fmt.Errorf("read staged Datadog migration plan: %w", err) + } + var copiedSavedValidation ValidationReport + if err := ReadJSON(filepath.Join(stage, filepath.FromSlash(ValidationRel)), &copiedSavedValidation); err != nil { + return nil, fmt.Errorf("read staged Datadog validation report: %w", err) + } + if err := validateSchemaFile("openexit.datadog-validation.schema.json", filepath.Join(stage, filepath.FromSlash(ValidationRel))); err != nil { + return nil, fmt.Errorf("staged validation report schema: %w", err) + } + copiedValidation := validateWorkspace(stage, stage, &copiedInventory, &copiedPlan, opts.AllowPartial) + if copiedValidation.Status != "passed" { + return nil, fmt.Errorf("staged migration bundle failed export validation: %s", failedValidationChecks(copiedValidation)) + } + if err := validateSavedValidation(&copiedSavedValidation, copiedValidation); err != nil { + return nil, fmt.Errorf("staged migration bundle: %w", err) + } + if err := validateReadiness(&copiedInventory, &copiedPlan, copiedValidation); err != nil { + return nil, fmt.Errorf("staged migration bundle: %w", err) + } + inventory = copiedInventory + plan = copiedPlan + + manifest := &BundleManifest{ + APIVersion: APIVersion, + Kind: BundleKind, + PlanID: plan.Metadata.PlanID, + InventoryDigest: inventory.Metadata.SnapshotDigest, + Build: BuildInfo{ + Version: nonEmpty(opts.Version, "dev"), + Commit: nonEmpty(opts.Commit, "unknown"), + Date: nonEmpty(opts.Date, "unknown"), + }, + } + manifest.Files, err = bundleFiles(stage, &inventory, &plan) + if err != nil { + return nil, err + } + if err := WriteJSON(filepath.Join(stage, BundleManifestRel), manifest); err != nil { + return nil, err + } + if err := validateSchemaFile("openexit.migration-bundle.schema.json", filepath.Join(stage, BundleManifestRel)); err != nil { + return nil, fmt.Errorf("bundle manifest schema: %w", err) + } + if err := writeBundleChecksums(stage); err != nil { + return nil, err + } + if err := EnsureNoSymlinks(stage); err != nil { + return nil, err + } + if err := installExportDirectory(stage, out, opts.Force); err != nil { + return nil, err + } + return manifest, nil +} + +func safeExportTarget(workDir, out string) (string, error) { + work, err := filepath.Abs(workDir) + if err != nil { + return "", err + } + target, err := filepath.Abs(out) + if err != nil { + return "", err + } + work = filepath.Clean(work) + target = filepath.Clean(target) + volumeRoot := filepath.Clean(filepath.VolumeName(target) + string(filepath.Separator)) + if target == volumeRoot { + return "", fmt.Errorf("refusing to export over filesystem root %s", target) + } + resolvedWork, err := resolvePathForContainment(work) + if err != nil { + return "", fmt.Errorf("resolve OpenExit work directory: %w", err) + } + resolvedTarget, err := resolvePathForContainment(target) + if err != nil { + return "", fmt.Errorf("resolve export target: %w", err) + } + if pathContains(resolvedWork, resolvedTarget) || pathContains(resolvedTarget, resolvedWork) { + return "", fmt.Errorf("export target and OpenExit work directory must not contain one another") + } + return target, nil +} + +func resolvePathForContainment(path string) (string, error) { + path = filepath.Clean(path) + current := path + var suffix []string + for { + if _, err := os.Lstat(current); err == nil { + resolved, err := filepath.EvalSymlinks(current) + if err != nil { + return "", err + } + for index := len(suffix) - 1; index >= 0; index-- { + resolved = filepath.Join(resolved, suffix[index]) + } + return filepath.Clean(resolved), nil + } else if !os.IsNotExist(err) { + return "", err + } + parent := filepath.Dir(current) + if parent == current { + return "", fmt.Errorf("no existing parent for %s", path) + } + suffix = append(suffix, filepath.Base(current)) + current = parent + } +} + +func pathContains(parent, child string) bool { + rel, err := filepath.Rel(parent, child) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +func copyExportPath(source, target string) error { + info, err := os.Lstat(source) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("symlink is not allowed: %s", source) + } + if !info.IsDir() { + return copyExportFile(source, target) + } + return filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("symlink is not allowed: %s", path) + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + destination := filepath.Join(target, rel) + if entry.IsDir() { + return os.MkdirAll(destination, 0o755) + } + return copyExportFile(path, destination) + }) +} + +func copyExportFile(source, target string) error { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + in, err := os.Open(source) + if err != nil { + return err + } + defer func() { _ = in.Close() }() + out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return err + } + return out.Close() +} + +func bundleFiles(stage string, inventory *Inventory, plan *MigrationPlan) ([]BundleFile, error) { + refs := bundleSourceRefs(inventory, plan) + var files []BundleFile + err := filepath.WalkDir(stage, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + rel, err := filepath.Rel(stage, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if err := SafeRelativePath(rel); err != nil { + return fmt.Errorf("bundle file %s: %w", rel, err) + } + digest, size, err := DigestFile(path) + if err != nil { + return err + } + files = append(files, BundleFile{Path: rel, Size: size, SHA256: digest, SourceRefs: refs[rel]}) + return nil + }) + if err != nil { + return nil, err + } + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + return files, nil +} + +func bundleSourceRefs(inventory *Inventory, plan *MigrationPlan) map[string][]string { + refs := map[string][]string{} + for _, resource := range inventory.Resources { + refs[resource.Evidence.Path] = append(refs[resource.Evidence.Path], resource.Ref) + } + for _, conversion := range plan.Resources { + for _, output := range conversion.Outputs { + refs[output.Path] = append(refs[output.Path], conversion.SourceRef) + } + } + configurationRefs := configSourceRefs(inventory.Resources) + for _, rel := range []string{"generated/alloy/config.alloy", "generated/opentelemetry/collector.yaml"} { + refs[rel] = append(refs[rel], configurationRefs...) + } + for rel, values := range refs { + refs[rel] = SortedUnique(values) + } + return refs +} + +func writeBundleChecksums(stage string) error { + var lines []string + err := filepath.WalkDir(stage, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + rel, err := filepath.Rel(stage, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if rel == BundleChecksumsRel { + return nil + } + digest, _, err := DigestFile(path) + if err != nil { + return err + } + lines = append(lines, digest+" "+rel) + return nil + }) + if err != nil { + return err + } + sort.Strings(lines) + return WriteText(filepath.Join(stage, BundleChecksumsRel), strings.Join(lines, "\n")+"\n") +} + +func installExportDirectory(stage, target string, force bool) error { + if _, err := os.Lstat(target); os.IsNotExist(err) { + return os.Rename(stage, target) + } else if err != nil { + return err + } + if !force { + return fmt.Errorf("export target already exists: %s", target) + } + backup := target + ".previous-" + time.Now().UTC().Format("20060102150405.000000000") + if _, err := os.Lstat(backup); err == nil { + return fmt.Errorf("export backup path already exists: %s", backup) + } else if !os.IsNotExist(err) { + return err + } + if err := os.Rename(target, backup); err != nil { + return err + } + if err := os.Rename(stage, target); err != nil { + _ = os.Rename(backup, target) + return err + } + if err := os.RemoveAll(backup); err != nil { + return fmt.Errorf("export installed, but previous target cleanup failed: %w", err) + } + return nil +} + +func failedValidationChecks(report *ValidationReport) string { + var failed []string + for _, check := range report.Checks { + if check.Status == "failed" { + failed = append(failed, check.Name) + } + } + if len(failed) == 0 { + return "unknown validation failure" + } + return strings.Join(failed, ", ") +} + +func validateSavedValidation(saved, current *ValidationReport) error { + want, err := CanonicalDigest(current) + if err != nil { + return err + } + got, err := CanonicalDigest(saved) + if err != nil { + return err + } + if want != got { + return fmt.Errorf("saved validation report does not match current deterministic validation") + } + return nil +} + +func validateReadiness(inventory *Inventory, plan *MigrationPlan, validation *ValidationReport) error { + expected := Score(inventory, plan.Resources, validation) + want, err := CanonicalDigest(expected) + if err != nil { + return err + } + got, err := CanonicalDigest(plan.Readiness) + if err != nil { + return err + } + if want != got { + return fmt.Errorf("plan readiness does not match the current inventory, conversions, and validation") + } + return nil +} + +func nonEmpty(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} diff --git a/internal/datadogplan/files.go b/internal/datadogplan/files.go new file mode 100644 index 0000000..17bd5f2 --- /dev/null +++ b/internal/datadogplan/files.go @@ -0,0 +1,146 @@ +package datadogplan + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +func WriteJSON(path string, value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".openexit-json-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + if err := tmp.Chmod(0o644); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + +func ReadJSON(path string, value any) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.UseNumber() + if err := dec.Decode(value); err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + return nil +} + +func DigestBytes(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func DigestFile(path string) (string, int64, error) { + f, err := os.Open(path) + if err != nil { + return "", 0, err + } + defer func() { _ = f.Close() }() + h := sha256.New() + size, err := io.Copy(h, f) + if err != nil { + return "", 0, err + } + return hex.EncodeToString(h.Sum(nil)), size, nil +} + +func CanonicalDigest(value any) (string, error) { + data, err := json.Marshal(value) + if err != nil { + return "", err + } + return DigestBytes(data), nil +} + +func SafeRelativePath(rel string) error { + if rel == "" || filepath.IsAbs(rel) || strings.HasPrefix(rel, `\`) { + return errors.New("path must be relative") + } + clean := filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))) + if clean == "." || clean != filepath.ToSlash(rel) || strings.HasPrefix(clean, "../") || clean == ".." { + return errors.New("path escapes the workspace") + } + return nil +} + +func WorkspacePath(workDir, rel string) (string, error) { + if err := SafeRelativePath(rel); err != nil { + return "", err + } + root, err := filepath.Abs(workDir) + if err != nil { + return "", err + } + path := filepath.Join(root, filepath.FromSlash(rel)) + clean := filepath.Clean(path) + if clean == root || !strings.HasPrefix(clean, root+string(filepath.Separator)) { + return "", errors.New("path escapes the workspace") + } + return clean, nil +} + +func EnsureNoSymlinks(root string) error { + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("symlink is not allowed: %s", path) + } + return nil + }) +} + +func SortedUnique(values []string) []string { + seen := map[string]struct{}{} + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + seen[value] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for value := range seen { + out = append(out, value) + } + sort.Strings(out) + return out +} + +func WriteText(path, value string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(value), 0o644) +} diff --git a/internal/datadogplan/generate.go b/internal/datadogplan/generate.go new file mode 100644 index 0000000..bb31b2e --- /dev/null +++ b/internal/datadogplan/generate.go @@ -0,0 +1,524 @@ +package datadogplan + +import ( + "fmt" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +type grafanaDashboard struct { + Title string `json:"title"` + Tags []string `json:"tags"` + Timezone string `json:"timezone"` + SchemaVersion int `json:"schemaVersion"` + Version int `json:"version"` + Editable bool `json:"editable"` + Panels []grafanaPanel `json:"panels"` + Templating map[string]any `json:"templating"` + Annotations map[string]any `json:"annotations"` + OpenExit map[string]any `json:"openexit"` +} + +type grafanaPanel struct { + ID int `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Description string `json:"description"` + GridPos map[string]int `json:"gridPos"` + Datasource any `json:"datasource,omitempty"` + Targets []any `json:"targets,omitempty"` + Options map[string]any `json:"options,omitempty"` +} + +type prometheusRuleFile struct { + Groups []prometheusRuleGroup `yaml:"groups"` +} + +type prometheusRuleGroup struct { + Name string `yaml:"name"` + Rules []prometheusAlertRule `yaml:"rules"` +} + +type prometheusAlertRule struct { + Alert string `yaml:"alert"` + Expr string `yaml:"expr"` + For string `yaml:"for"` + Labels map[string]string `yaml:"labels"` + Annotations map[string]string `yaml:"annotations"` +} + +func generateConversions(stage string, inv *Inventory) ([]Conversion, error) { + configSources := configSourceRefs(inv.Resources) + if err := generateTelemetryConfigs(stage, configSources); err != nil { + return nil, err + } + conversions := make([]Conversion, 0, len(inv.Resources)) + for _, resource := range inv.Resources { + conversion := baseConversion(resource) + var err error + switch resource.Kind { + case "dashboard": + conversion, err = generateDashboard(stage, resource) + case "monitor": + conversion, err = generateMonitor(stage, resource) + case "synthetic_test", "synthetic_variable", "synthetic_location": + conversion.Status = StatusUnsupported + conversion.ReasonCodes = []string{"target.synthetic-capability-out-of-scope"} + conversion.Summary = "Synthetic Monitoring is outside the Grafana/Prometheus/OpenTelemetry v0.1 target." + case "integration", "aws_integration", "azure_integration", "gcp_integration", "gcp_legacy_integration", "log_pipeline", "log_pipeline_order", "log_index", "log_archive", "log_metric", "apm_retention_filter", "span_metric", "service_definition": + conversion.Status = StatusManual + conversion.ReasonCodes = []string{"telemetry.topology-review"} + conversion.Summary = "Detected configuration is linked to candidate Alloy and OpenTelemetry pipelines but requires topology-specific reconstruction." + conversion.Outputs = []OutputRef{ + {Path: "generated/alloy/config.alloy", Kind: "alloy-config"}, + {Path: "generated/opentelemetry/collector.yaml", Kind: "opentelemetry-config"}, + } + conversion.SemanticChanges = []SemanticChange{{ + Code: "telemetry.generic-otlp-pipeline", + Description: "OpenExit emits a credential-free OTLP baseline and preserves the Datadog configuration as evidence instead of guessing receivers, endpoints, or secrets.", + Impact: "manual", + }} + case "metric": + conversion.Status = StatusManual + conversion.ReasonCodes = []string{"metric.identity-review"} + conversion.Summary = "Metric metadata is inventoried, but metric identity, label cardinality, and instrumentation ownership require review." + case "slo", "slo_correction": + conversion.Status = StatusManual + conversion.ReasonCodes = []string{"slo.sli-reconstruction"} + conversion.Summary = "SLO targets are preserved, but the target SLI and burn-rate rules require explicit reconstruction." + case "downtime", "monitor_policy": + conversion.Status = StatusManual + conversion.ReasonCodes = []string{"alerting.policy-review"} + conversion.Summary = "Alerting policy requires manual mapping to Grafana Alerting or Alertmanager semantics." + case "dashboard_list", "powerpack", "notebook": + conversion.Status = StatusManual + conversion.ReasonCodes = []string{"content.organization-review"} + conversion.Summary = "Content and organization metadata is preserved for manual reconstruction." + default: + conversion.Status = StatusUnsupported + conversion.ReasonCodes = []string{"resource.converter-unavailable"} + conversion.Summary = "No deterministic converter exists for this resource kind." + } + if err != nil { + return nil, err + } + conversion.ReasonCodes = SortedUnique(conversion.ReasonCodes) + conversion.Outputs = sortOutputs(conversion.Outputs) + conversions = append(conversions, conversion) + } + sort.Slice(conversions, func(i, j int) bool { return conversions[i].SourceRef < conversions[j].SourceRef }) + return conversions, nil +} + +func baseConversion(resource Resource) Conversion { + return Conversion{ + SourceRef: resource.Ref, + SourceKind: resource.Kind, + SourceName: resource.Name, + SourceURL: resource.SourceURL, + EvidencePath: resource.Evidence.Path, + Status: StatusManual, + ReasonCodes: []string{}, + Summary: "Manual review required.", + Outputs: []OutputRef{}, + } +} + +func generateDashboard(stage string, resource Resource) (Conversion, error) { + conversion := baseConversion(resource) + widgets := dashboardWidgets(resource.Spec) + panels := make([]grafanaPanel, 0, len(widgets)*2) + components := make([]Component, 0, len(widgets)) + appendPanel := func(panel grafanaPanel) { + index := len(panels) + panel.ID = index + 1 + panel.GridPos = map[string]int{"h": 8, "w": 12, "x": (index % 2) * 12, "y": (index / 2) * 8} + panels = append(panels, panel) + } + manual := false + approximate := false + for index, widget := range widgets { + kind := strings.ToLower(firstString(widget, "type", "kind")) + if kind == "" { + kind = "unknown" + } + title := firstString(widget, "title", "name") + if title == "" { + title = fmt.Sprintf("Datadog widget %d", index+1) + } + entries := dashboardQueryEntries(widget) + if len(entries) == 0 { + component := Component{ID: fmt.Sprintf("widget-%d", index+1), Kind: kind} + if content := firstString(widget, "text", "content", "note"); isTextWidget(kind) && content != "" { + component.Status = StatusExact + component.ReasonCodes = []string{"dashboard.text-preserved"} + appendPanel(grafanaPanel{Type: "text", Title: title, Description: "OpenExit candidate derived from " + resource.Ref + ".", Options: map[string]any{"mode": "markdown", "content": content}}) + } else { + component.Status = StatusManual + component.ReasonCodes = []string{"dashboard.widget-unsupported"} + component.Review = "Widget has no deterministic Grafana representation." + manual = true + appendPanel(grafanaPanel{Type: "text", Title: title, Description: "OpenExit candidate derived from " + resource.Ref + ".", Options: map[string]any{"mode": "markdown", "content": "### Manual widget reconstruction required\n\nDatadog widget type: `" + kind + "`"}}) + } + components = append(components, component) + continue + } + + var targets []any + var manualReview strings.Builder + for queryIndex, entry := range entries { + component := Component{ID: fmt.Sprintf("widget-%d-query-%d", index+1, queryIndex+1), Kind: kind + "/" + entry.Field, SourceQuery: entry.Value} + result := convertDashboardMetricQuery(entry.Value) + if entry.Field == "formula" { + result = queryConversion{ReasonCode: "query.formula", Review: "Datadog formulas require manual reconstruction after their named queries are mapped."} + } + if result.OK { + component.Status = StatusApproximate + component.ReasonCodes = []string{result.ReasonCode} + component.TargetQuery = result.Expr + component.Review = result.Review + approximate = true + targets = append(targets, map[string]any{ + "refId": grafanaRefID(len(targets)), + "expr": result.Expr, + "legendFormat": "{{instance}}", + "openexitSourcePath": entry.Path, + "openexitSourceQuery": entry.Value, + "openexitStatus": StatusApproximate, + "openexitReviewRequired": true, + }) + } else { + component.Status = StatusManual + component.ReasonCodes = []string{result.ReasonCode} + component.Review = result.Review + manual = true + manualReview.WriteString("#### `" + entry.Path + "`\n\n") + manualReview.WriteString(indentMarkdownCode(entry.Value) + "\n\n" + result.Review + "\n\n") + } + components = append(components, component) + } + if len(targets) > 0 { + appendPanel(grafanaPanel{ + Type: grafanaPanelType(kind), Title: title, Description: "OpenExit candidate derived from " + resource.Ref + ". Review before import.", + Datasource: map[string]string{"type": "prometheus", "uid": "${DS_PROMETHEUS}"}, Targets: targets, + }) + } + if manualReview.Len() > 0 { + appendPanel(grafanaPanel{ + Type: "text", Title: title + " — manual conversion", Description: "Unconverted source expressions from " + resource.Ref + ".", + Options: map[string]any{"mode": "markdown", "content": "### Manual query conversion required\n\n" + manualReview.String()}, + }) + } + } + if len(panels) == 0 { + manual = true + components = append(components, Component{ID: "dashboard", Kind: "dashboard", Status: StatusManual, ReasonCodes: []string{"dashboard.empty-definition"}, Review: "Dashboard has no convertible widgets or queries."}) + appendPanel(grafanaPanel{Type: "text", Title: "Manual dashboard review", Description: "OpenExit candidate derived from " + resource.Ref + ".", Options: map[string]any{"mode": "markdown", "content": "No convertible Datadog widgets were found. Review the source evidence."}}) + } + // Even a text-only dashboard is not lossless at the resource level: Grafana + // receives a normalized grid, candidate metadata, and a new data-source + // binding. Exact is reserved for component-level content we can preserve. + status := StatusApproximate + reasons := []string{"dashboard.layout-approximation", "dashboard.metadata-preserved"} + changes := []SemanticChange{{ + Code: "dashboard.layout-normalized", + Description: "Datadog widget placement is normalized to a review grid and Grafana candidate metadata is added.", + Impact: "review", + }} + if approximate { + reasons = append(reasons, "dashboard.promql-approximation") + changes = append(changes, SemanticChange{Code: "dashboard.datasource-change", Description: "Datadog metric queries were rewritten as PromQL candidates using a placeholder Prometheus data source UID.", Impact: "review"}) + } + if manual { + status = StatusManual + reasons = append(reasons, "dashboard.manual-components") + changes = append(changes, SemanticChange{Code: "dashboard.manual-panels", Description: "Unconverted widgets are rendered as visible text review panels rather than fake data queries.", Impact: "manual"}) + } + dashboard := grafanaDashboard{ + Title: resource.Name, Tags: SortedUnique(append([]string{"openexit", "candidate", "source:datadog"}, resource.Tags...)), + Timezone: "browser", SchemaVersion: 39, Version: 1, Editable: true, Panels: panels, + Templating: map[string]any{"list": []any{map[string]any{ + "name": "DS_PROMETHEUS", "label": "Prometheus", "type": "datasource", "query": "prometheus", "refresh": 1, + }}}, Annotations: map[string]any{"list": []any{}}, + OpenExit: map[string]any{"sourceRef": resource.Ref, "evidencePath": resource.Evidence.Path, "status": status, "productionReady": false, "rulesetVersion": RulesetVersion}, + } + name := safeFilename(resource.ID, resource.Ref) + ".json" + rel := "generated/grafana/dashboards/" + name + if err := WriteJSON(filepath.Join(stage, filepath.FromSlash(rel)), dashboard); err != nil { + return Conversion{}, err + } + conversion.Status = status + conversion.ReasonCodes = reasons + conversion.Summary = "Generated a reviewable Grafana dashboard candidate with explicit per-widget conversion results." + conversion.SemanticChanges = changes + conversion.Components = components + conversion.Outputs = []OutputRef{{Path: rel, Kind: "grafana-dashboard"}} + return conversion, nil +} + +type dashboardQueryEntry struct { + Path string + Field string + Value string +} + +func dashboardQueryEntries(value any) []dashboardQueryEntry { + var entries []dashboardQueryEntry + collectDashboardQueryEntries(value, "widget", &entries) + return entries +} + +func collectDashboardQueryEntries(value any, path string, entries *[]dashboardQueryEntry) { + switch typed := value.(type) { + case map[string]any: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + childPath := path + "." + key + if key == "query" || key == "q" || key == "formula" { + if query := stringValue(typed[key]); query != "" { + *entries = append(*entries, dashboardQueryEntry{Path: childPath, Field: key, Value: query}) + continue + } + } + collectDashboardQueryEntries(typed[key], childPath, entries) + } + case []any: + for index, child := range typed { + collectDashboardQueryEntries(child, fmt.Sprintf("%s[%d]", path, index), entries) + } + } +} + +func grafanaRefID(index int) string { + if index >= 0 && index < 26 { + return string(rune('A' + index)) + } + return fmt.Sprintf("Q%d", index+1) +} + +func indentMarkdownCode(value string) string { + return " " + strings.ReplaceAll(value, "\n", "\n ") +} + +func generateMonitor(stage string, resource Resource) (Conversion, error) { + conversion := baseConversion(resource) + query := specString(resource.Spec, "query") + if query == "" { + query = specString(resource.Spec, "attributes", "query") + } + result := convertMonitorQuery(query) + component := Component{ID: "monitor-query", Kind: "prometheus-alert", SourceQuery: query, ReasonCodes: []string{result.ReasonCode}, Review: result.Review} + if !result.OK { + component.Status = StatusManual + conversion.Status = StatusManual + conversion.ReasonCodes = []string{result.ReasonCode} + conversion.Summary = "No executable alert rule was emitted because the Datadog monitor is outside the safe deterministic subset." + conversion.Components = []Component{component} + return conversion, nil + } + component.Status = StatusApproximate + component.TargetQuery = result.Expr + alertName := promAlertName(resource.Name, resource.Ref) + rules := prometheusRuleFile{Groups: []prometheusRuleGroup{{ + Name: "openexit-datadog-" + DigestBytes([]byte(resource.Ref))[:8], + Rules: []prometheusAlertRule{{ + Alert: alertName, + Expr: result.Expr, + For: "0m", + Labels: map[string]string{ + "severity": "warning", "openexit_candidate": "true", "production_ready": "false", + "source": "datadog", "source_ref": resource.Ref, "conversion": StatusApproximate, + }, + Annotations: map[string]string{ + "summary": resource.Name, "openexit_source_query": query, "openexit_review": result.Review, + }, + }}, + }}} + data, err := yaml.Marshal(rules) + if err != nil { + return Conversion{}, err + } + rel := "generated/prometheus/rules/" + safeFilename(resource.ID, resource.Ref) + ".yaml" + if err := WriteText(filepath.Join(stage, filepath.FromSlash(rel)), string(data)); err != nil { + return Conversion{}, err + } + conversion.Status = StatusApproximate + conversion.ReasonCodes = []string{result.ReasonCode} + conversion.Summary = "Generated a Prometheus alert-rule candidate for a recognized static Datadog threshold." + conversion.Components = []Component{component} + conversion.SemanticChanges = []SemanticChange{{Code: "monitor.evaluation-semantics", Description: result.Review, Impact: "review"}} + conversion.Outputs = []OutputRef{{Path: rel, Kind: "prometheus-alert-rule"}} + return conversion, nil +} + +func generateTelemetryConfigs(stage string, sourceRefs []string) error { + commentLines := []string{"// Source resources linked to this candidate:"} + yamlComments := []string{"# Source resources linked to this candidate:"} + if len(sourceRefs) == 0 { + commentLines = append(commentLines, "// - target baseline (no Datadog telemetry configuration resources were present)") + yamlComments = append(yamlComments, "# - target baseline (no Datadog telemetry configuration resources were present)") + } else { + for _, ref := range sourceRefs { + commentLines = append(commentLines, "// - "+ref) + yamlComments = append(yamlComments, "# - "+ref) + } + } + alloy := strings.Join(commentLines, "\n") + ` +// OpenExit candidate only. Review topology, endpoints, TLS, authentication, sizing, and processors. +otelcol.receiver.otlp "openexit" { + grpc { } + http { } + output { + metrics = [otelcol.processor.batch.openexit.input] + logs = [otelcol.processor.batch.openexit.input] + traces = [otelcol.processor.batch.openexit.input] + } +} + +otelcol.processor.batch "openexit" { + output { + metrics = [otelcol.exporter.otlphttp.lgtm.input] + logs = [otelcol.exporter.otlphttp.lgtm.input] + traces = [otelcol.exporter.otlphttp.lgtm.input] + } +} + +otelcol.exporter.otlphttp "lgtm" { + client { + endpoint = sys.env("OPENEXIT_OTLP_ENDPOINT") + } +} +` + otel := strings.Join(yamlComments, "\n") + ` +# OpenExit candidate only. Review topology, endpoints, TLS, authentication, sizing, and processors. +receivers: + otlp: + protocols: + grpc: {} + http: {} +processors: + memory_limiter: + check_interval: 1s + limit_mib: 512 + batch: {} +exporters: + otlphttp/lgtm: + endpoint: "${env:OPENEXIT_OTLP_ENDPOINT}" +service: + pipelines: + metrics: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [otlphttp/lgtm] + logs: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [otlphttp/lgtm] + traces: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [otlphttp/lgtm] +` + if err := WriteText(filepath.Join(stage, "generated", "alloy", "config.alloy"), alloy); err != nil { + return err + } + return WriteText(filepath.Join(stage, "generated", "opentelemetry", "collector.yaml"), otel) +} + +func dashboardWidgets(spec map[string]any) []map[string]any { + var widgets []map[string]any + if raw, ok := spec["widgets"].([]any); ok { + for _, value := range raw { + widget, ok := value.(map[string]any) + if !ok { + continue + } + if definition, ok := widget["definition"].(map[string]any); ok { + copy := make(map[string]any, len(definition)+1) + for key, value := range definition { + copy[key] = value + } + if title := stringValue(widget["title"]); title != "" && stringValue(copy["title"]) == "" { + copy["title"] = title + } + widget = copy + } + widgets = append(widgets, widget) + } + } + if queries, ok := spec["queries"].([]any); ok { + for index, value := range queries { + if query := stringValue(value); query != "" { + widgets = append(widgets, map[string]any{"type": "timeseries", "title": fmt.Sprintf("Dashboard query %d", index+1), "query": query}) + } + } + } + return widgets +} + +func firstString(value map[string]any, keys ...string) string { + for _, key := range keys { + if text := stringValue(value[key]); text != "" { + return text + } + } + return "" +} + +func specString(spec map[string]any, keys ...string) string { + return stringValue(nestedValue(spec, keys...)) +} + +func isTextWidget(kind string) bool { + switch kind { + case "note", "free_text", "text", "markdown": + return true + default: + return false + } +} + +func grafanaPanelType(kind string) string { + switch kind { + case "query_value", "queryvalue", "value": + return "stat" + case "toplist", "table": + return "table" + case "heatmap": + return "heatmap" + default: + return "timeseries" + } +} + +func configSourceRefs(resources []Resource) []string { + allowed := map[string]bool{ + "integration": true, "aws_integration": true, "azure_integration": true, "gcp_integration": true, "gcp_legacy_integration": true, + "log_pipeline": true, "log_pipeline_order": true, "log_index": true, "log_archive": true, "log_metric": true, + "apm_retention_filter": true, "span_metric": true, "service_definition": true, + } + var refs []string + for _, resource := range resources { + if allowed[resource.Kind] { + refs = append(refs, resource.Ref) + } + } + return SortedUnique(refs) +} + +func sortOutputs(outputs []OutputRef) []OutputRef { + sort.Slice(outputs, func(i, j int) bool { + if outputs[i].Path == outputs[j].Path { + return outputs[i].Kind < outputs[j].Kind + } + return outputs[i].Path < outputs[j].Path + }) + return outputs +} diff --git a/internal/datadogplan/model.go b/internal/datadogplan/model.go new file mode 100644 index 0000000..82e7d31 --- /dev/null +++ b/internal/datadogplan/model.go @@ -0,0 +1,212 @@ +package datadogplan + +import "time" + +const ( + APIVersion = "openexit.dev/v1alpha1" + InventoryKind = "DatadogInventory" + PlanKind = "DatadogMigrationPlan" + ValidationKind = "DatadogValidation" + BundleKind = "MigrationBundle" + CatalogVersion = "datadog-observability/v1" + RulesetVersion = "datadog-grafana-lgtm/v1" + DefaultTarget = "grafana-lgtm" + DefaultWorkDir = ".openexit" + InventoryRel = "inventory/datadog.inventory.json" + PlanRel = "plan/openexit.plan.json" + ValidationRel = "validation/validation.json" + ReportRel = "index.html" + BundleReadmeRel = "README.md" +) + +type Inventory struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata InventoryMetadata `json:"metadata"` + Catalog Catalog `json:"catalog"` + Resources []Resource `json:"resources"` +} + +type InventoryMetadata struct { + Source string `json:"source"` + Site string `json:"site"` + CollectedAt time.Time `json:"collectedAt"` + CollectorVersion string `json:"collectorVersion"` + SnapshotDigest string `json:"snapshotDigest"` +} + +type Catalog struct { + Version string `json:"version"` + Complete bool `json:"complete"` + Coverage []CatalogFamily `json:"coverage"` +} + +type CatalogFamily struct { + Family string `json:"family"` + Status string `json:"status"` + Count int `json:"count"` + Endpoints []CatalogEndpoint `json:"endpoints"` + Message string `json:"message,omitempty"` +} + +type CatalogEndpoint struct { + Path string `json:"path"` + Status string `json:"status"` + Count int `json:"count"` + Message string `json:"message,omitempty"` +} + +const ( + CoverageComplete = "complete" + CoverageEmpty = "empty" + CoverageNotAvailable = "not_available" + CoveragePartial = "partial" + CoveragePermissionDenied = "permission_denied" + CoverageError = "error" +) + +type Resource struct { + Ref string `json:"ref"` + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + SourceURL string `json:"sourceUrl,omitempty"` + Tags []string `json:"tags,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` + Evidence Evidence `json:"evidence"` + Spec map[string]any `json:"spec"` +} + +type Evidence struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` +} + +type MigrationPlan struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata PlanMetadata `json:"metadata"` + Target string `json:"target"` + Summary PlanSummary `json:"summary"` + Readiness Readiness `json:"readiness"` + Resources []Conversion `json:"resources"` +} + +type PlanMetadata struct { + PlanID string `json:"planId"` + InventoryDigest string `json:"inventoryDigest"` + GeneratedAt time.Time `json:"generatedAt"` + RulesetVersion string `json:"rulesetVersion"` +} + +type PlanSummary struct { + Total int `json:"total"` + Exact int `json:"exact"` + Approximate int `json:"approximate"` + Manual int `json:"manual"` + Unsupported int `json:"unsupported"` + OutputFiles int `json:"outputFiles"` +} + +const ( + StatusExact = "exact" + StatusApproximate = "approximate" + StatusManual = "manual" + StatusUnsupported = "unsupported" +) + +type Conversion struct { + SourceRef string `json:"sourceRef"` + SourceKind string `json:"sourceKind"` + SourceName string `json:"sourceName"` + SourceURL string `json:"sourceUrl,omitempty"` + EvidencePath string `json:"evidencePath"` + Status string `json:"status"` + ReasonCodes []string `json:"reasonCodes"` + Summary string `json:"summary"` + SemanticChanges []SemanticChange `json:"semanticChanges,omitempty"` + Components []Component `json:"components,omitempty"` + Outputs []OutputRef `json:"outputs"` +} + +type Component struct { + ID string `json:"id"` + Kind string `json:"kind"` + Status string `json:"status"` + ReasonCodes []string `json:"reasonCodes,omitempty"` + SourceQuery string `json:"sourceQuery,omitempty"` + TargetQuery string `json:"targetQuery,omitempty"` + Review string `json:"review,omitempty"` +} + +type SemanticChange struct { + Code string `json:"code"` + Description string `json:"description"` + Impact string `json:"impact"` +} + +type OutputRef struct { + Path string `json:"path"` + Kind string `json:"kind"` +} + +type Readiness struct { + Score int `json:"score"` + Level string `json:"level"` + Formula string `json:"formula"` + Collection ReadinessFactor `json:"collection"` + Translation ReadinessFactor `json:"translation"` + Validation ReadinessFactor `json:"validation"` + Deductions []ScoreDeduction `json:"deductions"` + Interpretation string `json:"interpretation"` +} + +type ReadinessFactor struct { + Value float64 `json:"value"` + Numerator int `json:"numerator"` + Denominator int `json:"denominator"` + Description string `json:"description"` +} + +type ScoreDeduction struct { + Code string `json:"code"` + Description string `json:"description"` + Points int `json:"points"` +} + +type ValidationReport struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Status string `json:"status"` + GeneratedAt time.Time `json:"generatedAt"` + Checks []ValidationCheck `json:"checks"` +} + +type ValidationCheck struct { + Name string `json:"name"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + Critical bool `json:"critical"` +} + +type BundleManifest struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + PlanID string `json:"planId"` + InventoryDigest string `json:"inventoryDigest"` + Build BuildInfo `json:"build"` + Files []BundleFile `json:"files"` +} + +type BuildInfo struct { + Version string `json:"version"` + Commit string `json:"commit"` + Date string `json:"date"` +} + +type BundleFile struct { + Path string `json:"path"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + SourceRefs []string `json:"sourceRefs,omitempty"` +} diff --git a/internal/datadogplan/plan.go b/internal/datadogplan/plan.go new file mode 100644 index 0000000..7067e36 --- /dev/null +++ b/internal/datadogplan/plan.go @@ -0,0 +1,205 @@ +package datadogplan + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +type PlanOptions struct { + WorkDir string + Target string + AllowPartial bool +} + +func Plan(opts PlanOptions) (*MigrationPlan, *ValidationReport, error) { + if opts.WorkDir == "" { + opts.WorkDir = DefaultWorkDir + } + if opts.Target == "" { + opts.Target = DefaultTarget + } + if opts.Target != DefaultTarget { + return nil, nil, fmt.Errorf("unsupported target %q; v0.1 supports only %s", opts.Target, DefaultTarget) + } + var inv Inventory + if err := ReadJSON(filepath.Join(opts.WorkDir, filepath.FromSlash(InventoryRel)), &inv); err != nil { + return nil, nil, fmt.Errorf("read Datadog inventory: %w", err) + } + if err := validateInventoryStructure(&inv); err != nil { + return nil, nil, fmt.Errorf("datadog inventory structure: %w", err) + } + if err := validateInventoryDigest(&inv); err != nil { + return nil, nil, err + } + if !inv.Catalog.Complete && !opts.AllowPartial { + return nil, nil, &IncompleteScanError{Families: incompleteFamilies(inv.Catalog)} + } + stage, err := os.MkdirTemp(opts.WorkDir, ".plan-*") + if err != nil { + return nil, nil, err + } + defer func() { _ = os.RemoveAll(stage) }() + + conversions, err := generateConversions(stage, &inv) + if err != nil { + return nil, nil, err + } + planID, err := planDigest(inv.Metadata.SnapshotDigest, opts.Target) + if err != nil { + return nil, nil, err + } + plan := &MigrationPlan{ + APIVersion: APIVersion, + Kind: PlanKind, + Metadata: PlanMetadata{ + PlanID: planID, + InventoryDigest: inv.Metadata.SnapshotDigest, + GeneratedAt: inv.Metadata.CollectedAt, + RulesetVersion: RulesetVersion, + }, + Target: opts.Target, + Summary: summarizeConversions(conversions), + Resources: conversions, + } + plan.Readiness = Score(&inv, conversions, nil) + if err := WriteJSON(filepath.Join(stage, filepath.FromSlash(PlanRel)), plan); err != nil { + return nil, nil, err + } + preliminary := validateWorkspace(stage, opts.WorkDir, &inv, plan, opts.AllowPartial) + plan.Readiness = Score(&inv, conversions, preliminary) + if err := WriteJSON(filepath.Join(stage, filepath.FromSlash(PlanRel)), plan); err != nil { + return nil, nil, err + } + if err := WriteJSON(filepath.Join(stage, filepath.FromSlash(ValidationRel)), preliminary); err != nil { + return nil, nil, err + } + if err := renderReport(stage, &inv, plan, preliminary); err != nil { + return nil, nil, err + } + finalReport := validateWorkspace(stage, opts.WorkDir, &inv, plan, opts.AllowPartial) + plan.Readiness = Score(&inv, conversions, finalReport) + if err := WriteJSON(filepath.Join(stage, filepath.FromSlash(PlanRel)), plan); err != nil { + return nil, nil, err + } + if err := WriteJSON(filepath.Join(stage, filepath.FromSlash(ValidationRel)), finalReport); err != nil { + return nil, nil, err + } + if err := renderReport(stage, &inv, plan, finalReport); err != nil { + return nil, nil, err + } + if err := validateSchemaFile("openexit.datadog-validation.schema.json", filepath.Join(stage, filepath.FromSlash(ValidationRel))); err != nil { + return nil, nil, fmt.Errorf("validation report schema: %w", err) + } + if err := validateHTMLLinks(stage, opts.WorkDir); err != nil { + return nil, nil, err + } + if err := replacePlanState(opts.WorkDir, stage); err != nil { + return nil, nil, err + } + if finalReport.Status == "failed" { + return plan, finalReport, fmt.Errorf("datadog migration plan validation failed") + } + return plan, finalReport, nil +} + +func planDigest(inventoryDigest, target string) (string, error) { + return CanonicalDigest(struct { + InventoryDigest string `json:"inventoryDigest"` + Target string `json:"target"` + Ruleset string `json:"ruleset"` + }{InventoryDigest: inventoryDigest, Target: target, Ruleset: RulesetVersion}) +} + +func summarizeConversions(conversions []Conversion) PlanSummary { + summary := PlanSummary{Total: len(conversions)} + outputs := map[string]struct{}{} + for _, conversion := range conversions { + switch conversion.Status { + case StatusExact: + summary.Exact++ + case StatusApproximate: + summary.Approximate++ + case StatusManual: + summary.Manual++ + case StatusUnsupported: + summary.Unsupported++ + } + for _, output := range conversion.Outputs { + outputs[output.Path] = struct{}{} + } + } + // The two target-baseline telemetry files are always generated. + outputs["generated/alloy/config.alloy"] = struct{}{} + outputs["generated/opentelemetry/collector.yaml"] = struct{}{} + summary.OutputFiles = len(outputs) + return summary +} + +type stateMove struct { + source string + target string + backup string +} + +func replacePlanState(workDir, stage string) error { + targets := []string{"generated", "plan", "validation", ReportRel, BundleReadmeRel} + moves := make([]stateMove, 0, len(targets)) + stamp := time.Now().UTC().Format("20060102150405.000000000") + for _, name := range targets { + source := filepath.Join(stage, filepath.FromSlash(name)) + if _, err := os.Stat(source); err != nil { + return fmt.Errorf("planned output missing %s: %w", name, err) + } + target := filepath.Join(workDir, filepath.FromSlash(name)) + moves = append(moves, stateMove{source: source, target: target, backup: target + ".previous-" + stamp}) + } + if err := backupStateMoves(moves); err != nil { + return err + } + for index := range moves { + if err := os.Rename(moves[index].source, moves[index].target); err != nil { + for rollback := 0; rollback < index; rollback++ { + _ = os.RemoveAll(moves[rollback].target) + } + restoreMoves(moves) + return err + } + } + for _, item := range moves { + _ = os.RemoveAll(item.backup) + } + return nil +} + +func backupStateMoves(moves []stateMove) error { + for _, item := range moves { + if _, err := os.Lstat(item.backup); err == nil { + return fmt.Errorf("state backup path already exists: %s", item.backup) + } else if !os.IsNotExist(err) { + return err + } + } + for index, item := range moves { + if _, err := os.Lstat(item.target); err == nil { + if err := os.Rename(item.target, item.backup); err != nil { + restoreMoves(moves[:index]) + return err + } + } else if !os.IsNotExist(err) { + restoreMoves(moves[:index]) + return err + } + } + return nil +} + +func restoreMoves(moves []stateMove) { + for _, item := range moves { + if _, err := os.Lstat(item.backup); err == nil { + _ = os.RemoveAll(item.target) + _ = os.Rename(item.backup, item.target) + } + } +} diff --git a/internal/datadogplan/query.go b/internal/datadogplan/query.go new file mode 100644 index 0000000..cc88f13 --- /dev/null +++ b/internal/datadogplan/query.go @@ -0,0 +1,199 @@ +package datadogplan + +import ( + "fmt" + "regexp" + "strings" +) + +type queryConversion struct { + Expr string + Window string + Operator string + Threshold string + ReasonCode string + Review string + OK bool +} + +var ( + simpleMetricPattern = regexp.MustCompile(`(?i)^\s*(avg|sum|min|max|count):([a-zA-Z_:][a-zA-Z0-9_.:-]*)(\{([^}]*)\})?(?:\s+by\s+\{([^}]*)\})?\s*$`) + simpleThresholdPattern = regexp.MustCompile(`(?i)^\s*(sum|avg|min|max)\(last_([0-9]+)([smhd])\):\s*(sum|avg|min|max|count):([a-zA-Z_:][a-zA-Z0-9_.:-]*)(\{([^}]*)\})?(\.(as_count|as_rate)\(\))?\s*([<>]=?|==|!=)\s*([0-9]+(?:\.[0-9]+)?)\s*$`) + nonIdentifierPattern = regexp.MustCompile(`[^A-Za-z0-9]+`) + promIdentifierPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + datadogTagKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + datadogTagValuePattern = regexp.MustCompile(`^[A-Za-z0-9_.:/-]+$`) +) + +func convertDashboardMetricQuery(query string) queryConversion { + match := simpleMetricPattern.FindStringSubmatch(query) + if len(match) == 0 { + return queryConversion{ReasonCode: "query.datadog-syntax", Review: "Datadog query is outside the deterministic metric subset and requires manual PromQL or LogQL conversion."} + } + agg := strings.ToLower(match[1]) + metric := promMetricName(match[2]) + labels, ok := promLabels(match[4]) + if !ok || metric == "" { + return queryConversion{ReasonCode: "query.label-mapping", Review: "Metric name or tag filters require manual label mapping."} + } + groups, ok := promGroupLabels(match[5]) + if !ok { + return queryConversion{ReasonCode: "query.group-mapping", Review: "Datadog group-by tags require manual label mapping."} + } + expr := agg + "(" + metric + labels + ")" + if len(groups) > 0 { + expr = agg + " by (" + strings.Join(groups, ",") + ") (" + metric + labels + ")" + } + return queryConversion{ + Expr: expr, + ReasonCode: "query.promql-approximation", + Review: "Review metric naming, tag-to-label mapping, aggregation, rollup, and missing-data semantics.", + OK: true, + } +} + +func convertMonitorQuery(query string) queryConversion { + lower := strings.ToLower(query) + for _, unsupported := range []string{"anomal", "outlier", "forecast", "change(", "pct_change", "timeshift(", "default_zero(", "exclude_null(", "composite"} { + if strings.Contains(lower, unsupported) { + return queryConversion{ReasonCode: "monitor.complex-function", Review: "Complex Datadog monitor behavior requires a manual Prometheus alert design."} + } + } + match := simpleThresholdPattern.FindStringSubmatch(query) + if len(match) == 0 { + return queryConversion{ReasonCode: "monitor.query-unsupported", Review: "Monitor query is outside the deterministic static-threshold subset."} + } + outerAgg := strings.ToLower(match[1]) + window := match[2] + strings.ToLower(match[3]) + metricAgg := strings.ToLower(match[4]) + metric := promMetricName(match[5]) + labels, ok := promLabels(match[7]) + if !ok || metric == "" { + return queryConversion{ReasonCode: "monitor.label-mapping", Review: "Metric name or Datadog tag filters require manual Prometheus label mapping."} + } + converter := strings.ToLower(match[9]) + operator := match[10] + threshold := match[11] + rangeExpr := metric + labels + "[" + window + "]" + var vectorExpr string + switch converter { + case "as_count": + vectorExpr = "increase(" + rangeExpr + ")" + case "as_rate": + vectorExpr = "rate(" + rangeExpr + ")" + default: + switch metricAgg { + case "sum", "count": + vectorExpr = "sum_over_time(" + rangeExpr + ")" + case "min": + vectorExpr = "min_over_time(" + rangeExpr + ")" + case "max": + vectorExpr = "max_over_time(" + rangeExpr + ")" + default: + vectorExpr = "avg_over_time(" + rangeExpr + ")" + } + } + return queryConversion{ + Expr: fmt.Sprintf("%s(%s) %s %s", outerAgg, vectorExpr, operator, threshold), + Window: window, + Operator: operator, + Threshold: threshold, + ReasonCode: "monitor.promql-approximation", + Review: "Review metric naming, label mapping, aggregation, evaluation delay, no-data behavior, notification routing, and alert shadowing.", + OK: true, + } +} + +func promMetricName(metric string) string { + var builder strings.Builder + for _, r := range metric { + valid := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' + if valid { + builder.WriteRune(r) + } else { + builder.WriteByte('_') + } + } + result := strings.Trim(builder.String(), "_") + if result == "" { + return "" + } + if result[0] >= '0' && result[0] <= '9' { + result = "datadog_" + result + } + return result +} + +func promLabels(raw string) (string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "*" { + return "", true + } + var labels []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + key, value, ok := strings.Cut(part, ":") + if !ok || !datadogTagKeyPattern.MatchString(key) || !datadogTagValuePattern.MatchString(value) { + return "", false + } + labels = append(labels, fmt.Sprintf(`%s="%s"`, promLabelName(key), escapePromLabelValue(value))) + } + if len(labels) == 0 { + return "", true + } + labels = SortedUnique(labels) + return "{" + strings.Join(labels, ",") + "}", true +} + +func promGroupLabels(raw string) ([]string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, true + } + var labels []string + for _, value := range strings.Split(raw, ",") { + value = strings.TrimSpace(value) + if !datadogTagKeyPattern.MatchString(value) { + return nil, false + } + labels = append(labels, promLabelName(value)) + } + return SortedUnique(labels), true +} + +func promLabelName(value string) string { + value = promMetricName(value) + if value == "" { + return "label" + } + return value +} + +func escapePromLabelValue(value string) string { + value = strings.ReplaceAll(value, `\`, `\\`) + return strings.ReplaceAll(value, `"`, `\"`) +} + +func promAlertName(name, sourceRef string) string { + parts := nonIdentifierPattern.Split(name, -1) + var builder strings.Builder + for _, part := range parts { + if part == "" { + continue + } + builder.WriteString(strings.ToUpper(part[:1])) + if len(part) > 1 { + builder.WriteString(part[1:]) + } + } + if builder.Len() == 0 { + builder.WriteString("DatadogMonitor") + } + if first := builder.String()[0]; first >= '0' && first <= '9' { + return "DatadogMonitor" + builder.String() + "Candidate_" + DigestBytes([]byte(sourceRef))[:8] + } + return builder.String() + "Candidate_" + DigestBytes([]byte(sourceRef))[:8] +} diff --git a/internal/datadogplan/redact.go b/internal/datadogplan/redact.go new file mode 100644 index 0000000..3ce8c00 --- /dev/null +++ b/internal/datadogplan/redact.go @@ -0,0 +1,77 @@ +package datadogplan + +import ( + "encoding/json" + "regexp" + "strings" +) + +var ( + secretKeyPattern = regexp.MustCompile(`(?i)(api.?key|app.?key|token|password|secret|private.?key|access.?key|client.?secret|credential)`) + secretValuePattern = regexp.MustCompile(`(?i)(bearer\s+)[A-Za-z0-9_\-./+=]{12,}|dd[a-z0-9]{30,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(api[_-]?key|app[_-]?key|access[_-]?key|client[_-]?secret|password|token|secret)\s*[:=]\s*[^\s&]{4,}`) +) + +func RedactValue(value any) any { + return redactValue(value, false) +} + +func redactValue(value any, secretContext bool) any { + switch typed := value.(type) { + case map[string]any: + context := secretContext || boolValue(typed["is_secret"]) || boolValue(typed["secure"]) + if kind, _ := typed["type"].(string); strings.Contains(strings.ToLower(kind), "secret") { + context = true + } + out := make(map[string]any, len(typed)) + for key, child := range typed { + redact := secretKeyPattern.MatchString(key) || (context && secretPayloadKey(key)) + if _, metadata := child.(bool); metadata && (strings.EqualFold(key, "is_secret") || strings.EqualFold(key, "secure")) { + redact = false + } + if redact && child != nil { + out[key] = "[REDACTED]" + continue + } + out[key] = redactValue(child, context) + } + return out + case []any: + out := make([]any, len(typed)) + for i, child := range typed { + out[i] = redactValue(child, secretContext) + } + return out + case string: + if secretValuePattern.MatchString(typed) { + return "[REDACTED]" + } + return typed + default: + return value + } +} + +func secretPayloadKey(key string) bool { + key = strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), " ", "_")) + switch key { + case "value", "default", "default_value", "content", "payload": + return true + default: + return false + } +} + +func RedactJSON(data []byte) ([]byte, error) { + var value any + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.UseNumber() + if err := dec.Decode(&value); err != nil { + return nil, err + } + return json.MarshalIndent(RedactValue(value), "", " ") +} + +func boolValue(value any) bool { + result, _ := value.(bool) + return result +} diff --git a/internal/datadogplan/report.go b/internal/datadogplan/report.go new file mode 100644 index 0000000..2628299 --- /dev/null +++ b/internal/datadogplan/report.go @@ -0,0 +1,184 @@ +package datadogplan + +import ( + "fmt" + "html/template" + "os" + "path/filepath" + "strings" +) + +type reportData struct { + Inventory *Inventory + Plan *MigrationPlan + Validation *ValidationReport +} + +func renderReport(stage string, inv *Inventory, plan *MigrationPlan, validation *ValidationReport) error { + functions := template.FuncMap{ + "pct": func(value float64) string { return fmt.Sprintf("%.0f%%", value*100) }, + "statusClass": func(value string) string { return "status-" + strings.ReplaceAll(value, "_", "-") }, + "join": strings.Join, + } + tmpl, err := template.New("report").Funcs(functions).Parse(reportTemplate) + if err != nil { + return err + } + path := filepath.Join(stage, ReportRel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + file, err := os.Create(path) + if err != nil { + return err + } + execErr := tmpl.Execute(file, reportData{Inventory: inv, Plan: plan, Validation: validation}) + closeErr := file.Close() + if execErr != nil { + return execErr + } + if closeErr != nil { + return closeErr + } + return renderBundleReadme(stage, inv, plan, validation) +} + +func renderBundleReadme(stage string, inv *Inventory, plan *MigrationPlan, validation *ValidationReport) error { + validationStatus := "not run" + if validation != nil { + validationStatus = validation.Status + } + text := fmt.Sprintf(`# OpenExit Datadog Migration Plan + +This directory is a deterministic, read-only review bundle for a Datadog to Grafana LGTM, Prometheus, Alloy, and OpenTelemetry migration. + +- Inventory digest: %s +- Plan ID: %s +- Exit readiness: %d/100 (%s) +- Validation: %s +- Resources: %d +- Exact: %d +- Approximate: %d +- Manual: %d +- Unsupported: %d + +Open [index.html](index.html) for the complete migration report. + +Generated target files are candidates only. OpenExit does not deploy them, mutate Datadog, or make a cutover decision. +`, inv.Metadata.SnapshotDigest, plan.Metadata.PlanID, plan.Readiness.Score, plan.Readiness.Level, validationStatus, plan.Summary.Total, plan.Summary.Exact, plan.Summary.Approximate, plan.Summary.Manual, plan.Summary.Unsupported) + return WriteText(filepath.Join(stage, BundleReadmeRel), text) +} + +const reportTemplate = ` + + + + + + OpenExit Datadog Migration Plan + + + +
+
+
Read-only migration plan
+

Datadog → Grafana, Prometheus & OpenTelemetry

+

A deterministic inventory and conversion report. Every candidate is linked to redacted source evidence and every semantic change is made explicit.

+
+ +
+
{{.Plan.Readiness.Score}}
Exit readiness
{{.Plan.Readiness.Level}}
+
{{.Plan.Summary.Total}}
Source resources
+
{{.Plan.Summary.Exact}}
Exact
+
{{.Plan.Summary.Approximate}}
Approximate
+
{{.Plan.Summary.Manual}}
Manual
+
{{.Plan.Summary.Unsupported}}
Unsupported
+
+ +
+ This is not production readiness. {{.Plan.Readiness.Interpretation}} OpenExit performs no Datadog writes, deployment, or automatic cutover. +
+ +

Readiness calculation

+
+
{{pct .Plan.Readiness.Collection.Value}}
Collection · {{.Plan.Readiness.Collection.Numerator}}/{{.Plan.Readiness.Collection.Denominator}}
+
{{pct .Plan.Readiness.Translation.Value}}
Translation · {{.Plan.Readiness.Translation.Numerator}}/{{.Plan.Readiness.Translation.Denominator}}
+
{{pct .Plan.Readiness.Validation.Value}}
Validation · {{.Plan.Readiness.Validation.Numerator}}/{{.Plan.Readiness.Validation.Denominator}}
+
+

{{.Plan.Readiness.Formula}}

+ {{if .Plan.Readiness.Deductions}}
    {{range .Plan.Readiness.Deductions}}
  • {{.Code}} — {{.Description}}
  • {{end}}
{{end}} + +

Inventory coverage

+

Machine-readable inventory · Snapshot {{.Inventory.Metadata.SnapshotDigest}}

+ + {{range .Inventory.Catalog.Coverage}}{{end}} +
FamilyStatusResourcesNotes
{{.Family}}{{.Status}}{{.Count}}{{.Message}}
{{len .Endpoints}} endpoint check(s)
    {{range .Endpoints}}
  • {{.Path}}{{.Status}} · {{.Count}} resource(s) {{.Message}}
  • {{end}}
+ +

Conversion ledger

+

Machine-readable migration plan · Plan {{.Plan.Metadata.PlanID}}

+ + {{range .Plan.Resources}} + + + + + + + {{end}} +
SourceStatusDecisionEvidence & outputs
{{.SourceName}}
{{.SourceRef}}
{{.SourceKind}}
{{.Status}}{{.Summary}}
{{join .ReasonCodes ", "}} + {{if .SemanticChanges}}{{range .SemanticChanges}}
{{.Code}}

{{.Description}}

Impact: {{.Impact}}

{{end}}{{end}} + {{if .Components}}
{{len .Components}} component result(s)
    {{range .Components}}
  • {{.Status}} {{.ID}} · {{.Kind}}{{if .ReasonCodes}}
    {{join .ReasonCodes ", "}}{{end}}{{if .SourceQuery}}
    Datadog: {{.SourceQuery}}{{end}}{{if .TargetQuery}}
    Candidate: {{.TargetQuery}}{{end}}{{if .Review}}
    {{.Review}}{{end}}
  • {{end}}
{{end}} +
+ +

Validation

+

Overall status: {{.Validation.Status}} · Machine-readable validation

+ + {{range .Validation.Checks}}{{end}} +
CheckStatusMessage
{{.Name}}{{.Status}}{{.Message}}
+ +

Generated candidates

+ + + +
+ + +` diff --git a/internal/datadogplan/scan.go b/internal/datadogplan/scan.go new file mode 100644 index 0000000..f4899c1 --- /dev/null +++ b/internal/datadogplan/scan.go @@ -0,0 +1,892 @@ +package datadogplan + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +type ScanOptions struct { + WorkDir string + Site string + APIKeyEnv string + AppKeyEnv string + Fixture string + BaseURL string + HTTP *http.Client + Version string + Now time.Time + AllowPartial bool +} + +type IncompleteScanError struct { + Families []string +} + +func (err *IncompleteScanError) Error() string { + return "Datadog scan is incomplete: " + strings.Join(err.Families, ", ") +} + +type endpointResult struct { + Path string + Resources []Resource + Status string + Message string +} + +func Scan(ctx context.Context, opts ScanOptions) (*Inventory, error) { + applyScanDefaults(&opts) + if err := os.MkdirAll(opts.WorkDir, 0o755); err != nil { + return nil, err + } + stage, err := os.MkdirTemp(opts.WorkDir, ".scan-*") + if err != nil { + return nil, err + } + defer func() { _ = os.RemoveAll(stage) }() + + var inv *Inventory + if opts.Fixture != "" { + inv, err = scanFixture(stage, opts) + } else { + inv, err = scanLive(ctx, stage, opts) + } + if err != nil { + return nil, err + } + if err := validateInventoryStructure(inv); err != nil { + return nil, fmt.Errorf("validate collected inventory: %w", err) + } + if err := finalizeInventory(stage, inv); err != nil { + return nil, err + } + if err := validateInventoryDigest(inv); err != nil { + return nil, fmt.Errorf("validate collected inventory digest: %w", err) + } + if err := validateSchemaFile("openexit.datadog-inventory.schema.json", filepath.Join(stage, filepath.FromSlash(InventoryRel))); err != nil { + return nil, fmt.Errorf("validate collected inventory schema: %w", err) + } + if err := replaceScanState(opts.WorkDir, stage); err != nil { + return nil, err + } + + if !inv.Catalog.Complete && !opts.AllowPartial { + return inv, &IncompleteScanError{Families: incompleteFamilies(inv.Catalog)} + } + return inv, nil +} + +func applyScanDefaults(opts *ScanOptions) { + if opts.WorkDir == "" { + opts.WorkDir = DefaultWorkDir + } + if opts.Site == "" { + opts.Site = "datadoghq.com" + } + if opts.APIKeyEnv == "" { + opts.APIKeyEnv = "DATADOG_API_KEY" + } + if opts.AppKeyEnv == "" { + opts.AppKeyEnv = "DATADOG_APP_KEY" + } + if opts.Version == "" { + opts.Version = "dev" + } + if opts.Now.IsZero() { + opts.Now = time.Now().UTC() + } else { + opts.Now = opts.Now.UTC() + } +} + +func scanLive(ctx context.Context, stage string, opts ScanOptions) (*Inventory, error) { + apiKey := os.Getenv(opts.APIKeyEnv) + if apiKey == "" { + return nil, fmt.Errorf("environment variable %s is not set", opts.APIKeyEnv) + } + appKey := os.Getenv(opts.AppKeyEnv) + if appKey == "" { + return nil, fmt.Errorf("environment variable %s is not set", opts.AppKeyEnv) + } + client, err := newAPIClient(opts.Site, opts.BaseURL, apiKey, appKey, opts.HTTP) + if err != nil { + return nil, err + } + + byFamily := map[string][]endpointResult{} + for _, spec := range catalogEndpointSpecs { + result := scanEndpoint(ctx, client, stage, opts.Site, spec) + byFamily[spec.Family] = append(byFamily[spec.Family], result) + } + + resources := make([]Resource, 0) + coverage := make([]CatalogFamily, 0, len(catalogFamilies)) + for _, family := range catalogFamilies { + results := byFamily[family] + entry := aggregateFamily(family, results) + for _, result := range results { + resources = append(resources, result.Resources...) + } + coverage = append(coverage, entry) + } + resources = deduplicateResources(resources) + return newInventory(opts, resources, coverage), nil +} + +func scanEndpoint(ctx context.Context, client *apiClient, stage, site string, spec endpointSpec) endpointResult { + items, status, message := fetchEndpointItems(ctx, client, spec) + resources := make([]Resource, 0, len(items)) + for _, item := range items { + var detailErr error + item, detailErr = enrichEndpointItem(ctx, client, spec, item) + if detailErr != nil { + status = CoveragePartial + message = appendMessage(message, detailErr.Error()) + } + resource, err := makeResource(stage, site, spec.Kind, item) + if err != nil { + status = CoveragePartial + message = appendMessage(message, err.Error()) + continue + } + resources = append(resources, resource) + } + if status == CoverageComplete && len(resources) == 0 { + status = CoverageEmpty + } + return endpointResult{Path: spec.Path, Resources: resources, Status: status, Message: message} +} + +func enrichEndpointItem(ctx context.Context, client *apiClient, spec endpointSpec, item map[string]any) (map[string]any, error) { + id := resourceID(item, spec.Kind) + if id == "" || (spec.DetailPath == "" && spec.RelatedPath == "") { + return item, nil + } + out := cloneMap(item) + if spec.DetailPath != "" { + detail, err := fetchObject(ctx, client, fmt.Sprintf(spec.DetailPath, url.PathEscape(id))) + if err != nil { + return out, fmt.Errorf("%s %s detail: %w", spec.Kind, id, err) + } + for key, value := range detail { + out[key] = value + } + } + if spec.RelatedPath != "" { + body, err := client.get(ctx, fmt.Sprintf(spec.RelatedPath, url.PathEscape(id))) + if err != nil { + return out, fmt.Errorf("%s %s related resources: %w", spec.Kind, id, err) + } + decoded, err := decodeJSON(body) + if err != nil { + return out, fmt.Errorf("%s %s related resources: %w", spec.Kind, id, err) + } + if spec.RelatedKey == "" { + spec.RelatedKey = "related" + } + out["openexit_related"] = map[string]any{spec.RelatedKey: decoded} + } + return out, nil +} + +func fetchObject(ctx context.Context, client *apiClient, endpoint string) (map[string]any, error) { + body, err := client.get(ctx, endpoint) + if err != nil { + return nil, err + } + decoded, err := decodeJSON(body) + if err != nil { + return nil, err + } + root, ok := decoded.(map[string]any) + if !ok { + return nil, fmt.Errorf("response is not an object") + } + if data, ok := root["data"].(map[string]any); ok { + return data, nil + } + return root, nil +} + +func cloneMap(value map[string]any) map[string]any { + out := make(map[string]any, len(value)) + for key, child := range value { + out[key] = child + } + return out +} + +func fetchEndpointItems(ctx context.Context, client *apiClient, spec endpointSpec) ([]map[string]any, string, string) { + endpoint := spec.Path + var items []map[string]any + for page := 0; page < 1000; page++ { + requestEndpoint := pagedEndpoint(endpoint, spec, page) + body, err := client.get(ctx, requestEndpoint) + if err != nil { + return items, classifyAPIError(err, len(items) > 0), err.Error() + } + decoded, err := decodeJSON(body) + if err != nil { + return items, statusForPartial(len(items) > 0), "decode response: " + err.Error() + } + rawPageItems := extractItems(decoded, spec) + pageItems, filterComplete := filterEndpointItems(rawPageItems, spec) + items = append(items, pageItems...) + if !filterComplete { + return items, CoveragePartial, "installed-integration response omitted the installed field" + } + next := nextEndpoint(decoded, spec, endpoint, len(rawPageItems)) + if next == "" { + return items, CoverageComplete, "" + } + endpoint = next + if spec.Pagination != "links" && spec.Pagination != "cursor" { + // Page and offset modes derive the next request from the original path. + endpoint = spec.Path + } + } + return items, CoveragePartial, "pagination exceeded 1000 pages" +} + +func pagedEndpoint(endpoint string, spec endpointSpec, page int) string { + mode := spec.Pagination + if mode == "links" || mode == "" { + return endpoint + } + pageSize := spec.PageSize + if pageSize <= 0 { + pageSize = 100 + } + size := strconv.Itoa(pageSize) + u, err := url.Parse(endpoint) + if err != nil { + return endpoint + } + values := u.Query() + switch mode { + case "start": + values.Set("start", strconv.Itoa(page*pageSize)) + values.Set("count", size) + case "monitor": + values.Set("page", strconv.Itoa(page)) + values.Set("page_size", size) + case "offset": + values.Set("offset", strconv.Itoa(page*pageSize)) + values.Set("limit", size) + case "notebook": + values.Set("start", strconv.Itoa(page*pageSize)) + values.Set("count", size) + case "page-number": + values.Set("page_number", strconv.Itoa(page)) + values.Set("page_size", size) + case "page-offset": + values.Set("page[offset]", strconv.Itoa(page*pageSize)) + values.Set("page[limit]", size) + case "page-bracket-number": + values.Set("page[number]", strconv.Itoa(page)) + values.Set("page[size]", size) + case "cursor": + values.Set("page[size]", size) + default: + return endpoint + } + u.RawQuery = values.Encode() + return u.String() +} + +func nextEndpoint(decoded any, spec endpointSpec, endpoint string, count int) string { + if spec.Pagination == "links" { + root, _ := decoded.(map[string]any) + links, _ := root["links"].(map[string]any) + if next, _ := links["next"].(string); next != "" { + return next + } + meta, _ := root["meta"].(map[string]any) + pageMeta, _ := meta["page"].(map[string]any) + if after := stringValue(pageMeta["after"]); after != "" { + u, _ := url.Parse(endpoint) + query := u.Query() + query.Set("page[cursor]", after) + u.RawQuery = query.Encode() + return u.String() + } + return "" + } + if spec.Pagination == "cursor" { + root, _ := decoded.(map[string]any) + cursor := stringValue(nestedValue(root, "meta", "pagination", "next_cursor")) + if cursor == "" { + return "" + } + u, err := url.Parse(spec.Path) + if err != nil { + return "" + } + query := u.Query() + query.Set("page[size]", strconv.Itoa(spec.PageSize)) + query.Set("page[cursor]", cursor) + u.RawQuery = query.Encode() + return u.String() + } + if spec.Pagination != "" && count >= spec.PageSize { + return spec.Path + } + return "" +} + +func filterEndpointItems(items []map[string]any, spec endpointSpec) ([]map[string]any, bool) { + if !spec.InstalledOnly { + return items, true + } + out := make([]map[string]any, 0, len(items)) + complete := true + for _, item := range items { + installed, present := boolField(item, "installed") + if !present { + installed, present = boolField(item, "attributes", "installed") + } + if !present { + complete = false + continue + } + if installed { + out = append(out, item) + } + } + return out, complete +} + +func boolField(root map[string]any, keys ...string) (bool, bool) { + value := nestedValue(root, keys...) + result, ok := value.(bool) + return result, ok +} + +func extractItems(decoded any, spec endpointSpec) []map[string]any { + if list, ok := decoded.([]any); ok { + return mapsFromList(list) + } + root, ok := decoded.(map[string]any) + if !ok { + return nil + } + for _, key := range append(spec.ArrayKeys, "data") { + if list, ok := root[key].([]any); ok { + return mapsFromList(list) + } + } + if spec.Singleton { + return []map[string]any{root} + } + if _, hasID := root["id"]; hasID { + return []map[string]any{root} + } + return nil +} + +func mapsFromList(list []any) []map[string]any { + out := make([]map[string]any, 0, len(list)) + for _, item := range list { + if mapped, ok := item.(map[string]any); ok { + out = append(out, mapped) + } + } + return out +} + +func scanFixture(stage string, opts ScanOptions) (*Inventory, error) { + data, err := os.ReadFile(opts.Fixture) + if err != nil { + return nil, fmt.Errorf("read fixture: %w", err) + } + decoded, err := decodeJSON(data) + if err != nil { + return nil, fmt.Errorf("parse fixture: %w", err) + } + root, ok := decoded.(map[string]any) + if !ok { + return nil, fmt.Errorf("fixture must be a JSON object") + } + if site, _ := root["site"].(string); site != "" { + opts.Site = site + } + redacted, err := RedactJSON(data) + if err != nil { + return nil, err + } + if err := WriteText(filepath.Join(stage, "evidence", "datadog", "raw-fixture.json"), string(redacted)+"\n"); err != nil { + return nil, err + } + + definitions := []struct { + Key string + Family string + Kind string + }{ + {Key: "dashboards", Family: "dashboards", Kind: "dashboard"}, + {Key: "dashboard_lists", Family: "dashboards", Kind: "dashboard_list"}, + {Key: "powerpacks", Family: "dashboards", Kind: "powerpack"}, + {Key: "monitors", Family: "alerting", Kind: "monitor"}, + {Key: "monitor_policies", Family: "alerting", Kind: "monitor_policy"}, + {Key: "downtimes", Family: "alerting", Kind: "downtime"}, + {Key: "slos", Family: "slos", Kind: "slo"}, + {Key: "slo_corrections", Family: "slos", Kind: "slo_correction"}, + {Key: "notebooks", Family: "notebooks", Kind: "notebook"}, + {Key: "synthetic_tests", Family: "synthetics", Kind: "synthetic_test"}, + {Key: "synthetic_variables", Family: "synthetics", Kind: "synthetic_variable"}, + {Key: "synthetic_locations", Family: "synthetics", Kind: "synthetic_location"}, + {Key: "integrations", Family: "integrations", Kind: "integration"}, + {Key: "aws_integrations", Family: "integrations", Kind: "aws_integration"}, + {Key: "azure_integrations", Family: "integrations", Kind: "azure_integration"}, + {Key: "gcp_integrations", Family: "integrations", Kind: "gcp_integration"}, + {Key: "gcp_legacy_integrations", Family: "integrations", Kind: "gcp_legacy_integration"}, + {Key: "metrics", Family: "metrics", Kind: "metric"}, + {Key: "log_pipelines", Family: "logs", Kind: "log_pipeline"}, + {Key: "log_pipeline_orders", Family: "logs", Kind: "log_pipeline_order"}, + {Key: "log_indexes", Family: "logs", Kind: "log_index"}, + {Key: "log_archives", Family: "logs", Kind: "log_archive"}, + {Key: "log_metrics", Family: "logs", Kind: "log_metric"}, + {Key: "apm_retention_filters", Family: "apm", Kind: "apm_retention_filter"}, + {Key: "span_metrics", Family: "apm", Kind: "span_metric"}, + {Key: "service_definitions", Family: "services", Kind: "service_definition"}, + } + counts := map[string]int{} + var resources []Resource + for _, definition := range definitions { + list, _ := root[definition.Key].([]any) + for _, raw := range list { + item, ok := raw.(map[string]any) + if !ok { + continue + } + resource, err := makeResource(stage, opts.Site, definition.Kind, item) + if err != nil { + return nil, err + } + resources = append(resources, resource) + counts[definition.Family]++ + } + } + coverage := make([]CatalogFamily, 0, len(catalogFamilies)) + for _, family := range catalogFamilies { + status := CoverageEmpty + if counts[family] > 0 { + status = CoverageComplete + } + coverage = append(coverage, CatalogFamily{ + Family: family, Status: status, Count: counts[family], + Endpoints: []CatalogEndpoint{{Path: "fixture", Status: status, Count: counts[family]}}, + }) + } + return newInventory(opts, deduplicateResources(resources), coverage), nil +} + +func newInventory(opts ScanOptions, resources []Resource, coverage []CatalogFamily) *Inventory { + sort.Slice(resources, func(i, j int) bool { return resources[i].Ref < resources[j].Ref }) + sort.Slice(coverage, func(i, j int) bool { return coverage[i].Family < coverage[j].Family }) + complete := true + for _, entry := range coverage { + if !coverageSatisfied(entry.Status) { + complete = false + break + } + } + return &Inventory{ + APIVersion: APIVersion, + Kind: InventoryKind, + Metadata: InventoryMetadata{ + Source: "datadog", + Site: opts.Site, + CollectedAt: opts.Now, + CollectorVersion: opts.Version, + }, + Catalog: Catalog{Version: CatalogVersion, Complete: complete, Coverage: coverage}, + Resources: resources, + } +} + +func finalizeInventory(stage string, inv *Inventory) error { + digest, err := CanonicalDigest(struct { + Catalog Catalog `json:"catalog"` + Resources []Resource `json:"resources"` + }{Catalog: inv.Catalog, Resources: inv.Resources}) + if err != nil { + return err + } + inv.Metadata.SnapshotDigest = digest + return WriteJSON(filepath.Join(stage, filepath.FromSlash(InventoryRel)), inv) +} + +func replaceScanState(workDir, stage string) error { + installMoves := make([]stateMove, 0, 2) + stamp := time.Now().UTC().Format("20060102150405.000000000") + for _, name := range []string{"inventory", "evidence"} { + source := filepath.Join(stage, name) + target := filepath.Join(workDir, name) + if _, err := os.Stat(source); err != nil { + return fmt.Errorf("scan output missing %s: %w", name, err) + } + installMoves = append(installMoves, stateMove{source: source, target: target, backup: target + ".previous-" + stamp}) + } + moves := append([]stateMove{}, installMoves...) + for _, stale := range []string{"generated", "plan", "validation", ReportRel, BundleReadmeRel} { + target := filepath.Join(workDir, filepath.FromSlash(stale)) + moves = append(moves, stateMove{target: target, backup: target + ".previous-" + stamp}) + } + if err := backupStateMoves(moves); err != nil { + return err + } + for index := range installMoves { + if err := os.Rename(installMoves[index].source, installMoves[index].target); err != nil { + for rollback := 0; rollback < index; rollback++ { + _ = os.RemoveAll(installMoves[rollback].target) + } + restoreMoves(moves) + return err + } + } + for _, item := range moves { + _ = os.RemoveAll(item.backup) + } + return nil +} + +func makeResource(stage, site, kind string, raw map[string]any) (Resource, error) { + redactedValue := RedactValue(raw) + spec, ok := redactedValue.(map[string]any) + if !ok { + return Resource{}, fmt.Errorf("%s resource is not an object", kind) + } + id := resourceID(spec, kind) + if id == "" { + digest, err := CanonicalDigest(spec) + if err != nil { + return Resource{}, err + } + id = digest[:12] + } + name := resourceName(spec, kind, id) + ref := datadogSourceRef(kind, id) + evidenceRel := "evidence/datadog/" + kind + "/" + safeFilename(id, ref) + ".json" + evidencePath := filepath.Join(stage, filepath.FromSlash(evidenceRel)) + if err := WriteJSON(evidencePath, spec); err != nil { + return Resource{}, err + } + digest, _, err := DigestFile(evidencePath) + if err != nil { + return Resource{}, err + } + return Resource{ + Ref: ref, + Kind: kind, + ID: id, + Name: name, + SourceURL: resourceURL(site, kind, id), + Tags: resourceTags(spec), + Dependencies: resourceDependencies(spec), + Evidence: Evidence{Path: evidenceRel, SHA256: digest}, + Spec: spec, + }, nil +} + +func resourceID(item map[string]any, kind string) string { + for _, key := range []string{"id", "public_id", "publicId", "monitor_id", "slo_id", "name"} { + if value := stringValue(item[key]); value != "" { + return value + } + } + if attributes, ok := item["attributes"].(map[string]any); ok { + for _, key := range []string{"id", "public_id", "name", "title"} { + if value := stringValue(attributes[key]); value != "" { + return value + } + } + } + switch kind { + case "azure_integration": + if value := joinedResourceID(item, "tenant_name", "client_id"); value != "" { + return value + } + case "gcp_integration", "gcp_legacy_integration": + if value := joinedResourceID(item, "project_id", "client_email"); value != "" { + return value + } + case "aws_integration": + if value := joinedResourceID(item, "aws_account_id", "account_id", "role_name"); value != "" { + return value + } + } + if kind == "log_pipeline_order" { + return "pipeline-order" + } + return "" +} + +func joinedResourceID(item map[string]any, keys ...string) string { + var values []string + for _, key := range keys { + if value := stringValue(item[key]); value != "" { + values = append(values, value) + } + } + return strings.Join(values, ":") +} + +func datadogSourceRef(kind, id string) string { + return "datadog:" + kind + ":" + url.PathEscape(strings.TrimSpace(id)) +} + +func resourceName(item map[string]any, kind, fallback string) string { + for _, key := range []string{"name", "title", "display_name", "public_id"} { + if value := stringValue(item[key]); value != "" { + return value + } + } + if attributes, ok := item["attributes"].(map[string]any); ok { + for _, key := range []string{"name", "title", "display_name"} { + if value := stringValue(attributes[key]); value != "" { + return value + } + } + } + return strings.ReplaceAll(kind, "_", " ") + " " + fallback +} + +func resourceTags(item map[string]any) []string { + for _, candidate := range []any{item["tags"], nestedValue(item, "attributes", "tags")} { + if list, ok := candidate.([]any); ok { + values := make([]string, 0, len(list)) + for _, value := range list { + values = append(values, stringValue(value)) + } + return SortedUnique(values) + } + if list, ok := candidate.([]string); ok { + return SortedUnique(list) + } + } + return nil +} + +func resourceDependencies(item map[string]any) []string { + var refs []string + for _, value := range listValues(item["monitor_ids"]) { + refs = append(refs, datadogSourceRef("monitor", value)) + } + for _, value := range listValues(item["dashboardRefs"]) { + refs = append(refs, datadogSourceRef("dashboard", value)) + } + for _, value := range listValues(item["burnRateMonitorIds"]) { + refs = append(refs, datadogSourceRef("monitor", value)) + } + return SortedUnique(refs) +} + +func resourceURL(site, kind, id string) string { + if _, err := datadogAPIBaseURL(site); err != nil { + return "" + } + base := "https://app." + site + paths := map[string]string{ + "dashboard": "/dashboard/", + "monitor": "/monitors/", + "slo": "/slo/", + "notebook": "/notebook/", + "synthetic_test": "/synthetics/details/", + } + if prefix := paths[kind]; prefix != "" { + return base + prefix + url.PathEscape(id) + } + return "" +} + +func aggregateFamily(family string, results []endpointResult) CatalogFamily { + entry := CatalogFamily{Family: family} + var messages []string + for _, result := range results { + entry.Count += len(result.Resources) + entry.Endpoints = append(entry.Endpoints, CatalogEndpoint{Path: result.Path, Status: result.Status, Count: len(result.Resources), Message: result.Message}) + if result.Message != "" { + messages = append(messages, result.Message) + } + } + entry.Message = strings.Join(SortedUnique(messages), "; ") + entry.Status = catalogFamilyStatus(entry.Endpoints, entry.Count) + return entry +} + +func catalogFamilyStatus(endpoints []CatalogEndpoint, count int) string { + allUnavailable := len(endpoints) > 0 + allDenied := len(endpoints) > 0 + hadFailure := false + hadSuccess := false + for _, endpoint := range endpoints { + if endpoint.Status != CoverageNotAvailable { + allUnavailable = false + } + if endpoint.Status != CoveragePermissionDenied { + allDenied = false + } + if coverageSatisfied(endpoint.Status) { + hadSuccess = true + } else { + hadFailure = true + } + } + switch { + case allUnavailable: + return CoverageNotAvailable + case allDenied: + return CoveragePermissionDenied + case hadFailure: + if hadSuccess || count > 0 { + return CoveragePartial + } else { + return CoverageError + } + case count == 0: + return CoverageEmpty + default: + return CoverageComplete + } +} + +func deduplicateResources(resources []Resource) []Resource { + byRef := map[string]Resource{} + for _, resource := range resources { + byRef[resource.Ref] = resource + } + out := make([]Resource, 0, len(byRef)) + for _, resource := range byRef { + out = append(out, resource) + } + sort.Slice(out, func(i, j int) bool { return out[i].Ref < out[j].Ref }) + return out +} + +func classifyAPIError(err error, partial bool) string { + if partial { + return CoveragePartial + } + var apiErr *apiError + if errors.As(err, &apiErr) { + switch apiErr.StatusCode { + case http.StatusForbidden, http.StatusUnauthorized: + return CoveragePermissionDenied + case http.StatusNotFound: + return CoverageNotAvailable + } + } + return CoverageError +} + +func statusForPartial(partial bool) string { + if partial { + return CoveragePartial + } + return CoverageError +} + +func coverageSatisfied(status string) bool { + return status == CoverageComplete || status == CoverageEmpty || status == CoverageNotAvailable +} + +func incompleteFamilies(catalog Catalog) []string { + var out []string + for _, family := range catalog.Coverage { + if !coverageSatisfied(family.Status) { + out = append(out, family.Family+"="+family.Status) + } + } + return out +} + +func nestedValue(root map[string]any, keys ...string) any { + var value any = root + for _, key := range keys { + mapped, ok := value.(map[string]any) + if !ok { + return nil + } + value = mapped[key] + } + return value +} + +func listValues(value any) []string { + list, ok := value.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(list)) + for _, item := range list { + if text := stringValue(item); text != "" { + out = append(out, text) + } + } + return out +} + +func stringValue(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return typed.String() + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + default: + return "" + } +} + +func safeFilename(id, ref string) string { + slug := slugify(id) + digest := DigestBytes([]byte(ref))[:8] + return slug + "-" + digest +} + +func slugify(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + var builder strings.Builder + lastDash := false + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + builder.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + builder.WriteByte('-') + lastDash = true + } + } + result := strings.Trim(builder.String(), "-") + if result == "" { + return "unnamed" + } + if len(result) > 80 { + result = result[:80] + } + return result +} + +func appendMessage(existing, next string) string { + if existing == "" { + return next + } + return existing + "; " + next +} diff --git a/internal/datadogplan/score.go b/internal/datadogplan/score.go new file mode 100644 index 0000000..d16ab45 --- /dev/null +++ b/internal/datadogplan/score.go @@ -0,0 +1,115 @@ +package datadogplan + +import ( + "fmt" + "math" +) + +func Score(inv *Inventory, conversions []Conversion, validation *ValidationReport) Readiness { + collectionNumerator := 0 + for _, family := range inv.Catalog.Coverage { + if coverageSatisfied(family.Status) { + collectionNumerator++ + } + } + collectionDenominator := len(inv.Catalog.Coverage) + collection := ratio(collectionNumerator, collectionDenominator, 1) + + statusCounts := map[string]int{} + translationNumerator := 0 + for _, conversion := range conversions { + statusCounts[conversion.Status]++ + translationNumerator += int(math.Round(conversionWeight(conversion.Status) * 2)) + } + translationDenominator := len(conversions) * 2 + translation := ratio(translationNumerator, translationDenominator, 1) + + validationNumerator, validationDenominator := validationCounts(validation) + validationValue := ratio(validationNumerator, validationDenominator, 1) + raw := 100 * collection * (0.9*translation + 0.1*validationValue) + score := int(math.Round(raw)) + criticalFailure := false + if validation != nil { + for _, check := range validation.Checks { + if check.Critical && check.Status == "failed" { + criticalFailure = true + break + } + } + } + if criticalFailure && score > 49 { + score = 49 + } + if score < 0 { + score = 0 + } + if score > 100 { + score = 100 + } + level := "low" + if score >= 80 { + level = "high" + } else if score >= 50 { + level = "medium" + } + // Keep this as an empty JSON array when there are no deductions. The public + // plan schema deliberately rejects null so consumers can iterate it safely. + deductions := []ScoreDeduction{} + if collection < 1 { + deductions = append(deductions, ScoreDeduction{Code: "inventory.incomplete", Description: fmt.Sprintf("%d of %d catalog families completed", collectionNumerator, collectionDenominator), Points: int(math.Round(100 * (1 - collection)))}) + } + for _, status := range []string{StatusApproximate, StatusManual, StatusUnsupported} { + if statusCounts[status] > 0 { + deductions = append(deductions, ScoreDeduction{Code: "conversion." + status, Description: fmt.Sprintf("%d resources have %s conversion status", statusCounts[status], status), Points: statusCounts[status]}) + } + } + if criticalFailure { + deductions = append(deductions, ScoreDeduction{Code: "validation.critical", Description: "A critical validation check failed; the score is capped at 49", Points: 51}) + } + return Readiness{ + Score: score, + Level: level, + Formula: "round(100 × C × (0.9 × T + 0.1 × V)); exact=1.0, approximate=0.5, manual=0, unsupported=0", + Collection: ReadinessFactor{Value: collection, Numerator: collectionNumerator, Denominator: collectionDenominator, Description: "Successfully evaluated catalog families"}, + Translation: ReadinessFactor{Value: translation, Numerator: translationNumerator, Denominator: translationDenominator, Description: "Resource conversion points; exact=2, approximate=1, manual/unsupported=0"}, + Validation: ReadinessFactor{Value: validationValue, Numerator: validationNumerator, Denominator: validationDenominator, Description: "Required internal validation checks passed"}, + Deductions: deductions, + Interpretation: "Exit readiness measures deterministic inventory and translation coverage. It is not production readiness or cutover approval.", + } +} + +func conversionWeight(status string) float64 { + switch status { + case StatusExact: + return 1 + case StatusApproximate: + return 0.5 + default: + return 0 + } +} + +func validationCounts(report *ValidationReport) (int, int) { + if report == nil { + return 0, 0 + } + passed := 0 + total := 0 + for _, check := range report.Checks { + if !check.Critical { + continue + } + total++ + if check.Status == "passed" { + passed++ + } + } + return passed, total +} + +func ratio(numerator, denominator int, empty float64) float64 { + if denominator == 0 { + return empty + } + return float64(numerator) / float64(denominator) +} diff --git a/internal/datadogplan/validate.go b/internal/datadogplan/validate.go new file mode 100644 index 0000000..9608644 --- /dev/null +++ b/internal/datadogplan/validate.go @@ -0,0 +1,647 @@ +package datadogplan + +import ( + "bytes" + "encoding/json" + "fmt" + "html" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + publicschemas "github.com/RamazanKara/openexit/schemas" + "github.com/santhosh-tekuri/jsonschema/v6" + "gopkg.in/yaml.v3" +) + +func validateWorkspace(stage, workDir string, inv *Inventory, plan *MigrationPlan, allowPartial bool) *ValidationReport { + report := &ValidationReport{APIVersion: APIVersion, Kind: ValidationKind, Status: "passed", GeneratedAt: inv.Metadata.CollectedAt, Checks: []ValidationCheck{}} + add := func(name, status, message string, critical bool) { + report.Checks = append(report.Checks, ValidationCheck{Name: name, Status: status, Message: message, Critical: critical}) + if status == "failed" && critical { + report.Status = "failed" + } + } + + if err := validateInventoryStructure(inv); err != nil { + add("inventory-structure", "failed", err.Error(), true) + } else { + add("inventory-structure", "passed", "", true) + } + if err := validateInventoryDigest(inv); err != nil { + add("inventory-digest", "failed", err.Error(), true) + } else { + add("inventory-digest", "passed", "", true) + } + if inv.Catalog.Complete { + add("inventory-completeness", "passed", "", true) + } else if allowPartial { + add("inventory-completeness", "warning", "partial inventory explicitly accepted: "+strings.Join(incompleteFamilies(inv.Catalog), ", "), false) + } else { + add("inventory-completeness", "failed", strings.Join(incompleteFamilies(inv.Catalog), ", "), true) + } + if err := validatePlanIdentity(inv, plan); err != nil { + add("plan-identity", "failed", err.Error(), true) + } else { + add("plan-identity", "passed", "", true) + } + if err := validateConversionCoverage(inv, plan); err != nil { + add("conversion-coverage", "failed", err.Error(), true) + } else { + add("conversion-coverage", "passed", "", true) + } + if err := validateDeterministicRegeneration(stage, inv, plan); err != nil { + add("deterministic-regeneration", "failed", err.Error(), true) + } else { + add("deterministic-regeneration", "passed", "", true) + } + if err := validateEvidence(workDir, inv); err != nil { + add("evidence-integrity", "failed", err.Error(), true) + } else { + add("evidence-integrity", "passed", "", true) + } + if err := validateOutputProvenance(stage, plan); err != nil { + add("output-provenance", "failed", err.Error(), true) + } else { + add("output-provenance", "passed", "", true) + } + if err := validateGrafanaOutputs(stage, plan); err != nil { + add("grafana-candidates", "failed", err.Error(), true) + } else { + add("grafana-candidates", "passed", "", true) + } + if err := validatePrometheusOutputs(stage, plan); err != nil { + add("prometheus-candidates", "failed", err.Error(), true) + } else { + add("prometheus-candidates", "passed", "", true) + } + if err := validateTelemetryOutputs(stage, inv); err != nil { + add("telemetry-candidates", "failed", err.Error(), true) + } else { + add("telemetry-candidates", "passed", "", true) + } + if err := EnsureNoSymlinks(stage); err != nil { + add("workspace-path-safety", "failed", err.Error(), true) + } else { + add("workspace-path-safety", "passed", "", true) + } + if err := validateNoSecrets(stage, filepath.Join(workDir, "evidence")); err != nil { + add("secret-scan", "failed", err.Error(), true) + } else { + add("secret-scan", "passed", "", true) + } + if err := validateSchemaFile("openexit.datadog-inventory.schema.json", filepath.Join(workDir, filepath.FromSlash(InventoryRel))); err != nil { + add("jsonschema-inventory", "failed", err.Error(), true) + } else { + add("jsonschema-inventory", "passed", "", true) + } + if err := validateSchemaFile("openexit.datadog-plan.schema.json", filepath.Join(stage, filepath.FromSlash(PlanRel))); err != nil { + add("jsonschema-plan", "failed", err.Error(), true) + } else { + add("jsonschema-plan", "passed", "", true) + } + if _, err := os.Stat(filepath.Join(stage, ReportRel)); err == nil { + if err := validateHTMLLinks(stage, workDir); err != nil { + add("report-links", "failed", err.Error(), true) + } else { + add("report-links", "passed", "", true) + } + } + + sort.Slice(report.Checks, func(i, j int) bool { return report.Checks[i].Name < report.Checks[j].Name }) + return report +} + +func validateInventoryStructure(inv *Inventory) error { + if inv.APIVersion != APIVersion || inv.Kind != InventoryKind || inv.Metadata.Source != "datadog" || inv.Catalog.Version != CatalogVersion { + return fmt.Errorf("unexpected inventory identity or catalog version") + } + expectedEndpoints := map[string][]string{} + kindFamily := map[string]string{} + for _, spec := range catalogEndpointSpecs { + expectedEndpoints[spec.Family] = append(expectedEndpoints[spec.Family], spec.Path) + kindFamily[spec.Kind] = spec.Family + } + families := map[string]CatalogFamily{} + fixtureMode := false + for _, family := range inv.Catalog.Coverage { + if _, exists := families[family.Family]; exists { + return fmt.Errorf("duplicate catalog family %s", family.Family) + } + families[family.Family] = family + for _, endpoint := range family.Endpoints { + if endpoint.Path == "fixture" { + fixtureMode = true + } + } + } + if len(families) != len(catalogFamilies) { + return fmt.Errorf("catalog has %d families; expected %d", len(families), len(catalogFamilies)) + } + complete := true + for _, name := range catalogFamilies { + family, ok := families[name] + if !ok { + return fmt.Errorf("catalog family is missing: %s", name) + } + if len(family.Endpoints) == 0 { + return fmt.Errorf("catalog family %s has no endpoint coverage", name) + } + if fixtureMode { + if len(family.Endpoints) != 1 || family.Endpoints[0].Path != "fixture" { + return fmt.Errorf("fixture catalog family %s has unexpected endpoint coverage", name) + } + } else { + want := expectedEndpoints[name] + if len(family.Endpoints) != len(want) { + return fmt.Errorf("catalog family %s covers %d of %d endpoints", name, len(family.Endpoints), len(want)) + } + for index, path := range want { + if family.Endpoints[index].Path != path { + return fmt.Errorf("catalog family %s endpoint %d is %s; expected %s", name, index, family.Endpoints[index].Path, path) + } + } + } + endpointCount := 0 + for _, endpoint := range family.Endpoints { + if !validCoverageStatus(endpoint.Status) { + return fmt.Errorf("catalog endpoint %s has invalid status %q", endpoint.Path, endpoint.Status) + } + if endpoint.Count < 0 { + return fmt.Errorf("catalog endpoint %s has a negative count", endpoint.Path) + } + if endpointCoverageCountInconsistent(endpoint.Status, endpoint.Count) { + return fmt.Errorf("catalog endpoint %s status %s is inconsistent with count %d", endpoint.Path, endpoint.Status, endpoint.Count) + } + endpointCount += endpoint.Count + } + if endpointCount != family.Count { + return fmt.Errorf("catalog family %s count is %d; endpoint counts total %d", name, family.Count, endpointCount) + } + if want := catalogFamilyStatus(family.Endpoints, family.Count); family.Status != want { + return fmt.Errorf("catalog family %s status is %s; endpoint coverage requires %s", name, family.Status, want) + } + if endpointCoverageCountInconsistent(family.Status, family.Count) { + return fmt.Errorf("catalog family %s status %s is inconsistent with count %d", name, family.Status, family.Count) + } + if !coverageSatisfied(family.Status) { + complete = false + } + } + if inv.Catalog.Complete != complete { + return fmt.Errorf("catalog complete is %t; coverage requires %t", inv.Catalog.Complete, complete) + } + + resourceCounts := map[string]int{} + refs := map[string]struct{}{} + evidencePaths := map[string]struct{}{} + previousRef := "" + for _, resource := range inv.Resources { + family, ok := kindFamily[resource.Kind] + if !ok { + return fmt.Errorf("resource %s has unknown kind %s", resource.Ref, resource.Kind) + } + if resource.Ref != datadogSourceRef(resource.Kind, resource.ID) { + return fmt.Errorf("resource %s does not match kind and ID", resource.Ref) + } + if _, exists := refs[resource.Ref]; exists { + return fmt.Errorf("duplicate inventory resource %s", resource.Ref) + } + refs[resource.Ref] = struct{}{} + if previousRef != "" && resource.Ref < previousRef { + return fmt.Errorf("inventory resources are not sorted by source reference") + } + previousRef = resource.Ref + wantEvidence := "evidence/datadog/" + resource.Kind + "/" + safeFilename(resource.ID, resource.Ref) + ".json" + if resource.Evidence.Path != wantEvidence { + return fmt.Errorf("resource %s has unexpected evidence path %s", resource.Ref, resource.Evidence.Path) + } + if _, exists := evidencePaths[resource.Evidence.Path]; exists { + return fmt.Errorf("duplicate evidence path %s", resource.Evidence.Path) + } + evidencePaths[resource.Evidence.Path] = struct{}{} + resourceCounts[family]++ + } + for _, family := range inv.Catalog.Coverage { + if resourceCounts[family.Family] != family.Count { + return fmt.Errorf("catalog family %s reports %d resources; inventory contains %d", family.Family, family.Count, resourceCounts[family.Family]) + } + } + return nil +} + +func validCoverageStatus(status string) bool { + switch status { + case CoverageComplete, CoverageEmpty, CoverageNotAvailable, CoveragePartial, CoveragePermissionDenied, CoverageError: + return true + default: + return false + } +} + +func endpointCoverageCountInconsistent(status string, count int) bool { + switch status { + case CoverageComplete: + return count == 0 + case CoverageEmpty, CoverageNotAvailable, CoveragePermissionDenied, CoverageError: + return count != 0 + default: + return false + } +} + +func validateDeterministicRegeneration(stage string, inv *Inventory, plan *MigrationPlan) error { + temp, err := os.MkdirTemp("", "openexit-reproduction-*") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(temp) }() + expected, err := generateConversions(temp, inv) + if err != nil { + return err + } + wantDigest, err := CanonicalDigest(expected) + if err != nil { + return err + } + gotDigest, err := CanonicalDigest(plan.Resources) + if err != nil { + return err + } + if wantDigest != gotDigest { + return fmt.Errorf("conversion ledger does not match deterministic ruleset %s", RulesetVersion) + } + wantSummary, err := CanonicalDigest(summarizeConversions(expected)) + if err != nil { + return err + } + gotSummary, err := CanonicalDigest(plan.Summary) + if err != nil { + return err + } + if wantSummary != gotSummary { + return fmt.Errorf("plan summary does not match deterministic conversions") + } + wantFiles, err := generatedDigests(filepath.Join(temp, "generated")) + if err != nil { + return err + } + gotFiles, err := generatedDigests(filepath.Join(stage, "generated")) + if err != nil { + return err + } + if len(wantFiles) != len(gotFiles) { + return fmt.Errorf("generated file set has %d files; deterministic ruleset produced %d", len(gotFiles), len(wantFiles)) + } + for path, digest := range wantFiles { + if gotFiles[path] != digest { + return fmt.Errorf("generated file differs from deterministic ruleset: %s", path) + } + } + return nil +} + +func generatedDigests(root string) (map[string]string, error) { + files := map[string]string{} + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + digest, _, err := DigestFile(path) + if err != nil { + return err + } + files[filepath.ToSlash(rel)] = digest + return nil + }) + return files, err +} + +func validateInventoryDigest(inv *Inventory) error { + digest, err := CanonicalDigest(struct { + Catalog Catalog `json:"catalog"` + Resources []Resource `json:"resources"` + }{Catalog: inv.Catalog, Resources: inv.Resources}) + if err != nil { + return err + } + if digest != inv.Metadata.SnapshotDigest { + return fmt.Errorf("inventory digest mismatch: expected %s, got %s", inv.Metadata.SnapshotDigest, digest) + } + return nil +} + +func validatePlanIdentity(inv *Inventory, plan *MigrationPlan) error { + if plan.APIVersion != APIVersion || plan.Kind != PlanKind { + return fmt.Errorf("unexpected plan identity") + } + if plan.Target != DefaultTarget { + return fmt.Errorf("unsupported target %q", plan.Target) + } + if plan.Metadata.InventoryDigest != inv.Metadata.SnapshotDigest { + return fmt.Errorf("plan inventory digest does not match the current scan") + } + if plan.Metadata.RulesetVersion != RulesetVersion { + return fmt.Errorf("plan ruleset is %q; expected %q", plan.Metadata.RulesetVersion, RulesetVersion) + } + if !plan.Metadata.GeneratedAt.Equal(inv.Metadata.CollectedAt) { + return fmt.Errorf("plan timestamp does not match the inventory snapshot") + } + want, err := planDigest(inv.Metadata.SnapshotDigest, plan.Target) + if err != nil { + return err + } + if want != plan.Metadata.PlanID { + return fmt.Errorf("plan ID does not match its deterministic inputs") + } + return nil +} + +func validateConversionCoverage(inv *Inventory, plan *MigrationPlan) error { + resources := map[string]Resource{} + for _, resource := range inv.Resources { + if _, exists := resources[resource.Ref]; exists { + return fmt.Errorf("duplicate inventory resource %s", resource.Ref) + } + resources[resource.Ref] = resource + } + seen := map[string]struct{}{} + for _, conversion := range plan.Resources { + if _, ok := resources[conversion.SourceRef]; !ok { + return fmt.Errorf("conversion references unknown source %s", conversion.SourceRef) + } + if _, exists := seen[conversion.SourceRef]; exists { + return fmt.Errorf("duplicate conversion for %s", conversion.SourceRef) + } + seen[conversion.SourceRef] = struct{}{} + if conversion.Status != StatusExact && conversion.Status != StatusApproximate && conversion.Status != StatusManual && conversion.Status != StatusUnsupported { + return fmt.Errorf("%s has invalid status %q", conversion.SourceRef, conversion.Status) + } + if len(conversion.ReasonCodes) == 0 { + return fmt.Errorf("%s has no conversion reason code", conversion.SourceRef) + } + } + if len(seen) != len(resources) { + return fmt.Errorf("plan covers %d of %d inventory resources", len(seen), len(resources)) + } + return nil +} + +func validateEvidence(workDir string, inv *Inventory) error { + for _, resource := range inv.Resources { + path, err := WorkspacePath(workDir, resource.Evidence.Path) + if err != nil { + return fmt.Errorf("%s evidence path: %w", resource.Ref, err) + } + digest, _, err := DigestFile(path) + if err != nil { + return fmt.Errorf("%s evidence: %w", resource.Ref, err) + } + if digest != resource.Evidence.SHA256 { + return fmt.Errorf("%s evidence digest mismatch", resource.Ref) + } + var evidence map[string]any + if err := ReadJSON(path, &evidence); err != nil { + return fmt.Errorf("%s evidence content: %w", resource.Ref, err) + } + want, err := CanonicalDigest(resource.Spec) + if err != nil { + return err + } + got, err := CanonicalDigest(evidence) + if err != nil { + return err + } + if want != got { + return fmt.Errorf("%s inventory spec does not match its evidence", resource.Ref) + } + } + return nil +} + +func validateOutputProvenance(stage string, plan *MigrationPlan) error { + referenced := map[string][]string{} + for _, conversion := range plan.Resources { + for _, output := range conversion.Outputs { + if err := SafeRelativePath(output.Path); err != nil { + return fmt.Errorf("%s output %s: %w", conversion.SourceRef, output.Path, err) + } + path := filepath.Join(stage, filepath.FromSlash(output.Path)) + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return fmt.Errorf("%s output is missing: %s", conversion.SourceRef, output.Path) + } + referenced[output.Path] = append(referenced[output.Path], conversion.SourceRef) + } + } + allowedBaseline := map[string]bool{ + "generated/alloy/config.alloy": true, + "generated/opentelemetry/collector.yaml": true, + } + root := filepath.Join(stage, "generated") + return filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + rel, err := filepath.Rel(stage, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if len(referenced[rel]) == 0 && !allowedBaseline[rel] { + return fmt.Errorf("generated output is not linked from the plan: %s", rel) + } + return nil + }) +} + +func validateGrafanaOutputs(stage string, plan *MigrationPlan) error { + for _, conversion := range plan.Resources { + for _, output := range conversion.Outputs { + if output.Kind != "grafana-dashboard" { + continue + } + data, err := os.ReadFile(filepath.Join(stage, filepath.FromSlash(output.Path))) + if err != nil { + return err + } + var dashboard grafanaDashboard + if err := json.Unmarshal(data, &dashboard); err != nil { + return fmt.Errorf("%s: %w", output.Path, err) + } + if dashboard.Title == "" || dashboard.SchemaVersion <= 0 || dashboard.Panels == nil { + return fmt.Errorf("%s is missing required Grafana dashboard fields", output.Path) + } + if stringValue(dashboard.OpenExit["sourceRef"]) != conversion.SourceRef || dashboard.OpenExit["productionReady"] != false { + return fmt.Errorf("%s has invalid OpenExit source metadata", output.Path) + } + } + } + return nil +} + +func validatePrometheusOutputs(stage string, plan *MigrationPlan) error { + for _, conversion := range plan.Resources { + for _, output := range conversion.Outputs { + if output.Kind != "prometheus-alert-rule" { + continue + } + data, err := os.ReadFile(filepath.Join(stage, filepath.FromSlash(output.Path))) + if err != nil { + return err + } + if strings.Contains(string(data), "vector(0)") { + return fmt.Errorf("%s contains a fake vector(0) placeholder", output.Path) + } + var rules prometheusRuleFile + if err := yaml.Unmarshal(data, &rules); err != nil { + return fmt.Errorf("%s: %w", output.Path, err) + } + if len(rules.Groups) != 1 || len(rules.Groups[0].Rules) != 1 { + return fmt.Errorf("%s must contain exactly one source-linked rule", output.Path) + } + rule := rules.Groups[0].Rules[0] + if !promIdentifierPattern.MatchString(rule.Alert) || strings.TrimSpace(rule.Expr) == "" { + return fmt.Errorf("%s has an invalid alert name or empty expression", output.Path) + } + if rule.Labels["source_ref"] != conversion.SourceRef || rule.Labels["production_ready"] != "false" || rule.Labels["openexit_candidate"] != "true" { + return fmt.Errorf("%s has invalid source or safety labels", output.Path) + } + if rule.Annotations["openexit_source_query"] == "" || rule.Annotations["openexit_review"] == "" { + return fmt.Errorf("%s does not preserve source query and review guidance", output.Path) + } + } + } + return nil +} + +func validateTelemetryOutputs(stage string, inv *Inventory) error { + alloyData, err := os.ReadFile(filepath.Join(stage, "generated", "alloy", "config.alloy")) + if err != nil { + return err + } + otelData, err := os.ReadFile(filepath.Join(stage, "generated", "opentelemetry", "collector.yaml")) + if err != nil { + return err + } + alloy := string(alloyData) + for _, marker := range []string{"otelcol.receiver.otlp", "otelcol.processor.batch", "otelcol.exporter.otlphttp", `sys.env("OPENEXIT_OTLP_ENDPOINT")`} { + if !strings.Contains(alloy, marker) { + return fmt.Errorf("alloy candidate is missing %s", marker) + } + } + var otel map[string]any + if err := yaml.Unmarshal(otelData, &otel); err != nil { + return fmt.Errorf("OpenTelemetry candidate: %w", err) + } + for _, key := range []string{"receivers", "processors", "exporters", "service"} { + if _, ok := otel[key]; !ok { + return fmt.Errorf("OpenTelemetry candidate is missing %s", key) + } + } + for _, ref := range configSourceRefs(inv.Resources) { + if !strings.Contains(alloy, ref) || !strings.Contains(string(otelData), ref) { + return fmt.Errorf("telemetry candidates do not preserve source reference %s", ref) + } + } + return nil +} + +func validateNoSecrets(roots ...string) error { + for _, root := range roots { + if _, err := os.Stat(root); os.IsNotExist(err) { + continue + } + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if secretValuePattern.Match(data) { + return fmt.Errorf("secret-like value found in %s", path) + } + return nil + }) + if err != nil { + return err + } + } + return nil +} + +func validateSchemaFile(schemaFile, path string) error { + schemaData, err := publicschemas.FS.ReadFile(schemaFile) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + compiler := jsonschema.NewCompiler() + compiler.DefaultDraft(jsonschema.Draft7) + document, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaData)) + if err != nil { + return err + } + if err := compiler.AddResource(schemaFile, document); err != nil { + return err + } + schema, err := compiler.Compile(schemaFile) + if err != nil { + return err + } + instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + return err + } + return schema.Validate(instance) +} + +var hrefPattern = regexp.MustCompile(`href="([^"]+)"`) + +func validateHTMLLinks(stage, workDir string) error { + data, err := os.ReadFile(filepath.Join(stage, ReportRel)) + if err != nil { + return err + } + for _, match := range hrefPattern.FindAllStringSubmatch(string(data), -1) { + if len(match) != 2 { + continue + } + target := html.UnescapeString(match[1]) + if strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "#") { + continue + } + pathPart, _, _ := strings.Cut(target, "#") + if err := SafeRelativePath(pathPart); err != nil { + return fmt.Errorf("unsafe report link %q", target) + } + root := stage + if strings.HasPrefix(pathPart, "inventory/") || strings.HasPrefix(pathPart, "evidence/") { + root = workDir + } + if info, err := os.Stat(filepath.Join(root, filepath.FromSlash(pathPart))); err != nil || info.IsDir() { + return fmt.Errorf("broken report link %q", target) + } + } + return nil +} diff --git a/internal/datadogplan/workflow_test.go b/internal/datadogplan/workflow_test.go new file mode 100644 index 0000000..9919fcb --- /dev/null +++ b/internal/datadogplan/workflow_test.go @@ -0,0 +1,562 @@ +package datadogplan + +import ( + "context" + "encoding/json" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +func TestFixtureWorkflowIsDeterministicAndReviewable(t *testing.T) { + t.Parallel() + fixed := time.Date(2026, time.July, 18, 12, 0, 0, 0, time.UTC) + fixture := filepath.Join("..", "..", "testdata", "datadog", "small.json") + root := t.TempDir() + workA := filepath.Join(root, "a", DefaultWorkDir) + workB := filepath.Join(root, "b", DefaultWorkDir) + + for _, workDir := range []string{workA, workB} { + inventory, err := Scan(context.Background(), ScanOptions{WorkDir: workDir, Fixture: fixture, Version: "test", Now: fixed}) + if err != nil { + t.Fatalf("scan %s: %v", workDir, err) + } + if !inventory.Catalog.Complete || len(inventory.Resources) != 7 { + t.Fatalf("unexpected inventory: complete=%t resources=%d", inventory.Catalog.Complete, len(inventory.Resources)) + } + plan, validation, err := Plan(PlanOptions{WorkDir: workDir, Target: DefaultTarget}) + if err != nil { + t.Fatalf("plan %s: %v", workDir, err) + } + if validation.Status != "passed" { + t.Fatalf("validation status = %s", validation.Status) + } + if plan.Summary.Total != len(inventory.Resources) || len(plan.Resources) != len(inventory.Resources) { + t.Fatalf("plan does not cover inventory: summary=%d conversions=%d inventory=%d", plan.Summary.Total, len(plan.Resources), len(inventory.Resources)) + } + } + + assertTreeEqual(t, workA, workB) + + var inventory Inventory + readTestJSON(t, filepath.Join(workA, filepath.FromSlash(InventoryRel)), &inventory) + var plan MigrationPlan + readTestJSON(t, filepath.Join(workA, filepath.FromSlash(PlanRel)), &plan) + if plan.Readiness.Translation.Denominator != len(plan.Resources)*2 { + t.Fatalf("translation denominator = %d, want %d", plan.Readiness.Translation.Denominator, len(plan.Resources)*2) + } + if plan.Readiness.Translation.Value != float64(plan.Readiness.Translation.Numerator)/float64(plan.Readiness.Translation.Denominator) { + t.Fatalf("translation factor is not reproducible from numerator/denominator") + } + + convertedOutputs := map[string][]string{} + for _, conversion := range plan.Resources { + if conversion.SourceRef == "" || conversion.EvidencePath == "" || len(conversion.ReasonCodes) == 0 { + t.Fatalf("incomplete conversion ledger entry: %#v", conversion) + } + if _, err := os.Stat(filepath.Join(workA, filepath.FromSlash(conversion.EvidencePath))); err != nil { + t.Fatalf("missing evidence for %s: %v", conversion.SourceRef, err) + } + for _, output := range conversion.Outputs { + convertedOutputs[output.Path] = append(convertedOutputs[output.Path], conversion.SourceRef) + } + if conversion.SourceRef == "datadog:dashboard:abc-123" && len(conversion.Components) != 3 { + t.Fatalf("dashboard components = %d, want every one of 3 source components", len(conversion.Components)) + } + if conversion.SourceRef == "datadog:monitor:789012" && (conversion.Status != StatusManual || len(conversion.Outputs) != 0) { + t.Fatalf("complex monitor must stay manual without executable output: %#v", conversion) + } + } + + err := filepath.WalkDir(filepath.Join(workA, "generated"), func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil || entry.IsDir() { + return walkErr + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(data), "vector(0)") { + t.Fatalf("fake vector(0) placeholder in %s", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + exportDir := filepath.Join(root, "migration") + manifest, err := Export(ExportOptions{WorkDir: workA, Out: exportDir, Version: "0.1.0-test", Commit: "abc123", Date: "2026-07-18"}) + if err != nil { + t.Fatalf("export: %v", err) + } + manifestRefs := map[string][]string{} + for _, file := range manifest.Files { + manifestRefs[file.Path] = file.SourceRefs + } + for path, refs := range convertedOutputs { + for _, ref := range SortedUnique(refs) { + if !containsString(manifestRefs[path], ref) { + t.Fatalf("manifest %s does not link source %s", path, ref) + } + } + } + for _, resource := range inventory.Resources { + if !containsString(manifestRefs[resource.Evidence.Path], resource.Ref) { + t.Fatalf("manifest evidence %s does not link source %s", resource.Evidence.Path, resource.Ref) + } + } + verifyChecksumFile(t, exportDir) + if err := validateHTMLLinks(exportDir, exportDir); err != nil { + t.Fatalf("exported report links: %v", err) + } + if _, err := Export(ExportOptions{WorkDir: workA, Out: exportDir}); err == nil { + t.Fatal("export unexpectedly replaced an existing directory without --force") + } + validationPath := filepath.Join(workA, filepath.FromSlash(ValidationRel)) + validationBytes, err := os.ReadFile(validationPath) + if err != nil { + t.Fatal(err) + } + var tamperedValidation ValidationReport + if err := json.Unmarshal(validationBytes, &tamperedValidation); err != nil { + t.Fatal(err) + } + tamperedValidation.Checks[0].Message = "tampered" + if err := WriteJSON(validationPath, &tamperedValidation); err != nil { + t.Fatal(err) + } + if _, err := Export(ExportOptions{WorkDir: workA, Out: filepath.Join(root, "validation-tamper")}); err == nil || !strings.Contains(err.Error(), "saved validation report") { + t.Fatalf("tampered validation report was not rejected: %v", err) + } + if err := os.WriteFile(validationPath, validationBytes, 0o644); err != nil { + t.Fatal(err) + } + rulePath := filepath.Join(workA, "generated", "prometheus", "rules", "123456-75784b75.yaml") + rule, err := os.ReadFile(rulePath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(rulePath, append(rule, []byte("# tampered\n")...), 0o644); err != nil { + t.Fatal(err) + } + tamperedOut := filepath.Join(root, "tampered-export") + if _, err := Export(ExportOptions{WorkDir: workA, Out: tamperedOut}); err == nil || !strings.Contains(err.Error(), "deterministic-regeneration") { + t.Fatalf("tampered generated file was not rejected by deterministic regeneration: %v", err) + } + if _, err := os.Stat(tamperedOut); !os.IsNotExist(err) { + t.Fatalf("failed export left output behind: %v", err) + } +} + +func TestLiveScanUsesGETAndPaginatesMetrics(t *testing.T) { + var mu sync.Mutex + var methods []string + metricRequests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + mu.Lock() + methods = append(methods, request.Method) + mu.Unlock() + if request.Header.Get("DD-API-KEY") != "test-api-key" || request.Header.Get("DD-APPLICATION-KEY") != "test-app-key" { + t.Errorf("missing Datadog authentication headers") + } + body, _ := io.ReadAll(request.Body) + if len(body) != 0 { + t.Errorf("GET %s unexpectedly had a request body", request.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + switch request.URL.Path { + case "/api/v2/metrics": + mu.Lock() + metricRequests++ + mu.Unlock() + if request.URL.Query().Get("page[cursor]") == "next-page" { + _, _ = io.WriteString(w, `{"data":[{"id":"metric.two","type":"metrics"}],"meta":{"pagination":{}}}`) + return + } + _, _ = io.WriteString(w, `{"data":[{"id":"metric.one","type":"metrics"}],"meta":{"pagination":{"next_cursor":"next-page"}}}`) + case "/api/v1/logs/config/pipeline-order": + _, _ = io.WriteString(w, `{"pipeline_ids":[]}`) + default: + _, _ = io.WriteString(w, `{"data":[],"dashboards":[],"dashboard_lists":[],"tests":[],"variables":[],"locations":[],"indexes":[]}`) + } + })) + defer server.Close() + t.Setenv("OPENEXIT_TEST_DD_API", "test-api-key") + t.Setenv("OPENEXIT_TEST_DD_APP", "test-app-key") + + inventory, err := Scan(context.Background(), ScanOptions{ + WorkDir: filepath.Join(t.TempDir(), DefaultWorkDir), BaseURL: server.URL, HTTP: server.Client(), + APIKeyEnv: "OPENEXIT_TEST_DD_API", AppKeyEnv: "OPENEXIT_TEST_DD_APP", Now: time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC), + }) + if err != nil { + t.Fatalf("live scan: %v", err) + } + if !inventory.Catalog.Complete { + t.Fatal("catalog should be complete when every endpoint responds") + } + metricCount := 0 + for _, resource := range inventory.Resources { + if resource.Kind == "metric" { + metricCount++ + } + } + if metricCount != 2 { + t.Fatalf("metric count = %d, want 2 paginated resources", metricCount) + } + mu.Lock() + defer mu.Unlock() + if metricRequests != 2 { + t.Fatalf("metric requests = %d, want 2", metricRequests) + } + for _, method := range methods { + if method != http.MethodGet { + t.Fatalf("Datadog scan used %s instead of GET", method) + } + } +} + +func TestPermissionDeniedScanFailsClosedAndRemainsReviewable(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch request.URL.Path { + case "/api/v2/monitor/policy": + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, `{"errors":["secret response must not be persisted"]}`) + case "/api/v1/logs/config/pipeline-order": + _, _ = io.WriteString(w, `{"pipeline_ids":[]}`) + default: + _, _ = io.WriteString(w, `{"data":[],"dashboards":[],"dashboard_lists":[],"tests":[],"variables":[],"locations":[],"indexes":[],"meta":{"pagination":{}}}`) + } + })) + defer server.Close() + t.Setenv("OPENEXIT_DENIED_DD_API", "denied-api-key") + t.Setenv("OPENEXIT_DENIED_DD_APP", "denied-app-key") + workDir := filepath.Join(t.TempDir(), DefaultWorkDir) + inventory, err := Scan(context.Background(), ScanOptions{ + WorkDir: workDir, BaseURL: server.URL, HTTP: server.Client(), + APIKeyEnv: "OPENEXIT_DENIED_DD_API", AppKeyEnv: "OPENEXIT_DENIED_DD_APP", Now: time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC), + }) + if err == nil || inventory == nil { + t.Fatalf("permission-denied scan should persist inventory and fail closed: inventory=%v err=%v", inventory, err) + } + if inventory.Catalog.Complete { + t.Fatal("permission-denied catalog was marked complete") + } + var alerting *CatalogFamily + for index := range inventory.Catalog.Coverage { + if inventory.Catalog.Coverage[index].Family == "alerting" { + alerting = &inventory.Catalog.Coverage[index] + } + } + if alerting == nil || alerting.Status != CoveragePartial { + t.Fatalf("alerting coverage = %#v, want partial", alerting) + } + policyDenied := false + for _, endpoint := range alerting.Endpoints { + if endpoint.Path == "/api/v2/monitor/policy" && endpoint.Status == CoveragePermissionDenied { + policyDenied = true + } + } + if !policyDenied { + t.Fatalf("monitor-policy permission denial is not visible: %#v", alerting.Endpoints) + } + inventoryData, readErr := os.ReadFile(filepath.Join(workDir, filepath.FromSlash(InventoryRel))) + if readErr != nil { + t.Fatal(readErr) + } + for _, secret := range []string{"secret response", "denied-api-key", "denied-app-key"} { + if strings.Contains(string(inventoryData), secret) { + t.Fatalf("partial inventory exposed %q", secret) + } + } + if _, _, err := Plan(PlanOptions{WorkDir: workDir}); err == nil { + t.Fatal("plan unexpectedly accepted partial inventory without --allow-partial") + } + plan, validation, err := Plan(PlanOptions{WorkDir: workDir, AllowPartial: true}) + if err != nil { + t.Fatalf("explicitly accepted partial plan: %v", err) + } + if validation.Status != "passed" || plan.Readiness.Collection.Numerator >= plan.Readiness.Collection.Denominator { + t.Fatalf("partial limitation was not carried into plan: readiness=%#v validation=%#v", plan.Readiness, validation) + } +} + +func TestAPIErrorDoesNotExposeResponseOrCredentials(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + http.Error(w, `{"errors":["test-api-key test-app-key secret-response"]}`, http.StatusForbidden) + })) + defer server.Close() + client, err := newAPIClient("datadoghq.com", server.URL, "test-api-key", "test-app-key", server.Client()) + if err != nil { + t.Fatal(err) + } + _, err = client.get(context.Background(), "/api/v1/monitor") + if err == nil { + t.Fatal("expected API error") + } + for _, secret := range []string{"test-api-key", "test-app-key", "secret-response"} { + if strings.Contains(err.Error(), secret) { + t.Fatalf("error exposed %q: %v", secret, err) + } + } +} + +func TestAPIClientRefusesRedirectsBeforeCredentialsCanCrossHosts(t *testing.T) { + t.Parallel() + var redirectMu sync.Mutex + redirectReached := false + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + redirectMu.Lock() + redirectReached = true + redirectMu.Unlock() + if request.Header.Get("DD-API-KEY") != "" || request.Header.Get("DD-APPLICATION-KEY") != "" { + t.Error("Datadog credentials crossed a redirect boundary") + } + _, _ = io.WriteString(w, `{}`) + })) + defer target.Close() + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + http.Redirect(w, request, target.URL+"/capture", http.StatusFound) + })) + defer source.Close() + client, err := newAPIClient("datadoghq.com", source.URL, "test-api-key", "test-app-key", source.Client()) + if err != nil { + t.Fatal(err) + } + _, err = client.get(context.Background(), "/api/v1/monitor") + if err == nil || !strings.Contains(err.Error(), "HTTP 302") { + t.Fatalf("redirect was not surfaced as a rejected API response: %v", err) + } + redirectMu.Lock() + defer redirectMu.Unlock() + if redirectReached { + t.Fatal("redirect target was reached") + } +} + +func TestRedactionPreservesSecretResourceIdentity(t *testing.T) { + t.Parallel() + input := map[string]any{ + "id": "variable-123", "name": "deploy-token", "type": "secret", "is_secret": true, + "value": "super-secret-value", "nested": map[string]any{"client_secret": "nested-secret"}, + } + redacted := RedactValue(input).(map[string]any) + if redacted["id"] != "variable-123" || redacted["name"] != "deploy-token" || redacted["type"] != "secret" || redacted["is_secret"] != true { + t.Fatalf("redaction destroyed resource identity: %#v", redacted) + } + if redacted["value"] != "[REDACTED]" { + t.Fatalf("secret value was not redacted: %#v", redacted["value"]) + } + nested := redacted["nested"].(map[string]any) + if nested["client_secret"] != "[REDACTED]" { + t.Fatalf("nested secret was not redacted: %#v", nested) + } + textual := RedactValue(map[string]any{"tag": "api_key=embedded-value", "url": "https://example.invalid/?token=embedded-value"}).(map[string]any) + if textual["tag"] != "[REDACTED]" || textual["url"] != "[REDACTED]" { + t.Fatalf("textual secret assignments were not redacted: %#v", textual) + } +} + +func TestScoreUsesPublishedResourceWeights(t *testing.T) { + t.Parallel() + inventory := &Inventory{Catalog: Catalog{Coverage: []CatalogFamily{{Status: CoverageComplete}, {Status: CoverageEmpty}}}} + conversions := []Conversion{ + {Status: StatusExact}, + {Status: StatusApproximate}, + {Status: StatusManual}, + {Status: StatusUnsupported}, + } + validation := &ValidationReport{Checks: []ValidationCheck{{Status: "passed", Critical: true}}} + readiness := Score(inventory, conversions, validation) + if readiness.Translation.Numerator != 3 || readiness.Translation.Denominator != 8 || readiness.Translation.Value != 0.375 { + t.Fatalf("unexpected translation factor: %#v", readiness.Translation) + } + if readiness.Score != 44 { + t.Fatalf("score = %d, want round(100*(0.9*0.375+0.1)) = 44", readiness.Score) + } +} + +func TestExactOnlyScoreUsesSchemaCompatibleEmptyDeductions(t *testing.T) { + t.Parallel() + inventory := &Inventory{Catalog: Catalog{Coverage: []CatalogFamily{{Status: CoverageComplete}}}} + readiness := Score(inventory, []Conversion{{Status: StatusExact}}, &ValidationReport{Checks: []ValidationCheck{{Status: "passed", Critical: true}}}) + if readiness.Deductions == nil || len(readiness.Deductions) != 0 { + t.Fatalf("exact-only deductions = %#v, want a non-nil empty array", readiness.Deductions) + } +} + +func TestDashboardAndPrometheusCandidatesStayConservative(t *testing.T) { + t.Parallel() + stage := t.TempDir() + resource := Resource{ + Ref: "datadog:dashboard:text-only", Kind: "dashboard", ID: "text-only", Name: "Text only", + Evidence: Evidence{Path: "evidence/datadog/dashboard/text-only.json"}, + Spec: map[string]any{"widgets": []any{map[string]any{"definition": map[string]any{ + "type": "note", "title": "Runbook", "content": "Use the service runbook.", + }}}}, + } + conversion, err := generateDashboard(stage, resource) + if err != nil { + t.Fatal(err) + } + if conversion.Status != StatusApproximate || len(conversion.Components) != 1 || conversion.Components[0].Status != StatusExact { + t.Fatalf("text dashboard overstated conversion fidelity: %#v", conversion) + } + if result := convertDashboardMetricQuery("avg:system.cpu.user{*}"); !result.OK || result.Expr != "avg(system_cpu_user)" { + t.Fatalf("unfiltered Datadog selector was not converted conservatively: %#v", result) + } + for _, query := range []string{"avg:system.cpu.user{!env:prod}", "avg:system.cpu.user{env:(prod OR staging)}"} { + if result := convertDashboardMetricQuery(query); result.OK { + t.Fatalf("unsafe Datadog tag expression %q became executable PromQL: %#v", query, result) + } + } + if alert := promAlertName("123 errors", "datadog:monitor:123"); !promIdentifierPattern.MatchString(alert) { + t.Fatalf("generated invalid Prometheus alert name %q", alert) + } +} + +func TestSourceReferencesEscapeUnsafeResourceIDs(t *testing.T) { + t.Parallel() + ref := datadogSourceRef("dashboard", "folder/name\nwith space") + if strings.ContainsAny(ref, "/\n ") || !strings.Contains(ref, "%2F") || !strings.Contains(ref, "%0A") { + t.Fatalf("unsafe Datadog source reference %q", ref) + } +} + +func TestInstalledIntegrationFilteringFailsClosedWithoutProof(t *testing.T) { + t.Parallel() + spec := endpointSpec{InstalledOnly: true} + items := []map[string]any{ + {"id": "installed", "attributes": map[string]any{"installed": true}}, + {"id": "available", "attributes": map[string]any{"installed": false}}, + {"id": "unknown", "attributes": map[string]any{}}, + } + filtered, complete := filterEndpointItems(items, spec) + if complete || len(filtered) != 1 || stringValue(filtered[0]["id"]) != "installed" { + t.Fatalf("installed integration filter = %#v complete=%t", filtered, complete) + } +} + +func TestNewScanInvalidatesEveryStalePlanArtifact(t *testing.T) { + t.Parallel() + workDir := filepath.Join(t.TempDir(), DefaultWorkDir) + fixture := filepath.Join("..", "..", "testdata", "datadog", "small.json") + now := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) + if _, err := Scan(context.Background(), ScanOptions{WorkDir: workDir, Fixture: fixture, Version: "test", Now: now}); err != nil { + t.Fatal(err) + } + if _, _, err := Plan(PlanOptions{WorkDir: workDir}); err != nil { + t.Fatal(err) + } + if _, err := Scan(context.Background(), ScanOptions{WorkDir: workDir, Fixture: fixture, Version: "test", Now: now.Add(time.Minute)}); err != nil { + t.Fatal(err) + } + for _, stale := range []string{"generated", "plan", "validation", ReportRel, BundleReadmeRel} { + if _, err := os.Lstat(filepath.Join(workDir, filepath.FromSlash(stale))); !os.IsNotExist(err) { + t.Fatalf("new scan left stale %s behind: %v", stale, err) + } + } +} + +func TestExportTargetContainmentResolvesSymlinkedParents(t *testing.T) { + t.Parallel() + root := t.TempDir() + workDir := filepath.Join(root, "state") + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "state-alias") + if err := os.Symlink(workDir, alias); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := safeExportTarget(workDir, filepath.Join(alias, "migration")); err == nil { + t.Fatal("export target inside a symlinked work directory was accepted") + } + if _, err := safeExportTarget(workDir, filepath.Join(root, "migration")); err != nil { + t.Fatalf("safe sibling export target was rejected: %v", err) + } +} + +func assertTreeEqual(t *testing.T, left, right string) { + t.Helper() + leftFiles := readTree(t, left) + rightFiles := readTree(t, right) + if !reflect.DeepEqual(leftFiles, rightFiles) { + t.Fatalf("deterministic workspaces differ\nleft: %#v\nright: %#v", leftFiles, rightFiles) + } +} + +func readTree(t *testing.T, root string) map[string]string { + t.Helper() + files := map[string]string{} + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil || entry.IsDir() { + return walkErr + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + files[filepath.ToSlash(rel)] = string(data) + return nil + }) + if err != nil { + t.Fatal(err) + } + return files +} + +func readTestJSON(t *testing.T, path string, target any) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, target); err != nil { + t.Fatal(err) + } +} + +func verifyChecksumFile(t *testing.T, root string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, BundleChecksumsRel)) + if err != nil { + t.Fatal(err) + } + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + t.Fatalf("invalid checksum line %q", line) + } + digest, _, err := DigestFile(filepath.Join(root, filepath.FromSlash(fields[1]))) + if err != nil { + t.Fatal(err) + } + if digest != fields[0] { + t.Fatalf("checksum mismatch for %s", fields[1]) + } + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/internal/validate/jsonschema.go b/internal/validate/jsonschema.go index b434d14..f025c12 100644 --- a/internal/validate/jsonschema.go +++ b/internal/validate/jsonschema.go @@ -75,7 +75,11 @@ func newSchemaValidator() (*schemaValidator, error) { func schemaFileNames() []string { seen := map[string]struct{}{ - "openexit.validation.schema.json": {}, + "openexit.validation.schema.json": {}, + "openexit.datadog-inventory.schema.json": {}, + "openexit.datadog-plan.schema.json": {}, + "openexit.datadog-validation.schema.json": {}, + "openexit.migration-bundle.schema.json": {}, } for _, manifest := range schemaManifests { seen[manifest.schemaFile] = struct{}{} diff --git a/schemas/openexit.datadog-inventory.schema.json b/schemas/openexit.datadog-inventory.schema.json new file mode 100644 index 0000000..a7b85e2 --- /dev/null +++ b/schemas/openexit.datadog-inventory.schema.json @@ -0,0 +1,88 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://openexit.dev/schemas/openexit.datadog-inventory.schema.json", + "title": "OpenExit Datadog Inventory", + "type": "object", + "additionalProperties": false, + "required": ["apiVersion", "kind", "metadata", "catalog", "resources"], + "properties": { + "apiVersion": { "const": "openexit.dev/v1alpha1" }, + "kind": { "const": "DatadogInventory" }, + "metadata": { + "type": "object", + "additionalProperties": false, + "required": ["source", "site", "collectedAt", "collectorVersion", "snapshotDigest"], + "properties": { + "source": { "const": "datadog" }, + "site": { "type": "string", "minLength": 1 }, + "collectedAt": { "type": "string", "format": "date-time" }, + "collectorVersion": { "type": "string", "minLength": 1 }, + "snapshotDigest": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } + }, + "catalog": { + "type": "object", + "additionalProperties": false, + "required": ["version", "complete", "coverage"], + "properties": { + "version": { "const": "datadog-observability/v1" }, + "complete": { "type": "boolean" }, + "coverage": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["family", "status", "count", "endpoints"], + "properties": { + "family": { "type": "string", "minLength": 1 }, + "status": { "enum": ["complete", "empty", "not_available", "partial", "permission_denied", "error"] }, + "count": { "type": "integer", "minimum": 0 }, + "endpoints": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "status", "count"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "status": { "enum": ["complete", "empty", "not_available", "partial", "permission_denied", "error"] }, + "count": { "type": "integer", "minimum": 0 }, + "message": { "type": "string" } + } + } + }, + "message": { "type": "string" } + } + } + } + } + }, + "resources": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["ref", "kind", "id", "name", "evidence", "spec"], + "properties": { + "ref": { "type": "string", "pattern": "^datadog:[a-z0-9_]+:.+" }, + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "minLength": 1 }, + "sourceUrl": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "dependencies": { "type": "array", "items": { "type": "string" } }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": { "type": "string", "pattern": "^evidence/datadog/" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } + }, + "spec": { "type": "object" } + } + } + } + } +} diff --git a/schemas/openexit.datadog-plan.schema.json b/schemas/openexit.datadog-plan.schema.json new file mode 100644 index 0000000..0c4f390 --- /dev/null +++ b/schemas/openexit.datadog-plan.schema.json @@ -0,0 +1,138 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://openexit.dev/schemas/openexit.datadog-plan.schema.json", + "title": "OpenExit Datadog Migration Plan", + "type": "object", + "additionalProperties": false, + "required": ["apiVersion", "kind", "metadata", "target", "summary", "readiness", "resources"], + "properties": { + "apiVersion": { "const": "openexit.dev/v1alpha1" }, + "kind": { "const": "DatadogMigrationPlan" }, + "target": { "const": "grafana-lgtm" }, + "metadata": { + "type": "object", + "additionalProperties": false, + "required": ["planId", "inventoryDigest", "generatedAt", "rulesetVersion"], + "properties": { + "planId": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "inventoryDigest": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "generatedAt": { "type": "string", "format": "date-time" }, + "rulesetVersion": { "const": "datadog-grafana-lgtm/v1" } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": ["total", "exact", "approximate", "manual", "unsupported", "outputFiles"], + "properties": { + "total": { "type": "integer", "minimum": 0 }, + "exact": { "type": "integer", "minimum": 0 }, + "approximate": { "type": "integer", "minimum": 0 }, + "manual": { "type": "integer", "minimum": 0 }, + "unsupported": { "type": "integer", "minimum": 0 }, + "outputFiles": { "type": "integer", "minimum": 2 } + } + }, + "readiness": { + "type": "object", + "additionalProperties": false, + "required": ["score", "level", "formula", "collection", "translation", "validation", "deductions", "interpretation"], + "properties": { + "score": { "type": "integer", "minimum": 0, "maximum": 100 }, + "level": { "enum": ["low", "medium", "high"] }, + "formula": { "type": "string", "minLength": 1 }, + "collection": { "$ref": "#/definitions/factor" }, + "translation": { "$ref": "#/definitions/factor" }, + "validation": { "$ref": "#/definitions/factor" }, + "deductions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["code", "description", "points"], + "properties": { + "code": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "points": { "type": "integer", "minimum": 0 } + } + } + }, + "interpretation": { "type": "string", "minLength": 1 } + } + }, + "resources": { + "type": "array", + "items": { "$ref": "#/definitions/conversion" } + } + }, + "definitions": { + "factor": { + "type": "object", + "additionalProperties": false, + "required": ["value", "numerator", "denominator", "description"], + "properties": { + "value": { "type": "number", "minimum": 0, "maximum": 1 }, + "numerator": { "type": "integer", "minimum": 0 }, + "denominator": { "type": "integer", "minimum": 0 }, + "description": { "type": "string", "minLength": 1 } + } + }, + "conversion": { + "type": "object", + "additionalProperties": false, + "required": ["sourceRef", "sourceKind", "sourceName", "evidencePath", "status", "reasonCodes", "summary", "outputs"], + "properties": { + "sourceRef": { "type": "string", "pattern": "^datadog:[a-z0-9_]+:.+" }, + "sourceKind": { "type": "string", "minLength": 1 }, + "sourceName": { "type": "string", "minLength": 1 }, + "sourceUrl": { "type": "string" }, + "evidencePath": { "type": "string", "pattern": "^evidence/datadog/" }, + "status": { "enum": ["exact", "approximate", "manual", "unsupported"] }, + "reasonCodes": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "summary": { "type": "string", "minLength": 1 }, + "semanticChanges": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["code", "description", "impact"], + "properties": { + "code": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "impact": { "type": "string", "minLength": 1 } + } + } + }, + "components": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "status"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "kind": { "type": "string", "minLength": 1 }, + "status": { "enum": ["exact", "approximate", "manual", "unsupported"] }, + "reasonCodes": { "type": "array", "uniqueItems": true, "items": { "type": "string" } }, + "sourceQuery": { "type": "string" }, + "targetQuery": { "type": "string" }, + "review": { "type": "string" } + } + } + }, + "outputs": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "kind"], + "properties": { + "path": { "type": "string", "pattern": "^generated/" }, + "kind": { "type": "string", "minLength": 1 } + } + } + } + } + } + } +} diff --git a/schemas/openexit.datadog-validation.schema.json b/schemas/openexit.datadog-validation.schema.json new file mode 100644 index 0000000..4ebf12a --- /dev/null +++ b/schemas/openexit.datadog-validation.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://openexit.dev/schemas/openexit.datadog-validation.schema.json", + "title": "OpenExit Datadog Validation", + "type": "object", + "additionalProperties": false, + "required": ["apiVersion", "kind", "status", "generatedAt", "checks"], + "properties": { + "apiVersion": { "const": "openexit.dev/v1alpha1" }, + "kind": { "const": "DatadogValidation" }, + "status": { "enum": ["passed", "failed"] }, + "generatedAt": { "type": "string", "format": "date-time" }, + "checks": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "status", "critical"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "status": { "enum": ["passed", "failed", "warning"] }, + "message": { "type": "string" }, + "critical": { "type": "boolean" } + } + } + } + } +} diff --git a/schemas/openexit.migration-bundle.schema.json b/schemas/openexit.migration-bundle.schema.json new file mode 100644 index 0000000..c320e41 --- /dev/null +++ b/schemas/openexit.migration-bundle.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://openexit.dev/schemas/openexit.migration-bundle.schema.json", + "title": "OpenExit Migration Bundle", + "type": "object", + "additionalProperties": false, + "required": ["apiVersion", "kind", "planId", "inventoryDigest", "build", "files"], + "properties": { + "apiVersion": { "const": "openexit.dev/v1alpha1" }, + "kind": { "const": "MigrationBundle" }, + "planId": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "inventoryDigest": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "build": { + "type": "object", + "additionalProperties": false, + "required": ["version", "commit", "date"], + "properties": { + "version": { "type": "string", "minLength": 1 }, + "commit": { "type": "string", "minLength": 1 }, + "date": { "type": "string", "minLength": 1 } + } + }, + "files": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "size", "sha256"], + "properties": { + "path": { "type": "string", "pattern": "^[^/].*" }, + "size": { "type": "integer", "minimum": 0 }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "sourceRefs": { "type": "array", "uniqueItems": true, "items": { "type": "string", "pattern": "^datadog:" } } + } + } + } + } +}