diff --git a/.cursor/rules/domain-dependency-direction.mdc b/.cursor/rules/domain-dependency-direction.mdc new file mode 100644 index 0000000..3cf9904 --- /dev/null +++ b/.cursor/rules/domain-dependency-direction.mdc @@ -0,0 +1,15 @@ +--- +description: Keep domain packages independent from adapters and concrete modules +alwaysApply: true +--- + +# Domain Dependency Direction + +Core domain packages must be depended on by adapters, not depend on adapters. + +- Domain packages such as `internal/coverage`, `internal/gate`, `internal/standard`, and `internal/manifest` define stable concepts and invariants. +- Adapter/orchestration packages such as `internal/charge/*`, `cmd/hado/*`, `modules/*`, and `internal/modulerunner` may depend on domain packages. +- Domain packages must not import module runner transport types, CLI packages, concrete module wrappers, provider implementations, or executable names. +- Concrete module/provider registries belong in adapter/orchestration layers, not in domain packages. + +Example: `internal/coverage` may define `Measurement` and coverage validation, but must not know about `modulerunner.RunResult`, `modulerunner.Spec`, `hado-gobce`, or `gobce`. diff --git a/.cursor/rules/hado-implementation-docs.mdc b/.cursor/rules/hado-implementation-docs.mdc index ca45070..f186fdf 100644 --- a/.cursor/rules/hado-implementation-docs.mdc +++ b/.cursor/rules/hado-implementation-docs.mdc @@ -1,25 +1,23 @@ --- description: Keep docs in sync when changing HADO behavior (gates, CLI, coverage, manifest) -globs: cmd/hado/**/*.go,internal/gate/**/*.go,internal/coverage/**/*.go,internal/manifest/**/*.go,internal/standard/**/*.go +globs: cmd/hado/**/*.go,internal/gate/**/*.go,internal/coverage/**/*.go,internal/manifest/**/*.go,internal/standard/**/*.go,internal/charge/**/*.go,modules/**/*.go alwaysApply: false --- # 実装変更とドキュメントの同期 -次を編集したら、**同じ変更セット**でドキュメントを更新する。コードだけマージしない。 +**実装の詳細はコードが真実。** ドキュメントには **仕様(`docs/spec/`)と大きな設計(`docs/design/` 等)**だけを残す。 -1. **[docs/implementation-status.md](docs/implementation-status.md)** - - 実装済みゲート、`hado evaluate` のフラグ・出力形式、coverage adapter 名、MVP との差分など、**実装の真実**をここに反映する。 - - 手書きでよい。更新内容が大きいときは Cursor の Skill **`hado-doc-sync`** を明示的に使ってもよい。 +次を編集したら、**同じ変更セット**で関連ドキュメントを更新する。 -2. **ロードマップの「現状」** - - [docs/roadmap.md](docs/roadmap.md) の「このリポジトリの現状」が古くなっていたら、要約を直す(詳細は `implementation-status.md` に寄せてよい)。 +## 更新の優先順位 -3. **`internal/manifest/types.go` の型を変えた場合** - - [`internal/manifest/field_docs.go`](internal/manifest/field_docs.go) に **YAML パスごとの説明**を追加・更新する(`TestManifestYAMLDocComplete` が不足・余剰キーで失敗する)。 - - [`docs/hado.manifest.reference.yaml`](docs/hado.manifest.reference.yaml) を `make gen-manifest-doc` で再生成して同じ PR に含める。 +1. **ドメイン / 横断仕様** — [docs/spec/](docs/spec/) の該当ファイル(例: Observability → `spec/observability.md`、Coverage → `spec/coverage.md`、CLI → `spec/cli.md`、fire 判定 → `spec/evaluation.md`) +2. **Manifest スキーマ** — `field_docs.go` + **`make gen-manifest-doc`** +3. **横断 doc** — overview / README / **design/** は **リンク修正のみ**(仕様本文をコピペしない)。`design/` は検討ドラフト。確定した振る舞いは `spec/` へ -4. **README / アーキテクチャ** - - 利用者向けの CLI 説明(ルート [README.md](README.md) や [docs/architecture.md](docs/architecture.md))が実装と矛盾していたら合わせる。 +## やらないこと -ゲートを `internal/gate/evaluate.go` に追加したら、**必ず** `implementation-status.md` の実装済みゲート一覧と、必要なら `roadmap.md` の Phase 3 記述を更新する。 +- 複数 doc に同じ仕様段落を貼る + +Skill **`hado-doc-sync`** を参照。 diff --git a/.cursor/rules/internal-go-packages.mdc b/.cursor/rules/internal-go-packages.mdc index 300d948..8711550 100644 --- a/.cursor/rules/internal-go-packages.mdc +++ b/.cursor/rules/internal-go-packages.mdc @@ -32,7 +32,7 @@ Prefer **one main concern per file**. If a file grows large, split on boundaries - `internal/gate` — `types.go`, `evaluate.go` - `internal/standard` — `types.go`, `load.go`, `validate.go` -- `internal/coverage` — `types.go`, `profile.go`, `parse.go`, `specs.go`, `validate.go` +- `internal/coverage` — `types.go`, `metrics.go`, `validate.go`; pure coverage domain model and validation only - `internal/manifest` — `types.go`, `io.go`, `validate.go`, `path.go`, `scaffold.go`, `coverage.go`, `operations.go`, `observability.go`, `infra.go`, `release.go`; reference YAML in `refdoc/` - `internal/manifest/refdoc` — `field_docs.go`, `paths.go`, `generate.go` (`Write` for commented reference YAML) @@ -42,4 +42,4 @@ Prefer **one main concern per file**. If a file grows large, split on boundaries - Repeat the package comment in multiple `.go` files; keep it in `doc.go` only. - Put methods on evidence types in `types.go`; use the matching `.go` file even when the helper is small. -`internal/integration` may stay **doc-only** if it is intentionally a contract marker with no implementation. +Domain packages should stay adapter-free; see `domain-dependency-direction.mdc`. diff --git a/.cursor/skills/hado-doc-sync/SKILL.md b/.cursor/skills/hado-doc-sync/SKILL.md index b98d091..1c73215 100644 --- a/.cursor/skills/hado-doc-sync/SKILL.md +++ b/.cursor/skills/hado-doc-sync/SKILL.md @@ -1,51 +1,39 @@ --- name: hado-doc-sync description: >- - HADO の実装(gates, CLI, coverage adapters, manifest)を変えたあと、 - docs/implementation-status.md と関連ドキュメントを実装に合わせて更新する。 - cmd/hado、internal/gate、internal/coverage、internal/manifest、internal/standard を触ったときに使う。 + HADO の仕様(docs/spec/*)と manifest スキーマを変えたあと、 + field_docs / 参考 YAML を実装に合わせて更新する。 + cmd/hado、internal/gate、internal/charge、internal/manifest、modules を触ったときに使う。 --- -# HADO 実装とドキュメントの同期 +# HADO 仕様とドキュメントの同期 -## いつ使うか +## 方針 -- `cmd/hado`(サブコマンド、フラグ、終了コード、出力形式) -- `internal/gate`(評価する gate id、`Evaluate` の分岐) -- `internal/coverage`(adapter 名、`ParseAdapterInput` の分岐) -- `internal/manifest`(読み込む evidence の形) -- `internal/standard`(gate id 定数やバリデーション) +- **コード** = CLI フラグ、パッケージ、module レジストリの真実 +- **docs/spec/** = 振る舞い・契約の真実(ドメイン + 横断仕様を同一フォルダ) +- **field_docs.go + hado.manifest.reference.yaml** = manifest スキーマの真実 -のいずれかを変更したあと、**同じタスク内**でドキュメントを直す。 +## spec/ の構成 -## 手順 - -1. **真実のソースはコード** - 実装を読み、実際にサポートしている gate id・adapter 名・CLI フラグを確定する。 - -2. **`docs/implementation-status.md` を更新** - - 実装済みゲートの箇条書き(`internal/gate/evaluate.go` の `switch` と一致) - - coverage adapter 一覧(`internal/coverage/parse.go` の `Format*` 定数と一致) - - `hado target` / `charge` / `fire` / `manifest doc` のフラグ・`--output` の取りうる値・終了コードの説明(`cmd/hado` と一致) - - MVP / 未実装として追いたい項目があれば表や箇条書きで維持(ロードマップの [docs/roadmap.md](docs/roadmap.md) と矛盾させない) +| ファイル | 内容 | +| --- | --- | +| `spec/cli.md` | target / charge / fire | +| `spec/evaluation.md` | 終了コード、severity | +| `spec/coverage.md` | coverage gate、manifest、**gobce** | +| `spec/observability.md` | observability gate、Datadog discovery | -3. **`docs/hado.manifest.reference.yaml`** - - `internal/manifest/types.go` または `field_docs.go` を変えたら **`make gen-manifest-doc`** で再生成し、同じ変更セットに含める。 - -4. **`docs/roadmap.md`** - 「このリポジトリの現状」の要約が古ければ 2〜3 文だけ直す。詳細は `implementation-status.md` に任せる。 - -5. **利用者向け** - ルート `README.md` の CLI 例やフラグ説明がずれていたら合わせる。 +## 手順 -6. **push / PR 前** - `docs/` や `README.md` を触れたら `make lint`(少なくとも `make lint-markdown`)を通す。pre-push を使うなら `make setup-hooks`([docs/local-development.md](docs/local-development.md))。 +1. **どの spec か特定**して 1 ファイルだけ更新(executable 名・パスは書かない) +2. **Manifest 型を変えたら** `field_docs.go` + `make gen-manifest-doc` +3. **README / overview 等** — リンクずれの修正のみ +4. **`make lint-markdown`** ## やらないこと -- `make docstatus` のような別コマンドは**ない**。Manifest の参考 YAML(コメント付き)は **`make gen-manifest-doc`**(`hado manifest doc`)で生成し、それ以外は手書き+この Skill。 -- 実装と無関係な長いロードマップの書き換えは、ユーザーが求めた範囲に留める。 +- 削除済みの旧 doc パスに内容を復活させる ## 参照 -- プロジェクトルール: [.cursor/rules/hado-implementation-docs.mdc](.cursor/rules/hado-implementation-docs.mdc) +- [.cursor/rules/hado-implementation-docs.mdc](.cursor/rules/hado-implementation-docs.mdc) diff --git a/Makefile b/Makefile index 1d75fe9..2cace98 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,7 @@ GO_BOOTSTRAP_VERSION ?= 1.22.12 GO_FILES := $(shell git ls-files '*.go' | while read f; do test -f "$$f" && printf '%s\n' "$$f"; done) BINARY := bin/hado HADO_GOBCE_BINARY := bin/hado-gobce +HADO_DATADOG_BINARY := bin/hado-datadog-pup GO_BIN = $(shell $(GO_CMD) env GOPATH 2>/dev/null)/bin GOBCE = $(GO_BIN)/gobce GOBCE_PACKAGE ?= github.com/keyskey/gobce/cmd/gobce@latest @@ -85,6 +86,7 @@ build: ensure-go @mkdir -p bin $(GO_CMD) build -o "$(BINARY)" ./cmd/hado $(GO_CMD) build -o "$(HADO_GOBCE_BINARY)" ./modules/gobce/cmd/hado-gobce + $(GO_CMD) build -o "$(HADO_DATADOG_BINARY)" ./modules/datadog/cmd/hado-datadog-pup gen-manifest-doc: ensure-go @mkdir -p bin @@ -135,6 +137,7 @@ readiness-check: ensure-go $(GO_CMD) test ./... -coverprofile="$(COVERPROFILE)" @mkdir -p bin $(GO_CMD) build -o "$(HADO_GOBCE_BINARY)" ./modules/gobce/cmd/hado-gobce + $(GO_CMD) build -o "$(HADO_DATADOG_BINARY)" ./modules/datadog/cmd/hado-datadog-pup @tmpdir="$$(mktemp -d)"; \ trap 'rm -rf "$$tmpdir"' EXIT; \ cp "$(READINESS_MANIFEST)" "$$tmpdir/hado.yaml"; \ diff --git a/README.md b/README.md index 292256c..56f951c 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,9 @@ HADO という名前は「波動砲」から来ています。波動砲は、日 ## Docs - [Project HADO ドキュメント](docs/README.md) -- [実装状況(手保守; Cursor Skill `hado-doc-sync`)](docs/implementation-status.md) -- [HADO Manifest 参考(型から生成するコメント付き YAML)](docs/hado.manifest.reference.yaml)(`make gen-manifest-doc` で再生成) -- [ローカル開発コマンド](docs/local-development.md) +- [仕様(spec)](docs/spec/) +- [HADO Manifest 参考(型から生成)](docs/hado.manifest.reference.yaml)(`make gen-manifest-doc`) +- [ローカル開発](docs/local-development.md) ## Build and run @@ -35,7 +35,7 @@ make build ./bin/hado ``` -`make build` は HADO CLI に加えて coverage module wrapper の `bin/hado-gobce` もビルドします。 +`make build` は HADO CLI に加えて bundled module wrapper の `bin/hado-gobce` と `bin/hado-datadog-pup` もビルドします。 ## Target manifest(service / standard) @@ -54,22 +54,12 @@ TTY で実行すると、現在の manifest の値をデフォルトにしなが `hado` は `target` / `charge` / `fire` の 3 コマンドで運用します。 - `hado target`: service / standard と evidence のひな形を manifest に書く -- `hado charge`: coverage module を実行して `evidence.coverage.metrics` を manifest に書き戻す。任意で **`--datadog-discover`** により Datadog Monitor を API から特定し **`evidence.observability.monitor.refs`** に `discovery_type: auto` を upsert する(`DD_API_KEY` / `DD_APP_KEY`、詳細は [docs/observability-readiness.md](docs/observability-readiness.md)) -- `hado fire`: manifest に揃った evidence を Readiness Standard の gate と照合して判定する +- `hado charge`: evidence を module / discovery で充填([spec/](docs/spec/)) +- `hado fire`: manifest を Readiness Standard の gate と照合して判定する `hado fire` の終了コードは `0`(ready)、`1`(blocked)、`2`(error)です。`BLOCKED` のときは CI で扱いやすいように 1 で終了します。 -HADO core は、特定の runtime、tool、SaaS、infrastructure provider の -フォーマットに直接依存しません。Coverage、Operation、Observability、 -Infrastructure、Application、Security などの readiness domain は、 -module が evidence を正規化し、standard の gate が判定します。 - -現在の evaluator は、coverage・operations・observability・release(rollback と -自動リリース用 `workflow_refs` の宣言)・infra(deployment 参照)の各 evidence を -Manifest から読み、対応する existence 系 gate を評価できます(詳細は -[docs/implementation-status.md](docs/implementation-status.md))。 -Coverage tool 固有の出力は coverage module が `test.c0_coverage` / `test.c1_coverage` に正規化し、`hado charge` が manifest に保存します。 -`module: gobce` は HADO 同梱の `hado-gobce` wrapper を起動し、内部で `gobce analyze` を実行します。 +HADO core は、特定の runtime や SaaS の出力形式に直接依存しない。各 readiness domain は module が evidence を正規化し、Readiness Standard の gate が判定する。仕様は [docs/spec/](docs/spec/)、Manifest スキーマは [docs/hado.manifest.reference.yaml](docs/hado.manifest.reference.yaml) を参照。 ```bash cat > hado.yaml <<'YAML' @@ -102,4 +92,4 @@ evidence: c1: 68.4 ``` -必要なら `hado charge --coverage-module gobce` で manifest の `evidence.coverage.module` を補助的に指定できます。`--datadog-discover` を付けたときは、`evidence.observability.monitor.discovery.datadog` が必須で、失敗時(0 件・複数件・認証エラーなど)は exit 2 としその時点の manifest ファイルは更新されません。 +Coverage module は manifest の `evidence.coverage.module` で宣言します([spec/coverage.md](docs/spec/coverage.md))。 diff --git a/cmd/hado/charge/datadog.go b/cmd/hado/charge/datadog.go deleted file mode 100644 index 8869770..0000000 --- a/cmd/hado/charge/datadog.go +++ /dev/null @@ -1,173 +0,0 @@ -package charge - -import ( - "context" - "fmt" - "net/http" - "os" - "strconv" - "strings" - - "github.com/keyskey/hado/internal/integration/datadog" - "github.com/keyskey/hado/internal/manifest" -) - -// runDatadogDiscover resolves exactly one Datadog monitor and updates observability evidence. -func runDatadogDiscover(ctx context.Context, m *manifest.Manifest, httpClient *http.Client, apiBaseOverride string) error { - spec := discoverySpec(m) - if err := validateDiscoverySpec(spec, m.Service); err != nil { - return err - } - cfg := datadog.Config{ - APIKey: os.Getenv("DD_API_KEY"), - AppKey: os.Getenv("DD_APP_KEY"), - Site: os.Getenv("DD_SITE"), - APIBaseURL: apiBaseOverride, - } - client, err := datadog.NewClient(cfg, httpClient) - if err != nil { - return err - } - mons, err := client.ListMonitors(ctx) - if err != nil { - return err - } - matched, err := filterMonitors(mons, spec, m.Service) - if err != nil { - return err - } - if len(matched) == 0 { - return fmt.Errorf("datadog discover: no monitor matched criteria (monitors from API: %d)", len(mons)) - } - if len(matched) > 1 { - var ids []string - for _, x := range matched { - ids = append(ids, strconv.FormatInt(x.ID, 10)) - } - return fmt.Errorf("datadog discover: ambiguous match: %d monitors (%s); tighten hado_tags, service/env, or name_contains", len(matched), strings.Join(ids, ", ")) - } - - target := matched[0] - url := datadog.MonitorAppURL(client.Site(), target.ID) - if strings.TrimSpace(url) == "" { - return fmt.Errorf("datadog discover: empty monitor URL") - } - if m.Evidence.Observability == nil { - m.Evidence.Observability = &manifest.ObservabilityEvidence{} - } - obs := m.Evidence.Observability - if obs.Monitor == nil { - obs.Monitor = &manifest.ObservabilityLaneEvidence{} - } - upsertAutoRef(obs.Monitor, target.Name, url) - return nil -} - -func discoverySpec(m *manifest.Manifest) *manifest.DatadogDiscovery { - if m == nil || m.Evidence.Observability == nil || m.Evidence.Observability.Monitor == nil || - m.Evidence.Observability.Monitor.Discovery == nil { - return nil - } - return m.Evidence.Observability.Monitor.Discovery.Datadog -} - -func validateDiscoverySpec(d *manifest.DatadogDiscovery, svc manifest.Service) error { - if d == nil { - return fmt.Errorf("datadog discover: evidence.observability.monitor.discovery.datadog is required") - } - hasHado := false - for _, t := range d.HadoTags { - if strings.TrimSpace(t) != "" { - hasHado = true - break - } - } - hasSE := strings.TrimSpace(d.Service) != "" || strings.TrimSpace(d.Env) != "" || - strings.TrimSpace(svc.Name) != "" - hasName := strings.TrimSpace(d.NameContains) != "" - if !hasHado && !hasSE && !hasName { - return fmt.Errorf("datadog discover: set at least one of hado_tags, service/env (or manifest service.name), or name_contains") - } - return nil -} - -func filterMonitors(monitors []datadog.Monitor, spec *manifest.DatadogDiscovery, svc manifest.Service) ([]datadog.Monitor, error) { - var out []datadog.Monitor - for _, m := range monitors { - if monitorMatches(m, spec, svc) { - out = append(out, m) - } - } - return out, nil -} - -func monitorMatches(m datadog.Monitor, spec *manifest.DatadogDiscovery, svc manifest.Service) bool { - hasHado := false - var hadoReq []string - for _, t := range spec.HadoTags { - if s := strings.TrimSpace(t); s != "" { - hadoReq = append(hadoReq, s) - hasHado = true - } - } - if hasHado { - return tagsContainAll(m.Tags, hadoReq) - } - - serviceVal := strings.TrimSpace(spec.Service) - if serviceVal == "" { - serviceVal = strings.TrimSpace(svc.Name) - } - envVal := strings.TrimSpace(spec.Env) - if serviceVal != "" || envVal != "" { - var req []string - if serviceVal != "" { - req = append(req, "service:"+serviceVal) - } - if envVal != "" { - req = append(req, "env:"+envVal) - } - return tagsContainAll(m.Tags, req) - } - - sub := strings.TrimSpace(spec.NameContains) - if sub != "" { - return strings.Contains(strings.ToLower(m.Name), strings.ToLower(sub)) - } - return false -} - -func tagsContainAll(monitorTags, required []string) bool { - if len(required) == 0 { - return false - } - set := make(map[string]struct{}, len(monitorTags)) - for _, t := range monitorTags { - set[strings.ToLower(strings.TrimSpace(t))] = struct{}{} - } - for _, r := range required { - key := strings.ToLower(strings.TrimSpace(r)) - if key == "" { - continue - } - if _, ok := set[key]; !ok { - return false - } - } - return true -} - -func upsertAutoRef(lane *manifest.ObservabilityLaneEvidence, displayName, url string) { - var kept []manifest.ObservabilityRef - for _, r := range lane.Refs { - if !strings.EqualFold(strings.TrimSpace(r.DiscoveryType), manifest.ObservabilityDiscoveryAuto) { - kept = append(kept, r) - } - } - kept = append(kept, manifest.ObservabilityRef{ - Name: displayName, - URL: url, - DiscoveryType: manifest.ObservabilityDiscoveryAuto, - }) - lane.Refs = kept -} diff --git a/cmd/hado/charge/datadog_discover_test.go b/cmd/hado/charge/datadog_discover_test.go deleted file mode 100644 index 1a60b8a..0000000 --- a/cmd/hado/charge/datadog_discover_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package charge - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - - "github.com/keyskey/hado/internal/manifest" -) - -func TestRunDatadogDiscover_hadoTagSingleMatch(t *testing.T) { - t.Setenv("DD_API_KEY", "api") - t.Setenv("DD_APP_KEY", "app") - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[ - {"id":1,"name":"other","type":"query alert","query":"","tags":["env:prod"]}, - {"id":42,"name":"mine","type":"query alert","query":"","tags":["env:prod","hado:srv"]} - ]`)) - })) - defer ts.Close() - - m := &manifest.Manifest{ - Evidence: manifest.Evidence{ - Observability: &manifest.ObservabilityEvidence{ - Monitor: &manifest.ObservabilityLaneEvidence{ - Refs: []manifest.ObservabilityRef{ - {Name: "hand", URL: "https://manual.example/m", DiscoveryType: manifest.ObservabilityDiscoveryManual}, - }, - Discovery: &manifest.ObservabilityDiscovery{ - Datadog: &manifest.DatadogDiscovery{ - HadoTags: []string{"hado:srv"}, - }, - }, - }, - }, - }, - } - if err := runDatadogDiscover(context.Background(), m, ts.Client(), ts.URL); err != nil { - t.Fatal(err) - } - mon := m.Evidence.Observability.Monitor - if len(mon.Refs) != 2 { - t.Fatalf("refs = %#v", mon.Refs) - } - var auto *manifest.ObservabilityRef - for i := range mon.Refs { - if mon.Refs[i].DiscoveryType == manifest.ObservabilityDiscoveryAuto { - auto = &mon.Refs[i] - break - } - } - if auto == nil || auto.URL != "https://app.datadoghq.com/monitors/42" || auto.Name != "mine" { - t.Fatalf("auto ref = %#v", auto) - } -} - -func TestRunDatadogDiscover_ambiguous(t *testing.T) { - t.Setenv("DD_API_KEY", "api") - t.Setenv("DD_APP_KEY", "app") - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[ - {"id":1,"name":"a","type":"query alert","query":"","tags":["hado:x"]}, - {"id":2,"name":"b","type":"query alert","query":"","tags":["hado:x"]} - ]`)) - })) - defer ts.Close() - - m := &manifest.Manifest{ - Evidence: manifest.Evidence{ - Observability: &manifest.ObservabilityEvidence{ - Monitor: &manifest.ObservabilityLaneEvidence{ - Discovery: &manifest.ObservabilityDiscovery{ - Datadog: &manifest.DatadogDiscovery{ - HadoTags: []string{"hado:x"}, - }, - }, - }, - }, - }, - } - if err := runDatadogDiscover(context.Background(), m, ts.Client(), ts.URL); err == nil { - t.Fatal("want error") - } -} - -func TestRunDatadogDiscover_serviceFromManifestName(t *testing.T) { - t.Setenv("DD_API_KEY", "api") - t.Setenv("DD_APP_KEY", "app") - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[{"id":99,"name":"svc","type":"query alert","query":"","tags":["service:orders","env:prod"]}]`)) - })) - defer ts.Close() - - m := &manifest.Manifest{ - Service: manifest.Service{Name: "orders"}, - Evidence: manifest.Evidence{ - Observability: &manifest.ObservabilityEvidence{ - Monitor: &manifest.ObservabilityLaneEvidence{ - Discovery: &manifest.ObservabilityDiscovery{ - Datadog: &manifest.DatadogDiscovery{ - Env: "prod", - }, - }, - }, - }, - }, - } - if err := runDatadogDiscover(context.Background(), m, ts.Client(), ts.URL); err != nil { - t.Fatal(err) - } - mon := m.Evidence.Observability.Monitor - if len(mon.Refs) != 1 || mon.Refs[0].DiscoveryType != manifest.ObservabilityDiscoveryAuto { - t.Fatalf("monitor.refs = %#v", mon.Refs) - } - if mon.Refs[0].URL != "https://app.datadoghq.com/monitors/99" { - t.Fatalf("url = %q", mon.Refs[0].URL) - } -} diff --git a/cmd/hado/charge/run.go b/cmd/hado/charge/run.go index 60d390b..c980576 100644 --- a/cmd/hado/charge/run.go +++ b/cmd/hado/charge/run.go @@ -5,161 +5,92 @@ import ( "flag" "fmt" "io" - "net/http" "path/filepath" - "time" + "github.com/keyskey/hado/internal/charge" + coveragecharge "github.com/keyskey/hado/internal/charge/coverage" + observabilitycharge "github.com/keyskey/hado/internal/charge/observability" "github.com/keyskey/hado/internal/manifest" - "github.com/keyskey/hado/internal/modulerunner" "github.com/keyskey/hado/internal/standard" ) func Run(args []string, stdout, stderr io.Writer) (int, error) { - fs := flag.NewFlagSet("charge", flag.ContinueOnError) - fs.SetOutput(stderr) - manifestPath := fs.String("manifest", "", "path to HADO manifest YAML") - standardRef := fs.String("standard", "", "Readiness Standard id or path (optional; defaults to manifest standard.id)") - standardsDir := fs.String("standards-dir", "", "directory containing .yaml standards (default: /standards)") - coverageModule := fs.String("coverage-module", "", "coverage module to write to evidence.coverage.module before running charge (example: gobce)") - datadogDiscover := fs.Bool("datadog-discover", false, "resolve one Datadog monitor via API and write monitor.refs (discovery_type auto; requires DD_API_KEY, DD_APP_KEY, evidence.observability.monitor.discovery.datadog)") - if err := fs.Parse(args); err != nil { - return 2, err - } - if *manifestPath == "" { - return 2, fmt.Errorf("charge requires --manifest") - } - - m, err := manifest.LoadOrEmpty(*manifestPath) + opts, err := parseOptions(args, stderr) if err != nil { return 2, err } - stdDir := *standardsDir - if stdDir == "" { - stdDir = filepath.Join(filepath.Dir(*manifestPath), "standards") - } - standardPath, err := manifest.ResolveStandardPath(m, *manifestPath, stdDir, *standardRef) + m, st, err := loadInputs(opts) if err != nil { return 2, err } - st, err := standard.Load(standardPath) + + summary, err := charge.Execute(context.Background(), &m, st, []charge.Charger{ + coveragecharge.NewCharger(), + observabilitycharge.NewCharger(), + }) if err != nil { return 2, err } - - if *coverageModule != "" { - if m.Evidence.Coverage == nil { - m.Evidence.Coverage = &manifest.CoverageEvidence{} - } - m.Evidence.Coverage.Module = *coverageModule - } - - if *datadogDiscover { - if err := runDatadogDiscover(context.Background(), &m, http.DefaultClient, ""); err != nil { - return 2, err - } - } - - measuredAt := "" - if requiresCoverage(st) { - module := m.CoverageModule() - if module == "" { - return 2, fmt.Errorf("charge requires evidence.coverage.module for coverage gates") - } - result, err := runCoverageModule(context.Background(), module, m) - if err != nil { - return 2, err - } - metrics, err := coverageMetricsFromModuleResult(result) - if err != nil { - return 2, err - } - measuredAt = time.Now().UTC().Format(time.RFC3339) - if m.Evidence.Coverage == nil { - m.Evidence.Coverage = &manifest.CoverageEvidence{} - } - m.Evidence.Coverage.Module = module - m.Evidence.Coverage.MeasuredAt = measuredAt - m.Evidence.Coverage.Metrics = metrics - } - if err := m.Save(*manifestPath); err != nil { + if err := m.Save(opts.manifestPath); err != nil { return 2, err } - if measuredAt != "" { - fmt.Fprintf(stdout, "Wrote manifest %s (coverage module: %s, measured_at: %s)\n", *manifestPath, m.CoverageModule(), measuredAt) - } else { - fmt.Fprintf(stdout, "Wrote manifest %s\n", *manifestPath) - } + writeSummary(stdout, opts.manifestPath, summary) return 0, nil } -func requiresCoverage(st standard.Standard) bool { - return st.RequiresGate(standard.C0CoverageGateID) || st.RequiresGate(standard.C1CoverageGateID) +type options struct { + manifestPath string + standardRef string + standardsDir string } -func runCoverageModule(ctx context.Context, module string, m manifest.Manifest) (modulerunner.RunResult, error) { - spec, err := coverageModuleSpec(module) - if err != nil { - return modulerunner.RunResult{}, err +func parseOptions(args []string, stderr io.Writer) (options, error) { + fs := flag.NewFlagSet("charge", flag.ContinueOnError) + fs.SetOutput(stderr) + manifestPath := fs.String("manifest", "", "path to HADO manifest YAML") + standardRef := fs.String("standard", "", "Readiness Standard id or path (optional; defaults to manifest standard.id)") + standardsDir := fs.String("standards-dir", "", "directory containing .yaml standards (default: /standards)") + if err := fs.Parse(args); err != nil { + return options{}, err } - return modulerunner.Run(ctx, spec, modulerunner.RunRequest{ - APIVersion: modulerunner.APIVersion, - Operation: "coverage.analyze", - Service: map[string]string{ - "id": m.Service.ID, - "name": m.Service.Name, - }, - Evidence: map[string]any{ - "coverage": map[string]string{"module": module}, - }, - }) -} - -func coverageModuleSpec(module string) (modulerunner.Spec, error) { - switch module { - case "gobce": - return modulerunner.Spec{ - ID: "gobce", - Command: "hado-gobce", - Timeout: 2 * time.Minute, - MaxStdoutBytes: modulerunner.DefaultMaxStdoutBytes, - }, nil - default: - return modulerunner.Spec{}, fmt.Errorf("unsupported coverage module %q", module) + if *manifestPath == "" { + return options{}, fmt.Errorf("charge requires --manifest") } + return options{ + manifestPath: *manifestPath, + standardRef: *standardRef, + standardsDir: *standardsDir, + }, nil } -func coverageMetricsFromModuleResult(result modulerunner.RunResult) (*manifest.CoverageMetrics, error) { - var c0 *float64 - var c1 *float64 - for _, metric := range result.Metrics { - value := metric.Value - switch metric.ID { - case standard.C0CoverageGateID: - c0 = &value - case standard.C1CoverageGateID: - c1 = &value - } - } - metrics := &manifest.CoverageMetrics{C0: c0, C1: c1} - if err := validateCoverageModuleMetrics(metrics); err != nil { - return nil, err +func loadInputs(opts options) (manifest.Manifest, standard.Standard, error) { + m, err := manifest.LoadOrEmpty(opts.manifestPath) + if err != nil { + return manifest.Manifest{}, standard.Standard{}, err } - return metrics, nil -} -func validateCoverageModuleMetrics(metrics *manifest.CoverageMetrics) error { - if metrics.C0 == nil { - return fmt.Errorf("coverage module result missing %s", standard.C0CoverageGateID) + stdDir := opts.standardsDir + if stdDir == "" { + stdDir = filepath.Join(filepath.Dir(opts.manifestPath), "standards") } - if metrics.C1 == nil { - return fmt.Errorf("coverage module result missing %s", standard.C1CoverageGateID) + standardPath, err := manifest.ResolveStandardPath(m, opts.manifestPath, stdDir, opts.standardRef) + if err != nil { + return manifest.Manifest{}, standard.Standard{}, err } - if *metrics.C0 < 0 || *metrics.C0 > 100 { - return fmt.Errorf("coverage module result %s must be between 0 and 100", standard.C0CoverageGateID) + st, err := standard.Load(standardPath) + if err != nil { + return manifest.Manifest{}, standard.Standard{}, err } - if *metrics.C1 < 0 || *metrics.C1 > 100 { - return fmt.Errorf("coverage module result %s must be between 0 and 100", standard.C1CoverageGateID) + return m, st, nil +} + +func writeSummary(stdout io.Writer, manifestPath string, summary charge.Summary) { + if summary.Coverage != nil { + fmt.Fprintf(stdout, "Wrote manifest %s (coverage module: %s, measured_at: %s)\n", manifestPath, summary.Coverage.Module, summary.Coverage.MeasuredAt) + } else if summary.ObservabilityRefsUpdated { + fmt.Fprintf(stdout, "Wrote manifest %s (observability refs updated)\n", manifestPath) + } else { + fmt.Fprintf(stdout, "Wrote manifest %s\n", manifestPath) } - return nil } diff --git a/cmd/hado/charge/run_test.go b/cmd/hado/charge/run_test.go index 5b638b3..5895691 100644 --- a/cmd/hado/charge/run_test.go +++ b/cmd/hado/charge/run_test.go @@ -6,7 +6,6 @@ import ( "path/filepath" "strings" "testing" - "time" "github.com/keyskey/hado/internal/manifest" ) @@ -20,62 +19,6 @@ func writeFile(t *testing.T, dir, name, content string) string { return path } -func TestChargeRunsCoverageModuleAndWritesMetrics(t *testing.T) { - dir := t.TempDir() - stdDir := filepath.Join(dir, "standards") - if err := os.MkdirAll(stdDir, 0o755); err != nil { - t.Fatalf("mkdir standards: %v", err) - } - writeFile(t, stdDir, "standard.yaml", `id: test -gates: - - id: test.c0_coverage - severity: critical - required: true - threshold: - min: 70 -`) - installFakeGobce(t, dir, `{"apiVersion":"hado.dev/v1","module":{"id":"hado.gobce"},"metrics":[{"id":"test.c0_coverage","value":82.1,"unit":"percent"},{"id":"test.c1_coverage","value":68.4,"unit":"percent"}]}`) - manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 -standard: - id: standard.yaml -evidence: - coverage: - module: gobce -`) - - var stdout bytes.Buffer - var stderr bytes.Buffer - exitCode, err := Run([]string{ - "--manifest", manifestPath, - }, &stdout, &stderr) - if err != nil { - t.Fatalf("run charge: %v", err) - } - if exitCode != 0 { - t.Fatalf("exit code = %d, want 0", exitCode) - } - - m, err := manifest.Load(manifestPath) - if err != nil { - t.Fatalf("load manifest: %v", err) - } - if m.Evidence.Coverage == nil || m.Evidence.Coverage.Module != "gobce" { - t.Fatalf("coverage = %#v, want gobce module", m.Evidence.Coverage) - } - if m.Evidence.Coverage.Metrics == nil || m.Evidence.Coverage.Metrics.C0 == nil || *m.Evidence.Coverage.Metrics.C0 != 82.1 { - t.Fatalf("coverage metrics = %#v, want c0 82.1", m.Evidence.Coverage.Metrics) - } - if m.Evidence.Coverage.Metrics.C1 == nil || *m.Evidence.Coverage.Metrics.C1 != 68.4 { - t.Fatalf("coverage metrics = %#v, want c1 68.4", m.Evidence.Coverage.Metrics) - } - if _, err := time.Parse(time.RFC3339, m.Evidence.Coverage.MeasuredAt); err != nil { - t.Fatalf("measured_at = %q, want RFC3339: %v", m.Evidence.Coverage.MeasuredAt, err) - } - if !strings.Contains(stdout.String(), "coverage module: gobce") { - t.Fatalf("stdout = %q, want coverage module summary", stdout.String()) - } -} - func TestChargeRequiresManifest(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -122,7 +65,7 @@ evidence: } } -func TestChargeCoverageModuleOverrideWritesManifest(t *testing.T) { +func TestChargeRunsCoverageModuleFromManifest(t *testing.T) { dir := t.TempDir() stdDir := filepath.Join(dir, "standards") if err := os.MkdirAll(stdDir, 0o755); err != nil { @@ -140,15 +83,14 @@ gates: manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 standard: id: standard.yaml -evidence: {} +evidence: + coverage: + module: gobce `) var stdout bytes.Buffer var stderr bytes.Buffer - exitCode, err := Run([]string{ - "--manifest", manifestPath, - "--coverage-module", "gobce", - }, &stdout, &stderr) + exitCode, err := Run([]string{"--manifest", manifestPath}, &stdout, &stderr) if err != nil { t.Fatalf("run charge: %v", err) } @@ -162,40 +104,12 @@ evidence: {} if m.Evidence.Coverage == nil || m.Evidence.Coverage.Module != "gobce" { t.Fatalf("coverage = %#v, want gobce", m.Evidence.Coverage) } -} - -func TestChargeRequiresCoverageModuleForCoverageGates(t *testing.T) { - dir := t.TempDir() - stdDir := filepath.Join(dir, "standards") - if err := os.MkdirAll(stdDir, 0o755); err != nil { - t.Fatalf("mkdir standards: %v", err) - } - writeFile(t, stdDir, "standard.yaml", `id: test -gates: - - id: test.c0_coverage - severity: critical - required: true - threshold: - min: 70 -`) - manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 -standard: - id: standard.yaml -evidence: {} -`) - - var stdout bytes.Buffer - var stderr bytes.Buffer - exitCode, err := Run([]string{"--manifest", manifestPath}, &stdout, &stderr) - if err == nil { - t.Fatal("run charge error = nil, want module error") - } - if exitCode != 2 { - t.Fatalf("exit code = %d, want 2", exitCode) + if !strings.Contains(stdout.String(), "coverage module: gobce") { + t.Fatalf("stdout = %q, want coverage summary", stdout.String()) } } -func TestChargeDoesNotSaveManifestWhenModuleFails(t *testing.T) { +func TestChargeDoesNotSaveManifestWhenPipelineFails(t *testing.T) { dir := t.TempDir() stdDir := filepath.Join(dir, "standards") if err := os.MkdirAll(stdDir, 0o755); err != nil { @@ -209,13 +123,10 @@ gates: threshold: min: 70 `) - installFailingGobce(t, dir) manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 standard: id: standard.yaml -evidence: - coverage: - module: gobce +evidence: {} `) before, err := os.ReadFile(manifestPath) if err != nil { @@ -226,7 +137,7 @@ evidence: var stderr bytes.Buffer exitCode, err := Run([]string{"--manifest", manifestPath}, &stdout, &stderr) if err == nil { - t.Fatal("run charge error = nil, want module error") + t.Fatal("run charge error = nil, want pipeline error") } if exitCode != 2 { t.Fatalf("exit code = %d, want 2", exitCode) @@ -240,34 +151,12 @@ evidence: } } -func TestChargeFailsForInvalidModuleResult(t *testing.T) { - dir := t.TempDir() - stdDir := filepath.Join(dir, "standards") - if err := os.MkdirAll(stdDir, 0o755); err != nil { - t.Fatalf("mkdir standards: %v", err) - } - writeFile(t, stdDir, "standard.yaml", `id: test -gates: - - id: test.c0_coverage - severity: critical - required: true - threshold: - min: 70 -`) - installFakeGobce(t, dir, `{"apiVersion":"hado.dev/v1","metrics":[{"id":"test.c1_coverage","value":68.4}]}`) - manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 -standard: - id: standard.yaml -evidence: - coverage: - module: gobce -`) - +func TestChargeCoverageModuleFlagIsRemoved(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - exitCode, err := Run([]string{"--manifest", manifestPath}, &stdout, &stderr) + exitCode, err := Run([]string{"--manifest", "hado.yaml", "--coverage-module", "gobce"}, &stdout, &stderr) if err == nil { - t.Fatal("run charge error = nil, want missing metric error") + t.Fatal("run charge error = nil, want flag parse error") } if exitCode != 2 { t.Fatalf("exit code = %d, want 2", exitCode) @@ -286,28 +175,26 @@ func TestChargeCoverageInputFlagIsRemoved(t *testing.T) { } } -func installFakeGobce(t *testing.T, dir, stdout string) { - t.Helper() - binDir := filepath.Join(dir, "bin") - if err := os.MkdirAll(binDir, 0o755); err != nil { - t.Fatalf("mkdir bin: %v", err) +func TestChargeDatadogDiscoverFlagIsRemoved(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode, err := Run([]string{"--manifest", "hado.yaml", "--datadog-discover"}, &stdout, &stderr) + if err == nil { + t.Fatal("run charge error = nil, want flag parse error") } - path := filepath.Join(binDir, "hado-gobce") - content := "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' '" + stdout + "'\n" - if err := os.WriteFile(path, []byte(content), 0o755); err != nil { - t.Fatalf("write fake gobce: %v", err) + if exitCode != 2 { + t.Fatalf("exit code = %d, want 2", exitCode) } - t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) } -func installFailingGobce(t *testing.T, dir string) { +func installFakeGobce(t *testing.T, dir, stdout string) { t.Helper() binDir := filepath.Join(dir, "bin") if err := os.MkdirAll(binDir, 0o755); err != nil { t.Fatalf("mkdir bin: %v", err) } path := filepath.Join(binDir, "hado-gobce") - content := "#!/bin/sh\necho module failed >&2\nexit 3\n" + content := "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' '" + stdout + "'\n" if err := os.WriteFile(path, []byte(content), 0o755); err != nil { t.Fatalf("write fake gobce: %v", err) } diff --git a/docs/README.md b/docs/README.md index e7d18b3..af2116e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,50 +2,31 @@ Project HADO は、サービスを本番環境へリリースする前に「本番で生き残れる状態か」を判定するための、オープンソースの Production Readiness プラットフォームです。 -初期ドキュメントは日本語で管理します。公開範囲やコントリビューション方針が固まった段階で、英語ドキュメントへの移行を検討します。 - -## ドキュメント一覧 - -1. [プロジェクト概要](overview.md) - - Project HADO が目指すゴール、コンセプト、プロダクト原則、用語、non-goals など、最もハイレベルな前提をまとめる。 - -1. [アーキテクチャ](architecture.md) - - HADO 本体の論理アーキテクチャ、技術選定、**CLI の 3 段階(`target` / `charge` / `fire`)**、開発者体験、HADO Manifest、Readiness Standard、Module / Plugin アーキテクチャ、およびこのリポジトリの `internal` パッケージ構成をまとめる。 - -1. [Production Readiness の計測と評価](production-readiness-evaluation.md) - - Production Readiness をどのカテゴリ・gate・evidence・decision として評価するかをまとめる。 - -1. [開発計画とロードマップ](roadmap.md) - - MVP、開発ステップ、First Implementation Bias、フェーズごとの成果物をまとめる。 - -1. [実装状況](implementation-status.md) - - コードとの対応を手で保守する。実装変更時は Cursor Rule `hado-implementation-docs` と Skill `hado-doc-sync` に従い更新する。 - -1. [HADO Manifest 参考 YAML(コメント付き; `make gen-manifest-doc`)](hado.manifest.reference.yaml) - - `internal/manifest` の型と `field_docs.go` から生成する。手編集しない。 - -1. [Go C1 カバレッジ計測ツール](gobce.md) - - 最初から別リポジトリとして開発する `gobce` の目的、スコープ、HADO 連携方針をまとめる。 - -1. [Observability readiness と manifest](observability-readiness.md) - - 観測可能性の証跡・Linker 方針、`evidence.observability` の拡張、`hado charge --datadog-discover` の要件。 - -1. [Infrastructure Readiness とマニフェスト設計](infrastructure-readiness-and-manifest-design.md) - - 本体を薄く保ちつつ、Readiness Standard を組織・サービスタイプ・実行基盤・Tier ごとに細かく定義できるようにし、HADO Manifest のスキーマを安定させるための設計指針(腐敗防止層、Standard の分割・合成、Manifest の形)をまとめる。 - -1. [未解決課題](open-design-decisions.md) - - Open Design Decisions、未解決の命名・設計・実装方針をまとめる。 - -1. [ローカル開発コマンド](local-development.md) - - `Makefile` で提供している `lint` / `format` / `test` 系コマンドと事前準備をまとめる。 +## 読み方 + +| 種類 | ドキュメント | +| --- | --- | +| ビジョン・CLI 思想 | [overview.md](overview.md) | +| **仕様**(振る舞い・契約) | **[spec/](spec/)** | +| **設計**(検討・ドラフト) | **[design/](design/)** | +| Manifest スキーマ(生成) | [hado.manifest.reference.yaml](hado.manifest.reference.yaml) | +| ロードマップ | [roadmap.md](roadmap.md) | +| ローカル開発 | [local-development.md](local-development.md) | + +**実装の詳細(フラグ、パッケージ、module 名)はコードが真実。** 仕様変更時は [spec/](spec/) と `field_docs.go` を更新する(Skill `hado-doc-sync`)。 + +## 設計(design) + +- [索引](design/README.md) +- [論理アーキテクチャ](design/architecture.md) +- [未決事項](design/open-design-decisions.md) +- [Production Readiness の計測と評価](design/production-readiness-evaluation.md) +- [Infra readiness と manifest 安定性](design/infrastructure-readiness-and-manifest-design.md) + +## 仕様(spec) + +- [索引](spec/README.md) +- [CLI ワークフロー](spec/cli.md) +- [評価(終了コード / severity)](spec/evaluation.md) +- [Coverage(gobce 含む)](spec/coverage.md) +- [Observability(Datadog discovery)](spec/observability.md) diff --git a/docs/design/README.md b/docs/design/README.md new file mode 100644 index 0000000..1cbfae4 --- /dev/null +++ b/docs/design/README.md @@ -0,0 +1,12 @@ +# 設計(design) + +**仕様([spec/](../spec/))ではない**資料を置く。設計ドラフト、拡張方針、トレードオフ、未実装領域の検討など。 + +| ファイル | 内容 | +| --- | --- | +| [architecture.md](architecture.md) | 論理コンポーネント、CLI 3 段階、module 契約 | +| [production-readiness-evaluation.md](production-readiness-evaluation.md) | 評価カテゴリ・gate 展開・score / report の設計検討 | +| [open-design-decisions.md](open-design-decisions.md) | 未決事項・Open Design Decisions | +| [infrastructure-readiness-and-manifest-design.md](infrastructure-readiness-and-manifest-design.md) | Infra readiness と manifest 安定性の設計指針 | + +確定した振る舞いは **spec/** に移す。 diff --git a/docs/architecture.md b/docs/design/architecture.md similarity index 89% rename from docs/architecture.md rename to docs/design/architecture.md index fc3f51f..3f0321a 100644 --- a/docs/architecture.md +++ b/docs/design/architecture.md @@ -95,15 +95,14 @@ HADO 本体リポジトリは、root に `go.mod` を置く単一 Go module と ├── internal │ ├── manifest # HADO Manifest の読み込み・standard パス解決・evidence スキャフォールド │ ├── standard # Readiness Standard の読み込みと検証 -│ ├── coverage # HADO 内部の正規化 coverage metric +│ ├── charge # charge 時の readiness domain charger pipeline +│ ├── coverage # coverage domain の正規化 model / validation │ ├── modulerunner # 外部 module の JSON-over-stdio 実行 │ ├── gate # gate 評価と最終 decision -│ ├── integration # 外部 SaaS API の薄いラッパ。manifest や gate ID を import しない -│ │ └── datadog # Datadog Monitor 一覧など。公式 datadog-api-client-go を利用 │ ├── modules │ ├── gobce # HADO 同梱の gobce wrapper module(modules/gobce/cmd/hado-gobce) -│ ├── datadog +│ ├── datadog # HADO 同梱の pup wrapper module(modules/datadog/cmd/hado-datadog-pup) │ └── notion │ ├── standards @@ -120,13 +119,13 @@ HADO 本体リポジトリは、root に `go.mod` を置く単一 Go module と このツリーは、このリポジトリで **いま実在する** `internal` パッケージを反映している。将来、スコア計算やレポート生成、評価オーケストレーションを独立パッケージに切り出す場合は、その時点の責務に合わせて `internal` 配下に追加する。 -`gobce` 本体はこのリポジトリに同居させない。HADO 側には薄い `hado-gobce` wrapper module だけを置き、別リポジトリの `gobce` CLI 実行結果を module execution contract に変換して取り込む。 +`gobce` 本体や Datadog discovery の `pup` 本体はこのリポジトリに同居させない。HADO 側には薄い `hado-gobce` / `hado-datadog-pup` wrapper module だけを置き、外部 CLI の結果を module execution contract に変換して取り込む。 ## CLI の 3 段階(target → charge → fire) -HADO の由来である **波動砲のオペレーション**(照準 → 艦のリソースを集めてからの一撃に向けた準備 → 発射の可否判断)を、リリース準備に対応づけた利用モデルである。**実装の正本は常に HADO Manifest** とし、作品固有の用語や話数に依存した説明は [概要](overview.md) のように **責務ベースの語**で書く。 +HADO の由来である **波動砲のオペレーション**(照準 → 艦のリソースを集めてからの一撃に向けた準備 → 発射の可否判断)を、リリース準備に対応づけた利用モデルである。**実装の正本は常に HADO Manifest** とし、作品固有の用語や話数に依存した説明は [概要](../overview.md) のように **責務ベースの語**で書く。 -評価を **「対象と基準の確定 → 証跡の収集・manifest への反映 → gate 判定」** の 3 段階に分ける。データの流れの詳細は [概要](overview.md) の該当節を参照する。 +評価を **「対象と基準の確定 → 証跡の収集・manifest への反映 → gate 判定」** の 3 段階に分ける。データの流れの詳細は [概要](../overview.md) の該当節を参照する。 **一段ずつの論理フロー(マニフェストを正とする):** @@ -162,7 +161,7 @@ JSON / Markdown / GitHub PR comment / CI exit code を出力(デプロイは **`hado target` と manifest:** `internal/manifest` の **`ResolveStandardPath`** と **`ApplyEvidenceScaffold`** で、解決した Readiness Standard に応じた **evidence のひな形**(空文字のフィールドなど)を manifest にマージする。 -**`hado charge` の責務:** manifest の `evidence.coverage.module` を読み、coverage module を実行して `evidence.coverage.measured_at` と `metrics` を manifest に書き戻す。module failure や invalid result の場合は manifest を保存しない。 +**`hado charge` の責務:** Readiness Standard と manifest に応じ、未充足 evidence を module / discovery で充填し、**更新後 manifest を保存する**。失敗時は manifest を保存しない(詳細は [spec/](../spec/))。 **`hado fire` の責務:** 更新済み manifest と Standard だけを見た **機械的な gate 判定**とレポート出力を行う。coverage module などの外部 process は起動しない。`--manifest` は必須、`--standard` は任意(未指定時は manifest の `standard.id`、指定時は上書き)。 @@ -210,9 +209,9 @@ Report `hado.yaml` は、評価対象サービスが自分自身と evidence の場所を宣言するファイルである。単なる設定ではなく、リリース準備状態の入口になる manifest として扱う。 -**コメント付きの全プロパティ一覧:** [hado.manifest.reference.yaml](hado.manifest.reference.yaml) を `make gen-manifest-doc`(`hado manifest doc`)で再生成する。 +**コメント付きの全プロパティ一覧:** [hado.manifest.reference.yaml](../hado.manifest.reference.yaml) を `make gen-manifest-doc`(`hado manifest doc`)で再生成する。 -**実装済みのトップレベル(v1):** `service`(`id` / `name`)と `standard`(`id`:Readiness Standard の論理 id または標準 YAML へのパス)は `hado target` が書き込める。`evidence` 以下の形はこのリポジトリの [実装状況](implementation-status.md) に従う。 +**実装済みのトップレベル(v1):** `service`(`id` / `name`)と `standard`(`id`)は `hado target` が書き込める。`evidence` の形は [Manifest 参考 YAML](../hado.manifest.reference.yaml) と [spec/](../spec/) を参照。 ```yaml version: v1 @@ -519,7 +518,7 @@ jobs: standard: critical-api ``` -フェーズ分割 CLI を採用したあとの **目標形**([概要](overview.md) の 3 段階)は次のとおり。**CI では manifest が既にリポジトリに揃っていることが普通**なので、`target`(対話)は省略し **charge → fire** だけにすることが多い。初回だけローカルで `target` して `hado.yaml` をコミットする運用を想定する。`fire` は **デプロイをしない**(release gate の意思決定とレポートのみ)。GitHub Check / PR コメントへの投稿は `fire` 側(または reporter module)の責務とする。 +フェーズ分割 CLI を採用したあとの **目標形**([概要](../overview.md) の 3 段階)は次のとおり。**CI では manifest が既にリポジトリに揃っていることが普通**なので、`target`(対話)は省略し **charge → fire** だけにすることが多い。初回だけローカルで `target` して `hado.yaml` をコミットする運用を想定する。`fire` は **デプロイをしない**(release gate の意思決定とレポートのみ)。GitHub Check / PR コメントへの投稿は `fire` 側(または reporter module)の責務とする。 ```yaml - run: hado charge --manifest hado.yaml --standard critical-api diff --git a/docs/infrastructure-readiness-and-manifest-design.md b/docs/design/infrastructure-readiness-and-manifest-design.md similarity index 97% rename from docs/infrastructure-readiness-and-manifest-design.md rename to docs/design/infrastructure-readiness-and-manifest-design.md index a6c96cc..2264a44 100644 --- a/docs/infrastructure-readiness-and-manifest-design.md +++ b/docs/design/infrastructure-readiness-and-manifest-design.md @@ -125,7 +125,7 @@ evidence # 「どの module を使うか」と、charge が書き戻し 3. **Orchestration**(本体の薄い層)が、必要な module を起動し順序づけ、**EvaluationContext**(gate ID → 値)を埋める。 4. **Gate evaluator** が Standard と EvaluationContext だけを見て pass/fail を決める。 -CLI の **target / charge / fire** に写すと、1〜2 は主に **target**(manifest に残す照準・standard 解決・**evidence スキャフォールド**)、3 は **charge**(manifest を読み書きしながら evidence 参照を埋める)、4 は **fire** に相当する。**正本は manifest** とし、`.hado/context.json` のような中間ファイルを必須にしない方針は [概要](overview.md) を参照する。詳細は [アーキテクチャ](architecture.md) の「CLI の 3 段階」を参照する。 +CLI の **target / charge / fire** に写すと、1〜2 は主に **target**(manifest に残す照準・standard 解決・**evidence スキャフォールド**)、3 は **charge**(manifest を読み書きしながら evidence 参照を埋める)、4 は **fire** に相当する。**正本は manifest** とし、`.hado/context.json` のような中間ファイルを必須にしない方針は [概要](../overview.md) を参照する。詳細は [アーキテクチャ](architecture.md) の「CLI の 3 段階」を参照する。 ここで 3 の orchestration は「どの gate にどの module が責任を持つか」の **宣言**(Standard 側の annotation、または別の small registry)で駆動できるようにし、本体に **k8s / ECS の分岐**を書かない。 @@ -148,6 +148,6 @@ CLI の **target / charge / fire** に写すと、1〜2 は主に **target**(m ## 関連ドキュメント - [アーキテクチャ](architecture.md)(論理コンポーネント、module 契約) -- [Observability readiness と manifest](observability-readiness.md)(`evidence.observability` の `refs` / `discovery_type`、Datadog Monitor MVP) +- [Observability 仕様](../spec/observability.md) - [Production Readiness の計測と評価](production-readiness-evaluation.md) - [未解決課題](open-design-decisions.md) diff --git a/docs/open-design-decisions.md b/docs/design/open-design-decisions.md similarity index 97% rename from docs/open-design-decisions.md rename to docs/design/open-design-decisions.md index ebba7e2..19b7afb 100644 --- a/docs/open-design-decisions.md +++ b/docs/design/open-design-decisions.md @@ -234,7 +234,6 @@ HADO Manifest は coverage module を `evidence.coverage.module` で宣言する を manifest に直接書き戻す。 `hado fire`(判定コマンド)は module を実行せず、manifest inline metrics だけを読む。 -`--coverage-module` が指定された場合は manifest の module 指定を補助的に更新してから実行する。 ``` 未決: @@ -282,7 +281,7 @@ Go / gobce 固有で残る未決: ## CLI の 3 段階と未決事項 -[概要](overview.md) で固定した **target → charge → fire** を実装するときの未決はここに追記する。 +[概要](../overview.md) で固定した **target → charge → fire** を実装するときの未決はここに追記する。 **方針(マニフェスト中心):** `target` は対話で manifest に書き戻し、`charge` は既存の manifest メタデータと target の回答から **未充足 evidence を自動補完**する。**評価の正は manifest** とし、`.hado/context.json` のような中間状態ファイルを必須にしない。 diff --git a/docs/production-readiness-evaluation.md b/docs/design/production-readiness-evaluation.md similarity index 90% rename from docs/production-readiness-evaluation.md rename to docs/design/production-readiness-evaluation.md index 9628069..e338de5 100644 --- a/docs/production-readiness-evaluation.md +++ b/docs/design/production-readiness-evaluation.md @@ -79,9 +79,7 @@ HADO の初期 gate は、実際の新サービスリリースでよく発生す ## Observability readiness: 証跡のリンクとゲート評価 -現行の observability 系 gate は **existence** 判定である。各レーン(`slo` / `monitor` / `dashboard`)で **`refs` に trim 後非空の `url`** があれば、対応する gate は満たされる(`discovery_type` は判定に使わない)。 - -**charge と Discovery:** `hado charge --datadog-discover` は Datadog API でモニターを探索し、**1 件に特定**できたときだけ `monitor.refs` に `discovery_type: auto` を upsert する。既存の `manual` refs は保持し、既存の `auto` refs は置き換える。0 件・複数件・API 失敗時は **manifest を保存せず** exit 2(詳細は [Observability readiness と manifest](observability-readiness.md))。 +現行の observability 系 gate は **existence** 判定である。詳細は **[spec/observability.md](../spec/observability.md)** を参照。 Phase 2 以降の **assert**(閾値・SLO 目標・ダッシュボードの必須ウィジェット等)は、Policy language(Readiness Standard の YAML)の拡張として別途扱う([未解決課題](open-design-decisions.md))。 @@ -152,7 +150,7 @@ gates: HADO は module output を Readiness Standard の gate と照合し、最終 decision を返す。 -**CLI フェーズとの対応:** **target** で「どの standard のどの gate に対応するか」を確定し、manifest に **evidence のスキャフォールド**を載せる。**charge** で evidence 参照(coverage artifact の adapter/path など)を不足分マージし、**fire** で照合と decision まで行う。`fire` は manifest を必須入力として判定のみを担当する。詳細は [概要](overview.md) と [アーキテクチャ](architecture.md) を参照する。 +**CLI フェーズとの対応:** **target** で「どの standard のどの gate に対応するか」を確定し、manifest に **evidence のスキャフォールド**を載せる。**charge** で evidence 参照(coverage artifact の adapter/path など)を不足分マージし、**fire** で照合と decision まで行う。`fire` は manifest を必須入力として判定のみを担当する。詳細は [概要](../overview.md) と [アーキテクチャ](architecture.md) を参照する。 ```text ready diff --git a/docs/gobce.md b/docs/gobce.md deleted file mode 100644 index 6ab0e2d..0000000 --- a/docs/gobce.md +++ /dev/null @@ -1,161 +0,0 @@ -# Go C1 カバレッジ計測ツール gobce - -Status: 初期コンセプトノート -Date: 2026-04-25 - -## 位置づけ - -`gobce` は HADO の最初の強い analyzer module である。同時に、HADO 本体とは独立した OSS library / CLI として提供する。 - -`gobce` は Golang Branch Coverage Estimator の略である。Go 標準の coverage 情報をもとに、C1 branch coverage を軽量に推定することを目的にする。 - -重要な方針: - -```text -gobce は最初から別リポジトリとして開発する。 -HADO 本体リポジトリには同居させない。 -HADO とは module execution contract で連携する。 -``` - -## 目的 - -Go 標準の coverage は `go test -coverprofile` による C0/block coverage を提供する。しかし、Production Readiness の観点では「分岐がどれだけテストされているか」というより強いシグナルが欲しい。 - -`gobce` は Go の coverprofile と AST / 軽量 CFG 解析を組み合わせ、C1 branch coverage を推定する。 - -```text -go test -coverprofile + Go AST/CFG analysis - -> C1 coverage - -> uncovered branch findings - -> HADO readiness metrics -``` - -## Standalone CLI - -```bash -go test ./... -coverprofile coverage.out - -gobce analyze \ - --coverprofile coverage.out \ - --format json -``` - -出力例: - -```json -{ - "language": "go", - "statementCoverage": 82.1, - "estimatedBranchCoverage": 68.4, - "uncoveredBranches": [ - { - "file": "internal/order/validator.go", - "line": 42, - "kind": "if_false_path" - } - ] -} -``` - -## HADO 連携 - -`gobce` は C1 coverage producer の一例である。HADO core は `gobce` 固有の -JSON schema や、その値が推定値か実測値かを解釈しない。HADO 同梱の -`hado-gobce` wrapper module が `gobce analyze` を呼び、Module Runner contract の -RunResult に変換する。 - -`hado-gobce` は HADO analyzer module として RunResult に次を出す。 - -```text -metrics: - test.c0_coverage - test.c1_coverage - -findings: - test.uncovered_branch -``` - -初期 HADO 実装では findings は manifest に保存せず、`test.c0_coverage` / -`test.c1_coverage` だけを `evidence.coverage.metrics.c0` / `metrics.c1` に書き戻す。 - -Readiness Standard 側では、この metric を gate に使う。 - -```yaml -gates: - - id: test.c0_coverage - required: true - threshold: - min: 80 - - - id: test.c1_coverage - required: true - threshold: - min: 70 -``` - -## 初期解析スコープ - -MVP で扱う branch candidate: - -```text -if / else -switch / case / default -type switch -select -for / range の loop body entered vs not entered -&& / || による short-circuit boolean expression -``` - -将来的に扱う branch candidate: - -```text -panic / recover paths -defer-related paths -error-return conventions -table-test branch attribution -generated-code filtering improvements -``` - -## アルゴリズム概要 - -```text -1. Go coverprofile を parse する。 -2. go/parser と go/ast で Go package / file を parse する。 -3. 対応構文から branch candidate span を作る。 -4. coverage block と branch candidate span を対応付ける。 -5. 各 branch side が実行されたかを推定する。 -6. C1 percentage を計算する。 -7. file, line, kind, recommendation 付きの uncovered branch finding を出す。 -``` - -## 重要な制約 - -`gobce` の最初のバージョンでは、結果は実測値ではなく推定値である。Go の coverprofile は branch coverage 専用フォーマットではないため、シグナルの性質を正直に表現する。 - -```text -C0 coverage = measured -gobce C1 coverage = inferred from coverprofile + source analysis -``` - -この正直さは `gobce` の信頼性そのものに関わる。一方、HADO core の -`test.c1_coverage` gate は正規化された C1 coverage value だけを見る。 - -## 別リポジトリにする理由 - -`gobce` を最初から別リポジトリにする理由: - -- HADO 本体と独立して使える Go coverage tool にするため -- HADO 以外の CI / quality gate からも使えるようにするため -- Go の解析ロジックと HADO の policy / report ロジックを混ぜないため -- リリースサイクルとバージョニングを HADO 本体から分離するため -- 将来的に他言語版 analyzer を追加する時の参照実装にするため - -想定: - -```text -repository: gobce -outputs: - - library - - CLI - - HADO analyzer module mode -``` diff --git a/docs/hado.manifest.reference.yaml b/docs/hado.manifest.reference.yaml index 80851da..4e74e62 100644 --- a/docs/hado.manifest.reference.yaml +++ b/docs/hado.manifest.reference.yaml @@ -40,18 +40,16 @@ evidence: slo: # このレーンの外部プロバイダ名(任意。例: `datadog`)。 (論理型: string) provider: "datadog" - # charge がこのレーンで資源探索するときの条件(1 パターン)。MVP の Datadog 自動解決は monitor レーンのみ実装。 (論理型: object) + # charge がこのレーンで資源探索するときの条件(1 パターン)。`observability.slo_exists` gate と `discovery.datadog` があると SLO 一覧を同期する。 (論理型: object) discovery: - # Datadog API 向け探索条件(フィールドは Monitor 由来; SLO 自動解決は未実装でも manifest に先行記述可)。 (論理型: object) + # Datadog SLO 一覧に対する絞り込み条件(monitor レーンと同型)。 (論理型: object) datadog: - # `hado:*` タグ。すべて一致するモニター等に絞る(最優先)。 (論理型: array of string) + # 指定タグを **すべて** 持つ SLO にマッチ(AND)。 (論理型: array of string) hado_tags: [] - # `service:…` タグの値(未指定時は manifest `service.name` を利用しうる)。 (論理型: string) - service: "orders" - # `env:…` タグの値。 (論理型: string) + # Datadog `service:` タグの値。**いずれか 1 つ** にマッチ(OR)。 (論理型: array of string) + services: [] + # `env:` タグの値。指定時は必須一致。 (論理型: string) env: "prod" - # 名前の部分一致(フォールバック)。 (論理型: string) - name_contains: "error rate" # 証跡 URL の一覧。`discovery_type` で手入力(`manual`)と charge 自動解決(`auto`)を区別。 (論理型: array of object) refs: # 表示名(任意)。 (論理型: string) @@ -60,22 +58,20 @@ evidence: url: "https://app.datadoghq.com/slo?slo_id=example" # `manual` または `auto`。 (論理型: string) discovery_type: "manual" - # Monitor レーン。配列省略形 = `refs`(`discovery_type` 省略時 `manual`)。`hado charge --datadog-discover` は **このレーンの** `discovery.datadog` を読み、`refs` に `discovery_type: auto` を書く。 (論理型: object) + # Monitor レーン。配列省略形 = `refs`(`discovery_type` 省略時 `manual`)。`hado charge` は `observability.monitor_exists` gate と `discovery.datadog` があると Datadog module を実行し、`refs` に `discovery_type: auto` を書く。 (論理型: object) monitor: # このレーンの外部プロバイダ名(任意。例: `datadog`)。 (論理型: string) provider: "datadog" - # Monitor 探索の条件(1 パターン)。`--datadog-discover` 時に必須。 (論理型: object) + # Monitor 探索の条件(1 パターン)。`hado charge` が必要な Datadog module を自動実行するときに使う。 (論理型: object) discovery: - # `GET /api/v1/monitor` で列挙した一覧に対する絞り込み条件。 (論理型: object) + # `pup datadog monitor list` の結果に対する絞り込み条件。マッチした **すべて** を `refs` に同期する。 (論理型: object) datadog: - # `hado:*` タグすべて一致(最優先)。 (論理型: array of string) + # 指定タグを **すべて** 持つ monitor にマッチ(AND)。 (論理型: array of string) hado_tags: [] - # `service:…`(未指定時 manifest `service.name` 可)。 (論理型: string) - service: "orders" - # `env:…`。 (論理型: string) + # Datadog `service:` タグの値。**いずれか 1 つ** にマッチ(OR)。 (論理型: array of string) + services: [] + # `env:` タグの値。指定時は必須一致。 (論理型: string) env: "prod" - # モニター名部分一致。 (論理型: string) - name_contains: "error rate" # 証跡 URL の一覧。 (論理型: array of object) refs: # 表示名(任意)。 (論理型: string) @@ -92,14 +88,12 @@ evidence: discovery: # Datadog 向け探索条件プレースホルダ(フィールド形は共通)。 (論理型: object) datadog: - # 将来の discovery 用。 (論理型: array of string) + # 将来の discovery 用(monitor / slo と同型)。 (論理型: array of string) hado_tags: [] - # 同上。 (論理型: string) - service: "orders" + # 同上。 (論理型: array of string) + services: [] # 同上。 (論理型: string) env: "prod" - # 同上。 (論理型: string) - name_contains: "error rate" # 証跡 URL の一覧。 (論理型: array of object) refs: # 表示名(任意)。 (論理型: string) diff --git a/docs/implementation-status.md b/docs/implementation-status.md deleted file mode 100644 index 8c67019..0000000 --- a/docs/implementation-status.md +++ /dev/null @@ -1,117 +0,0 @@ -# 実装状況 - -**手書きで保守する。** 実装を変えたら Cursor の Rule(`.cursor/rules/hado-implementation-docs.mdc`)と Skill(`hado-doc-sync`)に従い、**同じ PR / セッションで**このファイルを更新する。 - -## CLI(`cmd/hado`) - -| コマンド | 状態 | -| --- | --- | -| 引数なし | 一行ヘルプ | -| `version` / `-v` / `--version` | 実装済み | -| `target` | 実装済み(`--manifest` 必須。TTY / フラグで `service` / `standard`。既定で resolved standard に応じ **evidence のスキャフォールド**(空文字のキーなど)をマージ) | -| `charge` | 実装済み(`--manifest` 必須。coverage gate がある場合は `evidence.coverage.module` の module を実行し、`measured_at` と `metrics` を manifest に書き戻す。**`--datadog-discover`** で Datadog Monitor を探索し **`evidence.observability.monitor.refs`** に `discovery_type: auto` を upsert) | -| `fire` | 実装済み(`--manifest` 必須。外部 process は起動せず、manifest の inline evidence を gate 評価して READY/BLOCKED/ERROR を返す) | -| `manifest doc` | 実装済み(`hado manifest doc [--out path]`。コメント付き参考 manifest を stdout またはファイルへ出力。YAML パスは `types.go` からリフレクションで生成、説明は `field_docs.go`。リポジトリ既定の保存先は [hado.manifest.reference.yaml](hado.manifest.reference.yaml)。`make gen-manifest-doc`) | - -`evaluate` は廃止し、`target` / `charge` / `fire` / `manifest doc` で CLI を構成する。 - -`target` の主なフラグ(`cmd/hado/target/run.go` の `Run`): - -- `--manifest`(必須) -- `--service-name`(任意; 非 TTY では既存 manifest かフラグのどちらかが必要) -- `--service-id`(任意; 空のときは `service-name` と同じにできる) -- `--standard-id`(任意; 非 TTY では既存 manifest かフラグのどちらかが必要) -- `--standards-dir`(任意; スキャフォールド用の standard YAML を探すディレクトリ。既定は manifest と同じ階層の `standards/`) -- `--rewrite-placeholders`(既定 `true`。`false` で service/standard のみ更新し evidence は触らない) - -`fire` は manifest の該当フィールドが **未設定** のとき、existence gate では失敗とみなします。Observability は各レーン(`slo` / `monitor` / `dashboard`)について **`refs` に trim 後非空の `url` を持つ要素が無い** 場合に未設定です。 - -`charge` の主なフラグ(`cmd/hado/charge/run.go`): - -- `--manifest`(必須) -- `--standard`(任意。未指定時は manifest の `standard.id` を利用) -- `--coverage-module`(任意。指定時は `evidence.coverage.module` に書き、その module を実行) -- `--datadog-discover`(`evidence.observability.monitor.discovery.datadog` に従って Datadog Monitor を探索。`DD_API_KEY` / `DD_APP_KEY` 必須、任意 `DD_SITE`。0 件・複数件・API 失敗時は exit 2 で **manifest を書き換えない**) - -`fire` の主なフラグ(`cmd/hado/fire/run.go`): - -- `--manifest`(必須) -- `--standard`(任意。未指定時は manifest の `standard.id` を利用、指定時は上書き) -- `--output`:`text` または `json`(それ以外はエラー) - -`hado manifest doc` の主なフラグ(`cmd/hado/manifestcmd/run.go`): - -- `--out`(任意。未指定時は標準出力へ書き込む) - -`--output text` は各 gate の判定行に `severity` を表示し、FAIL 行には「リリース前に必須対応か / リリース後対応可か」の運用ヒントを併記する。総合判定(`HADO: READY/BLOCKED/ERROR`)は一覧の最後に出力する。TTY では ANSI カラーを付与し、`PASS` は緑、`FAIL` は赤/黄(required+critical の FAIL を最強調)で表示する(`NO_COLOR` が設定されている場合は無効)。 - -**Coverage 入力の必須条件:** Readiness Standard が `test.c0_coverage` または `test.c1_coverage` のいずれかを含む場合、`charge` には manifest の `evidence.coverage.module`(または `--coverage-module`)が必要。`fire` は `evidence.coverage.metrics` が無いとエラー終了(exit 2)。 - -終了コード: `0` = ready、`1` = blocked(`required: true` かつ `severity: critical` の gate が失敗)、`2` = error(引数・読み込み・未対応 gate など)。 - -**未実装の例:** `--output markdown`、score / exception フィールド。 - -## 実装済みゲート(`internal/gate/evaluate.go` の `switch` 順) - -required として宣言されているが、ここに無い gate id は **error**(`unsupported required gate`)になる。optional の未知 gate は無視。 - -- `test.c0_coverage`(`internal/standard` の `C0CoverageGateID`) -- `test.c1_coverage` -- `operations.owner_exists` -- `operations.runbook_exists` -- `observability.slo_exists`(`evidence.observability.slo.refs` に trim 後非空の `url`) -- `observability.monitor_exists`(`evidence.observability.monitor.refs` について同上) -- `observability.dashboard_exists`(`evidence.observability.dashboard.refs` について同上) -- `infra.deployment_spec_exists`(`evidence.infra.deployment_spec` が非空; パス・URL・カタログ ID などの参照文字列として扱う) -- `release.rollback_plan_exists`(`evidence.release.rollback_plan` が非空) -- `release.automation_declared`(`evidence.release.automation.workflow_refs` に **空白以外**の文字列が **1 件以上**。`systems` は任意のメタデータで現ゲートでは未使用) - -### Gate severity(現状) - -`severity` は `internal/standard/types.go` の独自型 `Severity` として扱い、次の 3 値へ制限する(未知値は `standard.Load` でエラー): - -- `critical` -- `major` -- `minor` - -`required: true` の gate が失敗した場合の扱い: - -- `critical`: **blocked**(原則リリース不可) -- `major`: ready のまま(リリースは可能だが、リリース後の早期対応を要求する運用を想定) -- `minor`: ready のまま(リリース可能。リリース後の任意タイミングでの対応を想定) - -`severity` 未指定は `minor` と同等に扱う(非ブロック)。 - -## Module runner / Coverage module - -`internal/modulerunner` は外部 executable に RunRequest JSON を stdin で渡し、RunResult JSON を stdout から読む。timeout、stdout size limit、stderr diagnostics、non-zero exit、invalid JSON、context cancel を識別する。 - -現在の coverage module mapping: - -- `evidence.coverage.module: gobce` → `hado-gobce` - -`hado-gobce` は HADO 同梱の wrapper module executable。`gobce analyze --coverprofile --format json --output ` を呼び、gobce JSON を Module Runner の RunResult に変換する。`HADO_COVERPROFILE` があればその path を使い、未指定時は `coverage.out` を読む。 - -module result の `test.c0_coverage` / `test.c1_coverage` は `evidence.coverage.metrics.c0` / `metrics.c1` に保存される。module identity / version は初期 manifest には保存しない。 - -## Manifest(`internal/manifest`) - -Manifest の **全プロパティが列挙された参考 YAML**(各キーにコメントで説明・論理型)は [hado.manifest.reference.yaml](hado.manifest.reference.yaml)(`make gen-manifest-doc` / `hado manifest doc`)。`types.go` を変えたら `internal/manifest/refdoc/field_docs.go` を更新し **再生成後**に `internal/manifest/refdoc` の次のテストが通ること: **`TestYAMLDocComplete`**(`field_docs` ↔ 型)、**`TestWriteReferenceYAML_loads`**(生成物が `Load` 相当でパース可能)、**`TestCommittedReferenceYAMLMatchesGenerator`**(コミット済み YAML と生成器の完全一致)。 - -- `evidence.operations`(owner, runbook) -- `evidence.observability`(`slo` / `monitor` / `dashboard` を役割別レーンとして分割。各レーンに `refs`(`discovery_type: manual|auto`)・任意 `provider`・`discovery`。**YAML 配列だけ**の書き方は各レーンの `refs` 省略形として解釈される。詳細は [Observability readiness と manifest](observability-readiness.md)。) -- `evidence.infra`(`deployment_spec`) -- `evidence.release`(`rollback_plan`; `automation.workflow_refs`, 任意で `automation.systems`) -- `evidence.coverage`(`module`, `measured_at`, `metrics.c0`, `metrics.c1`) - -## 外部連携(`internal/integration`) - -- `internal/integration/datadog` … Datadog Monitor 一覧は公式 [`datadog-api-client-go`](https://github.com/DataDog/datadog-api-client-go) を利用する薄いラッパ。manifest / gate を import しない。 - -## MVP・ロードマップとの差(メモ) - -計画全体は [roadmap.md](roadmap.md)。コードにまだ無い例: - -- インフラ向け threshold 型 gate(例: PDB の数値比較)、Markdown レポート、GitHub PR 連携 -- Datadog の SLO / Dashboard 自動解決、Rollbar / PagerDuty 等の他ベンダー charge -- `test.uncovered_branch` など gobce findings の評価結果への載せ方 diff --git a/docs/observability-readiness.md b/docs/observability-readiness.md deleted file mode 100644 index 71e18b1..0000000 --- a/docs/observability-readiness.md +++ /dev/null @@ -1,110 +0,0 @@ -# Observability readiness と manifest - -Status: 実装同期(Datadog Monitor MVP) -Date: 2026-05-05 - -## 目的 - -Observability readiness は、本番で **異常を検知し、影響を追える**ための証跡が揃っているかを扱う。HADO は特定ベンダーに縛られない一方、**証跡は manifest 上でリンク可能**であることを優先する([概要](overview.md#production-readiness-linker))。 - -キメラ構成の例(役割ごとにツールが違ってよい): - -```text -APM: Datadog -Error: Rollbar -Logs: Grafana / Cloud Logging -On-call: PagerDuty -Runbook: GitHub / Notion -``` - -現行の gate は `observability.slo_exists` / `observability.monitor_exists` / `observability.dashboard_exists` の **existence** 判定である。 - -## Manifest: `evidence.observability` - -`observability` は **信号ごとのレーン**に分割する。 - -| キー | 用途 | -| --- | --- | -| `slo` | SLO / SLI レーン。`observability.slo_exists`。 | -| `monitor` | アラート / モニター。`observability.monitor_exists`。`hado charge --datadog-discover` は **このレーンだけ**を自動更新する(下記)。 | -| `dashboard` | ダッシュボード。`observability.dashboard_exists`。 | - -構造化ログ・PII マスキング等の PRR チェックは **Compliance / Security** 領域で扱う予定。`evidence.observability` に `log` レーンは置かない。 - -### 各レーンの形(`ObservabilityLaneEvidence`) - -各レーンは **次のいずれかの YAML 形**として書ける。 - -1. **ショートカット**: 配列だけ。これは **`refs` の列**とみなし、各要素の `discovery_type` は省略時 **`manual`**。 - - ```yaml - slo: - - name: api availability - url: https://app.datadoghq.com/slo?slo_id=a - ``` - -2. **フル(推奨)**: オブジェクトで、証跡 URL・プロバイダ・探索条件をまとめる。 - - ```yaml - slo: - provider: datadog - refs: - - name: Availability SLO - url: https://app.datadoghq.com/slo?slo_id=a - discovery_type: manual - - name: Latency SLO - url: https://app.datadoghq.com/slo?slo_id=b - discovery_type: manual - monitor: - provider: datadog - refs: - - name: latency - url: https://app.datadoghq.com/monitors/1 - discovery_type: manual - - name: prod error rate - url: https://app.datadoghq.com/monitors/42 - discovery_type: auto - discovery: - datadog: - hado_tags: ["hado:srv"] - ``` - -| フィールド | 用途 | -| --- | --- | -| `refs` | **証跡 URL の一覧**。手入力と charge 自動解決を同じ列に載せる。 | -| `refs[].name` | 表示名(任意)。 | -| `refs[].url` | ブラウザで開ける URL。 | -| `refs[].discovery_type` | `manual`(手入力)または `auto`(`hado charge` 等の自動解決)。 | -| `provider` | そのレーンの外部プロバイダ名(任意。例: `datadog`)。 | -| `discovery` | **そのレーン用に 1 パターン**。charge が外部 API で探索するときの条件。MVP で実装しているのは **`monitor.discovery.datadog`** のみ。 | - -ゲート判定は各レーンで **`refs` に trim 後非空の `url` があるか**(`discovery_type` は判定に使わない)。 - -### `monitor.discovery.datadog` と `hado charge --datadog-discover` - -`--datadog-discover` 指定時は **`evidence.observability.monitor.discovery.datadog`** が必須。次の **いずれか**を満たす必要がある。 - -- `hado_tags`: すべてのタグがモニターに含まれる(最優先) -- `service` / `env`: Datadog の `service:` / `env:` タグで絞る。`service` が空なら `manifest.service.name` をフォールバック -- `name_contains`: モニター名の部分一致(上記が未指定のときのフォールバック) - -**ちょうど 1 件**にマッチしなければ charge は失敗する(0 件・複数件は exit 2)。API キーは **環境変数**のみ(manifest に書かない)。 - -- `DD_API_KEY`、`DD_APP_KEY`(必須) -- `DD_SITE`(任意、既定は `datadoghq.com`) - -成功時は **`monitor.refs` に `discovery_type: auto` の要素を upsert** する。既存の `manual` refs は保持し、既存の `auto` refs は置き換える(MVP では auto は 1 件)。 - -## 実装の置き場 - -| 場所 | 責務 | -| --- | --- | -| `internal/integration/datadog` | Datadog 公式 Go クライアント経由の汎用ラッパ(manifest に依存しない) | -| `cmd/hado/charge` | manifest の `monitor.discovery` を解釈し、フィルタ・ambiguous チェック・`monitor.refs`(`discovery_type: auto`)の更新 | - -## 関連ドキュメント - -- [HADO Manifest 参考 YAML(全プロパティ・コメント付き)](hado.manifest.reference.yaml) -- [実装状況](implementation-status.md) -- [Production Readiness の計測と評価](production-readiness-evaluation.md) -- [Infrastructure Readiness とマニフェスト設計](infrastructure-readiness-and-manifest-design.md) diff --git a/docs/overview.md b/docs/overview.md index 3aa29e5..31771ca 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -17,10 +17,10 @@ HADO は CI、監視基盤、セキュリティスキャナ、コード品質ツ Evidence driven … 設定ファイルだけでなく、SLO・アラート・ダッシュボード・runbook など「辿れる証跡」を扱う Link first … 存在するが URL 等で辿れない状態をreadinessとして扱わない(manifest に解決済みリンクを載せる) Tool-agnostic / キメラ構成 … APM は Datadog、エラーは Rollbar、on-call は PagerDuty、runbook は GitHub / Notion、のようにベンダーが役割ごとに混在してもよい -Discovery = Readiness の一部 … charge が外部 API 等でリソースを特定し、manifest の `refs`(`discovery_type: auto`)に書き戻す(MVP では Datadog Monitor の自動解決を実装) +Discovery = Readiness の一部 … charge が外部 API 等でリソース集合を同期し、manifest の `refs`(`discovery_type: auto`)に書き戻す(Datadog Monitor / SLO を実装) ``` -ゲート評価(`hado fire`)は Readiness Standard で宣言された **gate ID と閾値**に対し、manifest 由来の **正規化済み値**(数値・非空・宣言の有無など)を照合する。観測可能性まわりのスキーマ・Datadog 連携の具体的な書き方は [Observability readiness と manifest](observability-readiness.md) を参照する。 +ゲート評価(`hado fire`)は Readiness Standard で宣言された **gate ID と閾値**に対し、manifest 由来の **正規化済み値**(数値・非空・宣言の有無など)を照合する。Observability の仕様は [spec/observability.md](spec/observability.md) を参照。 ## 最終的に目指すゴール @@ -87,9 +87,7 @@ HADO = 出航前に「撃てる状態」を証明する readiness amplif 解決した Readiness Standard を読み、**その standard が要求するゲートに対応する `evidence` のスキャフォールド**(空の値でキーを並べ、人間・`charge` が後から埋める)を manifest にマージする。初回セットアップや基準の切り替えで使い、**変更は Git の diff としてレビュー**できる形に残す。 2. hado charge … 充填(証跡を集め、manifest を埋める) - **target で回収した値**と、manifest に **すでに書かれているサービスメタデータ**(リポジトリ URL、Datadog の APM service 名や service catalog 参照など)を入力にし、Readiness Standard に照らして **まだ埋まっていない evidence を自動で埋める**。 - 例: `hado charge --datadog-discover` と `DD_API_KEY` / `DD_APP_KEY` で Datadog API から Monitor を特定し、`evidence.observability.monitor.refs` に `discovery_type: auto` を upsert する([Observability readiness と manifest](observability-readiness.md))。 - 外部 module の実行、正規化、CI 内の `go test` 成果物の取り込みなどはここに集約されうる。永続の主たる形は **更新後の manifest** である。 + Readiness Standard と manifest の `discovery` 設定に応じ、**未充足の evidence を module / discovery で埋める**(例: [Observability](spec/observability.md))。永続の主たる形は **更新後の manifest** である。 3. hado fire … 発射判定(gate を評価し、リリース可否だけを返す) 充填済みの manifest(と参照 artifact)と Standard を照合し、gate を評価する。**デプロイはしない**(波動砲の「射撃」に相当する本番反映は既存の CD に任せる)。HADO は **release gate の意思決定**だけを返す(`hado fire` は「この変更を本番に出してよいか」の判定に相当する)。 @@ -98,7 +96,7 @@ HADO = 出航前に「撃てる状態」を証明する readiness amplif **マニフェスト中心の原則:** `.hado/context.json` のような **評価の正本を二重化する中間ファイルを前提にしない**。ログや人間向けサマリが必要なら **任意の副産物**(標準出力、CI artifact、一時ファイル)とし、**評価の入力の正は常に manifest** とする。 -**`standard` の位置づけ:** 単なるルールセットではなく、**そのサービスを本番に出すために満たすべき生存基準**として扱う(Readiness Standard の説明は [アーキテクチャ](architecture.md) および [Production Readiness の計測と評価](production-readiness-evaluation.md) と整合させる)。 +**`standard` の位置づけ:** 単なるルールセットではなく、**そのサービスを本番に出すために満たすべき生存基準**として扱う(Readiness Standard の説明は [アーキテクチャ](design/architecture.md) および [Production Readiness の計測と評価](design/production-readiness-evaluation.md) と整合させる)。 **UX の方針:** @@ -106,9 +104,9 @@ HADO = 出航前に「撃てる状態」を証明する readiness amplif - **フラグでの `target`:** 対話なしで `--manifest` / `--standard` / `--service` だけ更新するモードも用意できる(スクリプト・自動化向け)。 - `--standard` には **短い alias** を許してもよい(例: `exchange-critical` → `ec`)。組織の運用に合わせて定義する。 -**実装との関係:** 現リポジトリは **`hado target` / `hado charge` / `hado fire`** を正とする。`target` は manifest の `service` / `standard` と **standard 由来の evidence スキャフォールド**を書き込み、`charge` は coverage module 実行や discovery で manifest を充填し、`fire` は manifest を入力に gate 判定のみを行う。`fire` は `--standard` 未指定時に manifest の `standard.id` を使い、指定時はそれを上書きする。 +**実装との関係:** 現リポジトリは **`hado target` / `hado charge` / `hado fire`** を正とする。詳細は [アーキテクチャ](design/architecture.md) と [仕様(spec/)](spec/) を参照。 -詳細な責務分担・データの流れは [アーキテクチャ](architecture.md) を参照する。 +詳細な責務分担・データの流れは [アーキテクチャ](design/architecture.md) を参照する。 ## プロダクト原則 diff --git a/docs/roadmap.md b/docs/roadmap.md index 573457a..89487f0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -47,22 +47,15 @@ HADO の gate 展開もこの順序に寄せる。 GitHub Actions 上で、Go service の最低限の Production Readiness を評価できる。 ``` -### このリポジトリの現状(実装済みの範囲) +### このリポジトリの現状(要約) -**実装の詳細は [implementation-status.md](implementation-status.md)(手保守)。** コードを変えたら Cursor の `.cursor/rules/hado-implementation-docs.mdc` と Skill `hado-doc-sync` でドキュメントを同時更新する。 +**実装の詳細はコードが真実。** 仕様は [spec/](spec/) を参照。 -現時点の要約: +- CLI: `hado target` → `hado charge` → `hado fire` +- Manifest + Readiness Standard による gate 判定 +- Coverage / Observability 等 — [spec/](spec/) -- HADO Manifest loader(`internal/manifest`) -- Readiness Standard loader(`internal/standard`) -- module runner と gobce coverage module 実行、C0 / C1 coverage gate(`internal/modulerunner`, `internal/gate`) -- operations / observability / infra / release(rollback と `release.automation_declared`)の existence gate(manifest 由来) -- `hado target`(manifest に `service` / `standard` と **standard に沿った evidence スキャフォールド**; `cmd/hado`) -- `hado charge` / `hado fire`(coverage module による充填と manifest inline metrics の判定を分離した 2 段階。**Datadog Monitor の discovery** は `charge --datadog-discover` と `internal/integration/datadog`) - -**CLI 体系:** `hado target` → `hado charge` → `hado fire`。`charge` は module / discovery で manifest を充填し、`fire` は manifest 入力のみで判定する。 - -ロードマップ上は、閾値ベースの infra gate、Markdown / PR 連携などはこのあとである。 +ロードマップ上は、閾値ベースの infra gate、Markdown / PR 連携、Dashboard 自動 discovery などはこのあとである。 MVP に含めるもの: diff --git a/docs/spec/README.md b/docs/spec/README.md new file mode 100644 index 0000000..c272c5b --- /dev/null +++ b/docs/spec/README.md @@ -0,0 +1,25 @@ +# 仕様(spec) + +HADO の **振る舞い・契約・manifest の使い方**をここに集約する。CLI フラグ、パッケージ名、module executable 名など **実装の詳細はコードが真実**。 + +## 横断仕様 + +| ファイル | 内容 | +| --- | --- | +| [cli.md](cli.md) | `target` / `charge` / `fire` ワークフロー | +| [evaluation.md](evaluation.md) | 終了コード、gate severity、`fire` 判定 | + +## ドメイン仕様 + +| ファイル | 内容 | +| --- | --- | +| [coverage.md](coverage.md) | Coverage readiness、`evidence.coverage`、gobce | +| [observability.md](observability.md) | Observability readiness、Datadog discovery | + +Manifest フィールド定義は [hado.manifest.reference.yaml](../hado.manifest.reference.yaml)(`field_docs.go` から `make gen-manifest-doc`)。 + +## スキーマ変更時 + +1. `internal/manifest/types.go` と `internal/manifest/refdoc/field_docs.go` +2. `make gen-manifest-doc` +3. 振る舞いが変わるドメイン spec を更新 diff --git a/docs/spec/cli.md b/docs/spec/cli.md new file mode 100644 index 0000000..0fb6df0 --- /dev/null +++ b/docs/spec/cli.md @@ -0,0 +1,27 @@ +# CLI ワークフロー + +Status: 仕様 +Date: 2026-05-29 + +## 3 段階 + +```text +hado target → hado charge → hado fire +``` + +| コマンド | 役割 | +| --- | --- | +| `target` | manifest に service / standard と evidence のスキャフォールド | +| `charge` | module / discovery で evidence を充填([ドメイン spec](README.md) 参照) | +| `fire` | manifest を gate 判定(外部 process は起動しない) | +| `manifest doc` | 参考 manifest YAML を生成(`make gen-manifest-doc`) | + +**正本は常に HADO Manifest。** 評価入力の正本を二重化する中間ファイルは前提にしない。 + +## 運用 + +- **初回:** ローカルで `target` して manifest をコミット +- **CI:** manifest が揃っていれば `charge` → `fire` のみでよい +- **`fire` はデプロイしない** — release gate の意思決定のみ + +概念・メタファーは [概要](../overview.md)。論理構成は [アーキテクチャ](../design/architecture.md)。 diff --git a/docs/spec/coverage.md b/docs/spec/coverage.md new file mode 100644 index 0000000..12c60f7 --- /dev/null +++ b/docs/spec/coverage.md @@ -0,0 +1,125 @@ +# Coverage readiness + +Status: 仕様 +Date: 2026-05-29 + +## 目的 + +Coverage readiness は、テストで **どれだけコードパスが実行されたか**を Production Readiness のシグナルとして扱う。HADO core は特定の計測器(gobce / JaCoCo / lcov 等)に依存せず、**正規化済み metrics** だけを gate で評価する。 + +## Gate(threshold) + +| Gate ID | 入力 | 判定 | +| --- | --- | --- | +| `test.c0_coverage` | `evidence.coverage.metrics.c0` | Standard の `threshold.min` 以上 | +| `test.c1_coverage` | `evidence.coverage.metrics.c1` | 同上 | + +Readiness Standard が coverage gate を含む場合: + +- **`charge`** — manifest の `evidence.coverage.module` が必要。module 実行後 `measured_at` と `metrics` を manifest に書く。失敗時は manifest を保存しない。 +- **`fire`** — `evidence.coverage.metrics` が無いと error(exit 2)。 + +## Manifest: `evidence.coverage` + +```yaml +evidence: + coverage: + module: gobce + measured_at: 2026-05-26T14:30:00Z + metrics: + c0: 82.1 + c1: 68.4 +``` + +| フィールド | 用途 | +| --- | --- | +| `module` | charge が呼ぶ coverage module の識別子(例: `gobce`)。manifest で宣言する。 | +| `measured_at` | charge が metrics を書き込んだ時刻(RFC3339)。 | +| `metrics.c0` / `metrics.c1` | 正規化済み coverage 百分比。 | + +フィールド定義は [Manifest 参考 YAML](../hado.manifest.reference.yaml) を参照。 + +## Producer-neutral 契約 + +- Coverage module は各言語・ツールの artifact を **`test.c0_coverage` / `test.c1_coverage` metric** に変換する。 +- HADO core は producer が推定値か実測値かを gate 判定では区別しない(metric 値と threshold のみ)。 +- 将来の findings(例: `test.uncovered_branch`)は別 spec で扱う。 + +--- + +## gobce(外部 C1 producer) + +`gobce`(Golang Branch Coverage Estimator)は **HADO 本体とは別リポジトリ**の OSS tool / library である。HADO とは module execution contract で連携する。 + +### gobce の目的 + +Go 標準の `go test -coverprofile` は C0/block coverage。Production Readiness では分岐カバレッジのシグナルが欲しいため、gobce は coverprofile と AST / 軽量 CFG 解析で **C1 branch coverage を推定**する。 + +```text +go test -coverprofile + Go AST/CFG analysis + -> C1 coverage + -> uncovered branch findings + -> HADO readiness metrics(module 経由) +``` + +### Standalone CLI(gobce 単体) + +```bash +go test ./... -coverprofile coverage.out + +gobce analyze \ + --coverprofile coverage.out \ + --format json +``` + +### HADO との関係 + +- manifest の `evidence.coverage.module: gobce` で gobce 系 module を指定 +- charge が module を実行し、RunResult の `test.c0_coverage` / `test.c1_coverage` を `evidence.coverage.metrics` に保存 +- 初期実装では uncovered branch **findings は manifest に保存しない**(metrics のみ) + +Standard 例: + +```yaml +gates: + - id: test.c0_coverage + required: true + threshold: + min: 80 + - id: test.c1_coverage + required: true + threshold: + min: 70 +``` + +### 推定値であること + +```text +C0 coverage = measured(coverprofile 由来) +gobce C1 coverage = inferred(coverprofile + ソース解析) +``` + +gobce の信頼性はこの区別に依存する。HADO gate は正規化された数値だけを見る。 + +### 解析スコープ(gobce MVP) + +```text +if / else +switch / case / default +type switch +select +for / range(body entered vs not entered) +&& / || の short-circuit +``` + +### 別リポジトリにする理由 + +- HADO 以外の CI / quality gate からも使える独立ツールにする +- Go 解析ロジックと HADO policy / report を分離する +- リリースサイクルを HADO 本体から分離する +- 他言語 analyzer 追加時の参照実装にする + +## 関連 + +- [評価(severity / 終了コード)](evaluation.md) +- [Production Readiness の計測と評価](../design/production-readiness-evaluation.md) diff --git a/docs/spec/evaluation.md b/docs/spec/evaluation.md new file mode 100644 index 0000000..89e8575 --- /dev/null +++ b/docs/spec/evaluation.md @@ -0,0 +1,32 @@ +# 評価(fire / charge) + +Status: 仕様 +Date: 2026-05-29 + +## 終了コード + +| コード | 意味 | +| --- | --- | +| `0` | ready / charge 成功 | +| `1` | blocked(`required: true` かつ `severity: critical` の gate 失敗) | +| `2` | error(引数・読み込み・charge 失敗・未対応 required gate など) | + +## Gate severity + +`severity` は `critical` / `major` / `minor`。`required: true` の gate が失敗した場合: + +| severity | 総合判定 | +| --- | --- | +| `critical` | blocked(原則リリース不可) | +| `major` | ready(リリース後の早期対応を想定) | +| `minor` | ready(任意タイミングのフォローアップを想定) | + +未指定は `minor` 相当。 + +## Existence gate + +Operations / Observability / Infra / Release の多くは **existence** 判定(値・URL・宣言の有無)。ドメインごとの条件は各 spec を参照。 + +## Threshold gate + +Coverage(`test.c0_coverage` / `test.c1_coverage`)は manifest の正規化済み metrics と Standard の threshold を照合する。詳細は [coverage.md](coverage.md)。 diff --git a/docs/spec/observability.md b/docs/spec/observability.md new file mode 100644 index 0000000..ca62f83 --- /dev/null +++ b/docs/spec/observability.md @@ -0,0 +1,109 @@ +# Observability readiness + +Status: 仕様(Datadog Monitor / SLO discovery 含む) +Date: 2026-05-29 + +## 目的 + +Observability readiness は、本番で **異常を検知し、影響を追える**ための証跡が揃っているかを扱う。HADO は特定ベンダーに縛られない一方、**証跡は manifest 上でリンク可能**であることを優先する([概要](../overview.md#production-readiness-linker))。 + +キメラ構成の例(役割ごとにツールが違ってよい): + +```text +APM: Datadog +Error: Rollbar +Logs: Grafana / Cloud Logging +On-call: PagerDuty +Runbook: GitHub / Notion +``` + +Datadog の Monitor / SLO discovery は **Datadog レーン向けの自動同期**に過ぎない。他ベンダーや手入力 `refs` との共存を前提とする。 + +構造化ログ・PII マスキング等の PRR チェックは **Compliance / Security** 領域で扱う予定。`evidence.observability` に `log` レーンは置かない。 + +## Gate(existence) + +| Gate ID | 満たす条件 | +| --- | --- | +| `observability.slo_exists` | `evidence.observability.slo.refs` に trim 後非空の `url` が **1 件以上** | +| `observability.monitor_exists` | `evidence.observability.monitor.refs` について同上 | +| `observability.dashboard_exists` | `evidence.observability.dashboard.refs` について同上 | + +`discovery_type`(`manual` / `auto`)は gate 判定に使わない。 + +## Manifest: `evidence.observability` + +`observability` は **信号ごとのレーン**に分割する。 + +| キー | 用途 | +| --- | --- | +| `slo` | SLO / SLI。`discovery.datadog` があると charge が SLO 集合を同期可能。 | +| `monitor` | アラート / モニター。`discovery.datadog` があると charge が Monitor 集合を同期可能。 | +| `dashboard` | ダッシュボード。自動 discovery は未指定(手入力 `refs`)。 | + +各レーンは **配列だけ**(= `refs` の省略形、`discovery_type` 省略時 `manual`)または **オブジェクト**(`refs` + 任意 `provider` + 任意 `discovery`)として書ける。 + +```yaml +slo: + provider: datadog + discovery: + datadog: + hado_tags: ["hado:orders"] + services: ["orders-api", "orders-worker"] + env: "prod" + refs: + - name: Availability SLO + url: https://app.datadoghq.com/slo?slo_id=a + discovery_type: manual +monitor: + provider: datadog + discovery: + datadog: + hado_tags: ["hado:orders"] + services: ["orders-api", "orders-worker"] + env: "prod" + refs: + - name: prod error rate + url: https://app.datadoghq.com/monitors/42 + discovery_type: auto +``` + +| フィールド | 用途 | +| --- | --- | +| `refs` | 証跡 URL の一覧。手入力(`manual`)と charge 自動解決(`auto`)を同居。 | +| `provider` | そのレーンの外部プロバイダ名(任意。例: `datadog`)。 | +| `discovery.datadog` | charge が Datadog から探索するときの **membership 条件**。 | + +フィールド定義の詳細は [Manifest 参考 YAML](../hado.manifest.reference.yaml) を参照。 + +## Datadog discovery(monitor / SLO) + +Readiness Standard が該当 gate を含み、manifest の当該レーンに **`discovery.datadog`** がある場合、`hado charge` は Datadog からリソース一覧を取得し、条件に合うものを `refs` に同期する。 + +- **monitor レーン** — Datadog Monitor 一覧 +- **slo レーン** — Datadog SLO 一覧(Monitor とは別リソース) + +### `discovery.datadog` の条件 + +`hado_tags` / `services` / `env` の **少なくとも 1 つ** を指定する。指定した次元は **AND** で合成する。 + +| フィールド | 意味 | +| --- | --- | +| `hado_tags` | リソース tags に **すべて** 含まれること | +| `services` | `service:` タグが **いずれか 1 つ** マッチ(OR)。1 Web アプリに複数 service(api / worker 等)がある場合向け。 | +| `env` | `env:` タグが一致 | + +### charge の同期ルール + +- 条件にマッチした **すべて** の monitor / SLO を `refs` に `discovery_type: auto` で書く +- **0 件** → charge 失敗(manifest 未更新) +- **2 件以上** → 成功(複数 auto ref) +- 既存 **manual** refs は保持 +- 既存 **auto** refs は今回マッチした集合で **全置換**(外部から消えたリソースは manifest の auto ref からも消える) + +認証情報は manifest に書かず **実行環境**に置く。Datadog site は URL 生成に反映する(未指定時は `datadoghq.com` 相当)。 + +## 関連 + +- [Production Readiness の計測と評価](../design/production-readiness-evaluation.md) +- [Manifest 参考 YAML](../hado.manifest.reference.yaml) diff --git a/go.mod b/go.mod index b7b000a..807354e 100644 --- a/go.mod +++ b/go.mod @@ -3,18 +3,8 @@ module github.com/keyskey/hado go 1.22 require ( - github.com/DataDog/datadog-api-client-go/v2 v2.54.0 golang.org/x/term v0.27.0 gopkg.in/yaml.v3 v3.0.1 ) -require ( - github.com/DataDog/zstd v1.5.2 // indirect - github.com/goccy/go-json v0.10.2 // indirect - github.com/golang/protobuf v1.5.3 // indirect - golang.org/x/net v0.17.0 // indirect - golang.org/x/oauth2 v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/protobuf v1.31.0 // indirect -) +require golang.org/x/sys v0.28.0 // indirect diff --git a/go.sum b/go.sum index f245a5d..7c925dc 100644 --- a/go.sum +++ b/go.sum @@ -1,37 +1,7 @@ -github.com/DataDog/datadog-api-client-go/v2 v2.54.0 h1:bLSwX1D7JA7hAHxpo8Aa2+d8F2wzD8sNOJszL89yGyU= -github.com/DataDog/datadog-api-client-go/v2 v2.54.0/go.mod h1:d3tOEgUd2kfsr9uuHQdY+nXrWp4uikgTgVCPdKNK30U= -github.com/DataDog/zstd v1.5.2 h1:vUG4lAyuPCXO0TLbXvPv7EB7cNK1QV/luu55UHLrrn8= -github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8= -golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/charge/charge.go b/internal/charge/charge.go new file mode 100644 index 0000000..92d61a2 --- /dev/null +++ b/internal/charge/charge.go @@ -0,0 +1,54 @@ +package charge + +import ( + "context" + + "github.com/keyskey/hado/internal/manifest" + "github.com/keyskey/hado/internal/standard" +) + +// Charger updates manifest evidence for one readiness domain. +type Charger interface { + Charge(context.Context, *manifest.Manifest, standard.Standard) (Change, error) +} + +// Change describes the user-visible updates made by a charger. +type Change struct { + Coverage *CoverageChange + ObservabilityRefsUpdated bool +} + +// CoverageChange describes coverage evidence written by charge. +type CoverageChange struct { + Module string + MeasuredAt string +} + +// Summary aggregates all changes made by the charge pipeline. +type Summary struct { + Coverage *CoverageChange + ObservabilityRefsUpdated bool +} + +// Execute runs domain chargers in order and aggregates their changes. +func Execute(ctx context.Context, m *manifest.Manifest, st standard.Standard, chargers []Charger) (Summary, error) { + var summary Summary + for _, charger := range chargers { + change, err := charger.Charge(ctx, m, st) + if err != nil { + return Summary{}, err + } + summary.Merge(change) + } + return summary, nil +} + +// Merge adds one charger change into the summary. +func (s *Summary) Merge(change Change) { + if change.Coverage != nil { + s.Coverage = change.Coverage + } + if change.ObservabilityRefsUpdated { + s.ObservabilityRefsUpdated = true + } +} diff --git a/internal/charge/charge_test.go b/internal/charge/charge_test.go new file mode 100644 index 0000000..6d1cce7 --- /dev/null +++ b/internal/charge/charge_test.go @@ -0,0 +1,34 @@ +package charge + +import ( + "context" + "testing" + + "github.com/keyskey/hado/internal/manifest" + "github.com/keyskey/hado/internal/standard" +) + +type fakeCharger struct { + change Change + err error +} + +func (f fakeCharger) Charge(context.Context, *manifest.Manifest, standard.Standard) (Change, error) { + return f.change, f.err +} + +func TestExecuteAggregatesChanges(t *testing.T) { + summary, err := Execute(context.Background(), &manifest.Manifest{}, standard.Standard{}, []Charger{ + fakeCharger{change: Change{Coverage: &CoverageChange{Module: "gobce", MeasuredAt: "2026-05-28T00:00:00Z"}}}, + fakeCharger{change: Change{ObservabilityRefsUpdated: true}}, + }) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if summary.Coverage == nil || summary.Coverage.Module != "gobce" { + t.Fatalf("coverage summary = %#v", summary.Coverage) + } + if !summary.ObservabilityRefsUpdated { + t.Fatal("ObservabilityRefsUpdated = false, want true") + } +} diff --git a/internal/charge/coverage/coverage.go b/internal/charge/coverage/coverage.go new file mode 100644 index 0000000..f64015b --- /dev/null +++ b/internal/charge/coverage/coverage.go @@ -0,0 +1,118 @@ +package coverage + +import ( + "context" + "fmt" + "time" + + "github.com/keyskey/hado/internal/charge" + coveragedomain "github.com/keyskey/hado/internal/coverage" + "github.com/keyskey/hado/internal/manifest" + "github.com/keyskey/hado/internal/modulerunner" + "github.com/keyskey/hado/internal/standard" +) + +const analyzeOperation = "coverage.analyze" + +// Charger collects coverage evidence when the loaded standard requires coverage gates. +type Charger struct { + now func() time.Time +} + +// NewCharger returns a coverage domain charger. +func NewCharger() Charger { + return Charger{ + now: func() time.Time { return time.Now().UTC() }, + } +} + +// Charge runs the configured coverage module and writes inline coverage metrics. +func (c Charger) Charge(ctx context.Context, m *manifest.Manifest, st standard.Standard) (charge.Change, error) { + if !requiresCoverage(st) { + return charge.Change{}, nil + } + + module := m.CoverageModule() + if module == "" { + return charge.Change{}, fmt.Errorf("charge requires evidence.coverage.module for coverage gates") + } + result, err := runModule(ctx, module, *m) + if err != nil { + return charge.Change{}, err + } + measurement, err := measurementFromRunResult(result) + if err != nil { + return charge.Change{}, err + } + + measuredAt := c.now().Format(time.RFC3339) + if m.Evidence.Coverage == nil { + m.Evidence.Coverage = &manifest.CoverageEvidence{} + } + m.Evidence.Coverage.Module = module + m.Evidence.Coverage.MeasuredAt = measuredAt + m.Evidence.Coverage.Metrics = &manifest.CoverageMetrics{ + C0: measurement.C0, + C1: measurement.C1, + } + + return charge.Change{ + Coverage: &charge.CoverageChange{ + Module: module, + MeasuredAt: measuredAt, + }, + }, nil +} + +func requiresCoverage(st standard.Standard) bool { + return st.RequiresGate(standard.C0CoverageGateID) || st.RequiresGate(standard.C1CoverageGateID) +} + +func runModule(ctx context.Context, module string, m manifest.Manifest) (modulerunner.RunResult, error) { + spec, err := moduleSpec(module) + if err != nil { + return modulerunner.RunResult{}, err + } + return modulerunner.Run(ctx, spec, modulerunner.RunRequest{ + APIVersion: modulerunner.APIVersion, + Operation: analyzeOperation, + Service: map[string]string{ + "id": m.Service.ID, + "name": m.Service.Name, + }, + Evidence: map[string]any{ + "coverage": map[string]string{"module": module}, + }, + }) +} + +func moduleSpec(module string) (modulerunner.Spec, error) { + switch module { + case "gobce": + return modulerunner.Spec{ + ID: "gobce", + Command: "hado-gobce", + Timeout: 2 * time.Minute, + MaxStdoutBytes: modulerunner.DefaultMaxStdoutBytes, + }, nil + default: + return modulerunner.Spec{}, fmt.Errorf("unsupported coverage module %q", module) + } +} + +func measurementFromRunResult(result modulerunner.RunResult) (coveragedomain.Measurement, error) { + var measurement coveragedomain.Measurement + for _, metric := range result.Metrics { + value := metric.Value + switch metric.ID { + case standard.C0CoverageGateID: + measurement.C0 = &value + case standard.C1CoverageGateID: + measurement.C1 = &value + } + } + if err := measurement.ValidateComplete(); err != nil { + return coveragedomain.Measurement{}, fmt.Errorf("coverage module result: %w", err) + } + return measurement, nil +} diff --git a/internal/charge/coverage/coverage_test.go b/internal/charge/coverage/coverage_test.go new file mode 100644 index 0000000..08c9bc2 --- /dev/null +++ b/internal/charge/coverage/coverage_test.go @@ -0,0 +1,81 @@ +package coverage + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/keyskey/hado/internal/manifest" + "github.com/keyskey/hado/internal/standard" +) + +func TestChargeRunsModuleAndWritesMetrics(t *testing.T) { + installFakeGobce(t, `{"apiVersion":"hado.dev/v1","module":{"id":"hado.gobce"},"metrics":[{"id":"test.c0_coverage","value":82.1,"unit":"percent"},{"id":"test.c1_coverage","value":68.4,"unit":"percent"}]}`) + measuredAt := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + ch := NewCharger() + ch.now = func() time.Time { return measuredAt } + m := manifest.Manifest{ + Evidence: manifest.Evidence{ + Coverage: &manifest.CoverageEvidence{Module: "gobce"}, + }, + } + + change, err := ch.Charge(context.Background(), &m, coverageStandard()) + if err != nil { + t.Fatalf("Charge() error = %v", err) + } + if change.Coverage == nil || change.Coverage.Module != "gobce" || change.Coverage.MeasuredAt != measuredAt.Format(time.RFC3339) { + t.Fatalf("coverage change = %#v", change.Coverage) + } + if m.Evidence.Coverage.Metrics == nil || m.Evidence.Coverage.Metrics.C0 == nil || *m.Evidence.Coverage.Metrics.C0 != 82.1 { + t.Fatalf("coverage metrics = %#v, want c0 82.1", m.Evidence.Coverage.Metrics) + } + if m.Evidence.Coverage.Metrics.C1 == nil || *m.Evidence.Coverage.Metrics.C1 != 68.4 { + t.Fatalf("coverage metrics = %#v, want c1 68.4", m.Evidence.Coverage.Metrics) + } +} + +func TestChargeRequiresModuleForCoverageGates(t *testing.T) { + _, err := NewCharger().Charge(context.Background(), &manifest.Manifest{}, coverageStandard()) + if err == nil { + t.Fatal("Charge() error = nil, want missing module error") + } +} + +func TestChargeFailsForInvalidModuleResult(t *testing.T) { + installFakeGobce(t, `{"apiVersion":"hado.dev/v1","metrics":[{"id":"test.c1_coverage","value":68.4}]}`) + m := manifest.Manifest{ + Evidence: manifest.Evidence{ + Coverage: &manifest.CoverageEvidence{Module: "gobce"}, + }, + } + + if _, err := NewCharger().Charge(context.Background(), &m, coverageStandard()); err == nil { + t.Fatal("Charge() error = nil, want missing metric error") + } +} + +func coverageStandard() standard.Standard { + return standard.Standard{ + Gates: []standard.Gate{ + {ID: standard.C0CoverageGateID, Severity: standard.SeverityCritical, Required: true, Threshold: standard.Threshold{Min: floatPtr(70)}}, + }, + } +} + +func floatPtr(v float64) *float64 { + return &v +} + +func installFakeGobce(t *testing.T, stdout string) { + t.Helper() + binDir := t.TempDir() + path := filepath.Join(binDir, "hado-gobce") + content := "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' '" + stdout + "'\n" + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("write fake gobce: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} diff --git a/internal/charge/coverage/result_test.go b/internal/charge/coverage/result_test.go new file mode 100644 index 0000000..7b59ca2 --- /dev/null +++ b/internal/charge/coverage/result_test.go @@ -0,0 +1,37 @@ +package coverage + +import ( + "testing" + + "github.com/keyskey/hado/internal/modulerunner" + "github.com/keyskey/hado/internal/standard" +) + +func TestMeasurementFromRunResultConvertsCoverageMetrics(t *testing.T) { + result := modulerunner.RunResult{ + Metrics: []modulerunner.Metric{ + {ID: standard.C0CoverageGateID, Value: 82.1}, + {ID: standard.C1CoverageGateID, Value: 68.4}, + }, + } + + measurement, err := measurementFromRunResult(result) + if err != nil { + t.Fatalf("measurementFromRunResult() error = %v", err) + } + if measurement.C0 == nil || *measurement.C0 != 82.1 { + t.Fatalf("C0 = %v, want 82.1", measurement.C0) + } + if measurement.C1 == nil || *measurement.C1 != 68.4 { + t.Fatalf("C1 = %v, want 68.4", measurement.C1) + } +} + +func TestMeasurementFromRunResultRequiresCompleteCoverage(t *testing.T) { + _, err := measurementFromRunResult(modulerunner.RunResult{ + Metrics: []modulerunner.Metric{{ID: standard.C1CoverageGateID, Value: 68.4}}, + }) + if err == nil { + t.Fatal("measurementFromRunResult() error = nil, want missing c0 error") + } +} diff --git a/internal/charge/observability/observability.go b/internal/charge/observability/observability.go new file mode 100644 index 0000000..e0dde6c --- /dev/null +++ b/internal/charge/observability/observability.go @@ -0,0 +1,168 @@ +package observability + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/keyskey/hado/internal/charge" + "github.com/keyskey/hado/internal/manifest" + "github.com/keyskey/hado/internal/modulerunner" + "github.com/keyskey/hado/internal/standard" +) + +const ( + monitorDiscoverOperation = "observability.monitor.discover" + sloDiscoverOperation = "observability.slo.discover" +) + +type datadogProvider struct{} + +// Charger collects observability evidence for lanes that declare discovery. +type Charger struct { + datadog datadogProvider +} + +// NewCharger returns an observability domain charger. +func NewCharger() Charger { + return Charger{} +} + +// Charge runs configured observability discovery providers for required observability gates. +func (c Charger) Charge(ctx context.Context, m *manifest.Manifest, st standard.Standard) (charge.Change, error) { + updated := false + if st.RequiresGate(standard.ObservabilitySLOExistsGateID) { + sloUpdated, err := c.chargeLane(ctx, m, sloDiscovery(m), sloDiscoverOperation, "slo") + if err != nil { + return charge.Change{}, err + } + updated = updated || sloUpdated + } + if st.RequiresGate(standard.ObservabilityMonitorExistsGateID) { + monitorUpdated, err := c.chargeLane(ctx, m, monitorDiscovery(m), monitorDiscoverOperation, "monitor") + if err != nil { + return charge.Change{}, err + } + updated = updated || monitorUpdated + } + return charge.Change{ObservabilityRefsUpdated: updated}, nil +} + +func (c Charger) chargeLane( + ctx context.Context, + m *manifest.Manifest, + discovery *manifest.ObservabilityDiscovery, + operation string, + lane string, +) (bool, error) { + if discovery == nil || discovery.Datadog == nil { + return false, nil + } + if err := c.datadog.charge(ctx, m, discovery, operation, lane); err != nil { + return false, err + } + return true, nil +} + +func sloDiscovery(m *manifest.Manifest) *manifest.ObservabilityDiscovery { + if m == nil || m.Evidence.Observability == nil || m.Evidence.Observability.SLO == nil { + return nil + } + return m.Evidence.Observability.SLO.Discovery +} + +func monitorDiscovery(m *manifest.Manifest) *manifest.ObservabilityDiscovery { + if m == nil || m.Evidence.Observability == nil || m.Evidence.Observability.Monitor == nil { + return nil + } + return m.Evidence.Observability.Monitor.Discovery +} + +func (datadogProvider) charge(ctx context.Context, m *manifest.Manifest, discovery *manifest.ObservabilityDiscovery, operation, lane string) error { + if discovery == nil || discovery.Datadog == nil { + return fmt.Errorf("datadog module requires evidence.observability.%s.discovery.datadog", lane) + } + result, err := modulerunner.Run(ctx, datadogModuleSpec(), modulerunner.RunRequest{ + APIVersion: modulerunner.APIVersion, + Operation: operation, + Service: map[string]string{ + "id": m.Service.ID, + "name": m.Service.Name, + }, + Inputs: map[string]any{ + "lane": lane, + "discovery": discovery.Datadog, + }, + }) + if err != nil { + return err + } + return applyAutoRefs(m, lane, result) +} + +func datadogModuleSpec() modulerunner.Spec { + return modulerunner.Spec{ + ID: "datadog", + Command: "hado-datadog-pup", + Timeout: 2 * time.Minute, + MaxStdoutBytes: modulerunner.DefaultMaxStdoutBytes, + } +} + +func applyAutoRefs(m *manifest.Manifest, lane string, result modulerunner.RunResult) error { + if len(result.ObservabilityRefs) == 0 { + return fmt.Errorf("observability module result missing observability refs") + } + if m.Evidence.Observability == nil { + m.Evidence.Observability = &manifest.ObservabilityEvidence{} + } + target, err := laneEvidence(m.Evidence.Observability, lane) + if err != nil { + return err + } + + refs := make([]manifest.ObservabilityRef, 0, len(result.ObservabilityRefs)) + for _, ref := range result.ObservabilityRefs { + if ref.Lane != lane { + return fmt.Errorf("observability module returned unsupported observability lane %q", ref.Lane) + } + if strings.TrimSpace(ref.URL) == "" { + return fmt.Errorf("observability module returned %s ref with empty url", lane) + } + refs = append(refs, manifest.ObservabilityRef{ + Name: ref.Name, + URL: ref.URL, + DiscoveryType: manifest.ObservabilityDiscoveryAuto, + }) + } + syncAutoRefs(target, refs) + return nil +} + +func laneEvidence(o *manifest.ObservabilityEvidence, lane string) (*manifest.ObservabilityLaneEvidence, error) { + switch lane { + case "slo": + if o.SLO == nil { + o.SLO = &manifest.ObservabilityLaneEvidence{} + } + return o.SLO, nil + case "monitor": + if o.Monitor == nil { + o.Monitor = &manifest.ObservabilityLaneEvidence{} + } + return o.Monitor, nil + default: + return nil, fmt.Errorf("unsupported observability lane %q", lane) + } +} + +func syncAutoRefs(lane *manifest.ObservabilityLaneEvidence, refs []manifest.ObservabilityRef) { + kept := make([]manifest.ObservabilityRef, 0, len(lane.Refs)+len(refs)) + for _, ref := range lane.Refs { + if !strings.EqualFold(strings.TrimSpace(ref.DiscoveryType), manifest.ObservabilityDiscoveryAuto) { + kept = append(kept, ref) + } + } + lane.Refs = append(kept, refs...) +} diff --git a/internal/charge/observability/observability_test.go b/internal/charge/observability/observability_test.go new file mode 100644 index 0000000..89ef799 --- /dev/null +++ b/internal/charge/observability/observability_test.go @@ -0,0 +1,147 @@ +package observability + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/keyskey/hado/internal/manifest" + "github.com/keyskey/hado/internal/standard" +) + +func TestChargeRunsDatadogProviderAndSyncsMonitorRefs(t *testing.T) { + installFakeDatadogModule(t, `{"apiVersion":"hado.dev/v1","module":{"id":"hado.datadog.pup"},"observabilityRefs":[{"lane":"monitor","name":"prod latency","url":"https://app.datadoghq.com/monitors/42","discoveryType":"auto"},{"lane":"monitor","name":"error rate","url":"https://app.datadoghq.com/monitors/43","discoveryType":"auto"}]}`) + m := manifest.Manifest{ + Service: manifest.Service{Name: "orders"}, + Evidence: manifest.Evidence{ + Observability: &manifest.ObservabilityEvidence{ + Monitor: &manifest.ObservabilityLaneEvidence{ + Refs: []manifest.ObservabilityRef{ + {Name: "manual monitor", URL: "https://manual.example/monitor", DiscoveryType: manifest.ObservabilityDiscoveryManual}, + {Name: "stale auto", URL: "https://old.example/monitor", DiscoveryType: manifest.ObservabilityDiscoveryAuto}, + }, + Discovery: &manifest.ObservabilityDiscovery{ + Datadog: &manifest.DatadogDiscovery{HadoTags: []string{"hado:srv"}}, + }, + }, + }, + }, + } + + change, err := NewCharger().Charge(context.Background(), &m, observabilityStandard()) + if err != nil { + t.Fatalf("Charge() error = %v", err) + } + if !change.ObservabilityRefsUpdated { + t.Fatal("ObservabilityRefsUpdated = false, want true") + } + refs := m.Evidence.Observability.Monitor.Refs + if len(refs) != 3 { + t.Fatalf("refs = %#v, want manual + 2 auto", refs) + } + if refs[0].DiscoveryType != manifest.ObservabilityDiscoveryManual { + t.Fatalf("manual ref was not preserved first: %#v", refs) + } + if refs[1].URL != "https://app.datadoghq.com/monitors/42" || refs[2].URL != "https://app.datadoghq.com/monitors/43" { + t.Fatalf("auto refs = %#v", refs[1:]) + } +} + +func TestChargeSyncsSLORefs(t *testing.T) { + installFakeDatadogModule(t, `{"apiVersion":"hado.dev/v1","module":{"id":"hado.datadog.pup"},"observabilityRefs":[{"lane":"slo","name":"availability","url":"https://app.datadoghq.com/slo?slo_id=a","discoveryType":"auto"}]}`) + m := manifest.Manifest{ + Evidence: manifest.Evidence{ + Observability: &manifest.ObservabilityEvidence{ + SLO: &manifest.ObservabilityLaneEvidence{ + Refs: []manifest.ObservabilityRef{ + {URL: "https://old.example/slo", DiscoveryType: manifest.ObservabilityDiscoveryAuto}, + }, + Discovery: &manifest.ObservabilityDiscovery{ + Datadog: &manifest.DatadogDiscovery{Services: []string{"orders-api"}}, + }, + }, + }, + }, + } + + change, err := NewCharger().Charge(context.Background(), &m, sloObservabilityStandard()) + if err != nil { + t.Fatalf("Charge() error = %v", err) + } + if !change.ObservabilityRefsUpdated { + t.Fatal("ObservabilityRefsUpdated = false, want true") + } + if len(m.Evidence.Observability.SLO.Refs) != 1 || m.Evidence.Observability.SLO.Refs[0].URL != "https://app.datadoghq.com/slo?slo_id=a" { + t.Fatalf("slo refs = %#v", m.Evidence.Observability.SLO.Refs) + } +} + +func TestChargeDoesNothingWhenMonitorDiscoveryIsMissing(t *testing.T) { + m := manifest.Manifest{} + + change, err := NewCharger().Charge(context.Background(), &m, observabilityStandard()) + if err != nil { + t.Fatalf("Charge() error = %v", err) + } + if change.ObservabilityRefsUpdated { + t.Fatal("ObservabilityRefsUpdated = true, want false") + } +} + +func TestChargeFailsWhenDatadogProviderFails(t *testing.T) { + installFailingDatadogModule(t) + m := manifest.Manifest{ + Evidence: manifest.Evidence{ + Observability: &manifest.ObservabilityEvidence{ + Monitor: &manifest.ObservabilityLaneEvidence{ + Discovery: &manifest.ObservabilityDiscovery{ + Datadog: &manifest.DatadogDiscovery{Services: []string{"orders-api"}}, + }, + }, + }, + }, + } + + if _, err := NewCharger().Charge(context.Background(), &m, observabilityStandard()); err == nil { + t.Fatal("Charge() error = nil, want module error") + } +} + +func observabilityStandard() standard.Standard { + return standard.Standard{ + Gates: []standard.Gate{ + {ID: standard.ObservabilityMonitorExistsGateID, Severity: standard.SeverityCritical, Required: true}, + }, + } +} + +func sloObservabilityStandard() standard.Standard { + return standard.Standard{ + Gates: []standard.Gate{ + {ID: standard.ObservabilitySLOExistsGateID, Severity: standard.SeverityCritical, Required: true}, + }, + } +} + +func installFakeDatadogModule(t *testing.T, stdout string) { + t.Helper() + binDir := t.TempDir() + path := filepath.Join(binDir, "hado-datadog-pup") + content := "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' '" + stdout + "'\n" + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("write fake datadog module: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func installFailingDatadogModule(t *testing.T) { + t.Helper() + binDir := t.TempDir() + path := filepath.Join(binDir, "hado-datadog-pup") + content := "#!/bin/sh\necho module failed >&2\nexit 3\n" + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("write fake datadog module: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} diff --git a/internal/coverage/doc.go b/internal/coverage/doc.go index fc3de86..80a2faa 100644 --- a/internal/coverage/doc.go +++ b/internal/coverage/doc.go @@ -1,2 +1,2 @@ -// Package coverage defines normalized coverage metrics shared by HADO evaluators. +// Package coverage defines the normalized coverage domain model and validation. package coverage diff --git a/internal/coverage/metrics.go b/internal/coverage/metrics.go index bd0d29c..e5031b3 100644 --- a/internal/coverage/metrics.go +++ b/internal/coverage/metrics.go @@ -1,12 +1,12 @@ package coverage -// Merge overlays non-nil metrics from next onto metrics. -func (metrics Metrics) Merge(next Metrics) Metrics { - if next.C0Coverage != nil { - metrics.C0Coverage = next.C0Coverage +// Merge overlays non-nil values from next onto measurement. +func (measurement Measurement) Merge(next Measurement) Measurement { + if next.C0 != nil { + measurement.C0 = next.C0 } - if next.C1Coverage != nil { - metrics.C1Coverage = next.C1Coverage + if next.C1 != nil { + measurement.C1 = next.C1 } - return metrics + return measurement } diff --git a/internal/coverage/metrics_test.go b/internal/coverage/metrics_test.go index d72a54e..0a83fe3 100644 --- a/internal/coverage/metrics_test.go +++ b/internal/coverage/metrics_test.go @@ -2,25 +2,25 @@ package coverage import "testing" -func TestMetricsValidateRejectsOutOfRangeCoverage(t *testing.T) { +func TestMeasurementValidateRejectsOutOfRangeCoverage(t *testing.T) { value := 101.0 - err := Metrics{C0Coverage: &value}.Validate() + err := Measurement{C0: &value}.Validate() if err == nil { t.Fatal("Validate() error = nil, want error") } } -func TestMetricsMergeOverlaysPresentValues(t *testing.T) { +func TestMeasurementMergeOverlaysPresentValues(t *testing.T) { c0 := 82.1 c1 := 68.4 - metrics := Metrics{C0Coverage: &c0}.Merge(Metrics{C1Coverage: &c1}) + measurement := Measurement{C0: &c0}.Merge(Measurement{C1: &c1}) - if metrics.C0Coverage == nil || *metrics.C0Coverage != c0 { - t.Fatalf("C0Coverage = %v, want %f", metrics.C0Coverage, c0) + if measurement.C0 == nil || *measurement.C0 != c0 { + t.Fatalf("C0 = %v, want %f", measurement.C0, c0) } - if metrics.C1Coverage == nil || *metrics.C1Coverage != c1 { - t.Fatalf("C1Coverage = %v, want %f", metrics.C1Coverage, c1) + if measurement.C1 == nil || *measurement.C1 != c1 { + t.Fatalf("C1 = %v, want %f", measurement.C1, c1) } } diff --git a/internal/coverage/types.go b/internal/coverage/types.go index 5b764ca..e7c281d 100644 --- a/internal/coverage/types.go +++ b/internal/coverage/types.go @@ -1,7 +1,7 @@ package coverage -// Metrics contains normalized coverage values supplied to HADO by any producer. -type Metrics struct { - C0Coverage *float64 `json:"c0Coverage"` - C1Coverage *float64 `json:"c1Coverage"` +// Measurement contains normalized coverage percentages supplied to HADO by any producer. +type Measurement struct { + C0 *float64 `json:"c0,omitempty"` + C1 *float64 `json:"c1,omitempty"` } diff --git a/internal/coverage/validate.go b/internal/coverage/validate.go index 98be21e..f0dc8b4 100644 --- a/internal/coverage/validate.go +++ b/internal/coverage/validate.go @@ -3,12 +3,26 @@ package coverage import "fmt" // Validate checks that all present coverage percentages are in range. -func (metrics Metrics) Validate() error { - if metrics.C0Coverage != nil && (*metrics.C0Coverage < 0 || *metrics.C0Coverage > 100) { - return fmt.Errorf("c0Coverage must be between 0 and 100") +func (measurement Measurement) Validate() error { + if measurement.C0 != nil && (*measurement.C0 < 0 || *measurement.C0 > 100) { + return fmt.Errorf("c0 coverage must be between 0 and 100") } - if metrics.C1Coverage != nil && (*metrics.C1Coverage < 0 || *metrics.C1Coverage > 100) { - return fmt.Errorf("c1Coverage must be between 0 and 100") + if measurement.C1 != nil && (*measurement.C1 < 0 || *measurement.C1 > 100) { + return fmt.Errorf("c1 coverage must be between 0 and 100") + } + return nil +} + +// ValidateComplete checks that all normalized coverage percentages required by HADO are present and in range. +func (measurement Measurement) ValidateComplete() error { + if measurement.C0 == nil { + return fmt.Errorf("coverage measurement missing c0 coverage") + } + if measurement.C1 == nil { + return fmt.Errorf("coverage measurement missing c1 coverage") + } + if err := measurement.Validate(); err != nil { + return err } return nil } diff --git a/internal/integration/datadog/client.go b/internal/integration/datadog/client.go deleted file mode 100644 index 2453e56..0000000 --- a/internal/integration/datadog/client.go +++ /dev/null @@ -1,154 +0,0 @@ -package datadog - -import ( - "context" - "errors" - "fmt" - "net/http" - "net/url" - "os" - "strings" - - dd "github.com/DataDog/datadog-api-client-go/v2/api/datadog" - "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" -) - -const defaultSite = "datadoghq.com" - -// Config holds API authentication and site. Keys are never read from disk by this package. -type Config struct { - APIKey string - AppKey string - Site string - // APIBaseURL, if non-empty, is a full base URL (e.g. httptest server) used to override - // the API host via Configuration.Scheme/Host (for tests). - APIBaseURL string -} - -// Client lists Datadog monitors via the official datadog-api-client-go. -type Client struct { - cfg Config - site string - monitors *datadogV1.MonitorsApi -} - -// NewClient returns a Client with the given config and HTTP client. -func NewClient(cfg Config, httpClient *http.Client) (*Client, error) { - if strings.TrimSpace(cfg.APIKey) == "" { - return nil, fmt.Errorf("datadog: api key is empty") - } - if strings.TrimSpace(cfg.AppKey) == "" { - return nil, fmt.Errorf("datadog: application key is empty") - } - site := strings.TrimSpace(cfg.Site) - if site == "" { - site = defaultSite - } - - configuration := dd.NewConfiguration() - if httpClient != nil { - configuration.HTTPClient = httpClient - } - if base := strings.TrimSpace(cfg.APIBaseURL); base != "" { - parsed, err := url.Parse(base) - if err != nil { - return nil, fmt.Errorf("datadog: parse api base url: %w", err) - } - if parsed.Host != "" { - if parsed.Scheme != "" { - configuration.Scheme = parsed.Scheme - } else { - configuration.Scheme = "http" - } - configuration.Host = parsed.Host - } - } - - apiClient := dd.NewAPIClient(configuration) - return &Client{ - cfg: Config{ - APIKey: strings.TrimSpace(cfg.APIKey), - AppKey: strings.TrimSpace(cfg.AppKey), - Site: site, - APIBaseURL: strings.TrimSpace(cfg.APIBaseURL), - }, - site: site, - monitors: datadogV1.NewMonitorsApi(apiClient), - }, nil -} - -// NewClientFromEnv builds a client using DD_API_KEY, DD_APP_KEY, and optional DD_SITE. -func NewClientFromEnv(httpClient *http.Client) (*Client, error) { - return NewClient(Config{ - APIKey: os.Getenv("DD_API_KEY"), - AppKey: os.Getenv("DD_APP_KEY"), - Site: os.Getenv("DD_SITE"), - }, httpClient) -} - -// ListMonitors returns all monitors; call filters the result. Pagination is handled by the generated client. -func (c *Client) ListMonitors(ctx context.Context) ([]Monitor, error) { - ctx = c.apiContext(ctx) - ch, cancel := c.monitors.ListMonitorsWithPagination(ctx) - defer cancel() - - var v1mons []datadogV1.Monitor - for result := range ch { - if result.Error != nil { - return nil, formatListMonitorsError(result.Error) - } - v1mons = append(v1mons, result.Item) - } - - out := make([]Monitor, 0, len(v1mons)) - for _, m := range v1mons { - if m.Id == nil { - continue - } - name := "" - if m.Name != nil { - name = *m.Name - } - tags := m.Tags - if tags == nil { - tags = []string{} - } - out = append(out, Monitor{ - ID: *m.Id, - Name: name, - Tags: tags, - }) - } - return out, nil -} - -// Site returns the normalized Datadog site suffix (for example datadoghq.com). -func (c *Client) Site() string { - return c.cfg.Site -} - -func (c *Client) apiContext(ctx context.Context) context.Context { - ctx = context.WithValue(ctx, dd.ContextAPIKeys, map[string]dd.APIKey{ - "apiKeyAuth": {Key: c.cfg.APIKey}, - "appKeyAuth": {Key: c.cfg.AppKey}, - }) - // Test hosts override URL host via Configuration; default servers still need DD_SITE. - if strings.TrimSpace(c.cfg.APIBaseURL) == "" { - ctx = context.WithValue(ctx, dd.ContextServerVariables, map[string]string{ - "site": c.site, - }) - } - return ctx -} - -func formatListMonitorsError(err error) error { - var openAPIErr dd.GenericOpenAPIError - if errors.As(err, &openAPIErr) { - msg := strings.TrimSpace(string(openAPIErr.Body())) - if msg == "" { - return fmt.Errorf("datadog: list monitors: %s", openAPIErr.Error()) - } - return fmt.Errorf("datadog: list monitors: %s: %s", openAPIErr.Error(), msg) - } - return fmt.Errorf("datadog: list monitors: %w", err) -} diff --git a/internal/integration/datadog/client_test.go b/internal/integration/datadog/client_test.go deleted file mode 100644 index 3dbfbf1..0000000 --- a/internal/integration/datadog/client_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package datadog - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" -) - -func TestListMonitors_decode(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/monitor" { - t.Fatalf("path = %s", r.URL.Path) - } - if r.Header.Get("DD-API-KEY") != "api" || r.Header.Get("DD-APPLICATION-KEY") != "app" { - t.Fatalf("missing auth headers") - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[{"id":42,"name":"test","type":"query alert","query":"","tags":["env:prod","hado:foo"]}]`)) - })) - defer ts.Close() - - c, err := NewClient(Config{ - APIKey: "api", - AppKey: "app", - Site: "datadoghq.com", - APIBaseURL: ts.URL, - }, ts.Client()) - if err != nil { - t.Fatal(err) - } - - mons, err := c.ListMonitors(context.Background()) - if err != nil { - t.Fatal(err) - } - if len(mons) != 1 || mons[0].ID != 42 || mons[0].Name != "test" { - t.Fatalf("got %+v", mons) - } -} - -func TestMonitorAppURL_defaultSite(t *testing.T) { - got := MonitorAppURL("", 7) - want := "https://app.datadoghq.com/monitors/7" - if got != want { - t.Fatalf("got %q want %q", got, want) - } -} - -func TestMonitorAppURL_eu(t *testing.T) { - got := MonitorAppURL("datadoghq.eu", 9) - want := "https://app.datadoghq.eu/monitors/9" - if got != want { - t.Fatalf("got %q want %q", got, want) - } -} diff --git a/internal/integration/datadog/doc.go b/internal/integration/datadog/doc.go deleted file mode 100644 index 18982b5..0000000 --- a/internal/integration/datadog/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package datadog wraps the official Datadog HTTP API client for monitor listing and URL helpers. -// It does not import manifest types or gate IDs; callers own discovery rules and manifest updates. -package datadog diff --git a/internal/integration/datadog/types.go b/internal/integration/datadog/types.go deleted file mode 100644 index 1e350ed..0000000 --- a/internal/integration/datadog/types.go +++ /dev/null @@ -1,8 +0,0 @@ -package datadog - -// Monitor is a subset of Datadog monitor API fields needed for discovery and linking. -type Monitor struct { - ID int64 `json:"id"` - Name string `json:"name"` - Tags []string `json:"tags"` -} diff --git a/internal/integration/datadog/url.go b/internal/integration/datadog/url.go deleted file mode 100644 index c1a74b7..0000000 --- a/internal/integration/datadog/url.go +++ /dev/null @@ -1,13 +0,0 @@ -package datadog - -import "fmt" - -// MonitorAppURL returns the web UI URL for a monitor id. site is the Datadog site suffix -// (for example datadoghq.com, datadoghq.eu) matching DD_SITE. -func MonitorAppURL(site string, monitorID int64) string { - s := site - if s == "" { - s = defaultSite - } - return fmt.Sprintf("https://app.%s/monitors/%d", s, monitorID) -} diff --git a/internal/integration/doc.go b/internal/integration/doc.go deleted file mode 100644 index 25205a4..0000000 --- a/internal/integration/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// Package integration holds thin HTTP/API clients for external systems. -// Vendor-specific code lives in subpackages (for example datadog). -// HADO manifest schemas and gate IDs must not be imported from here. -package integration diff --git a/internal/manifest/README.md b/internal/manifest/README.md index 9404997..22c3493 100644 --- a/internal/manifest/README.md +++ b/internal/manifest/README.md @@ -3,7 +3,7 @@ HADO Manifest の **型・読み書き・gate 向けヘルパ・scaffold** を置くパッケージ。 **gate の判定ロジック本体**は `internal/gate`、**Readiness Standard** は `internal/standard` に置く(ここには持ち込まない)。 -人間向けの詳細仕様は `docs/`(例: [observability-readiness.md](../../docs/observability-readiness.md))。この README は **コードの置き場と命名のレール**。 +人間向けの **仕様**は [docs/spec/](../docs/spec/)。Manifest フィールドは [hado.manifest.reference.yaml](../docs/hado.manifest.reference.yaml)。この README は **コードの置き場と命名のレール**。 --- @@ -110,5 +110,6 @@ observability のように **レーン単位**の拡張は `ObservabilityLaneEvi ## 関連 - リポジトリ全体の internal パッケージ規約: [.cursor/rules/internal-go-packages.mdc](../../.cursor/rules/internal-go-packages.mdc) -- 実装状況・CLI: [docs/implementation-status.md](../../docs/implementation-status.md) +- 仕様: [docs/spec/](../../docs/spec/) +- Manifest 参考 YAML: [docs/hado.manifest.reference.yaml](../../docs/hado.manifest.reference.yaml) - Manifest 参考 YAML: [docs/hado.manifest.reference.yaml](../../docs/hado.manifest.reference.yaml)(`make gen-manifest-doc`) diff --git a/internal/manifest/refdoc/field_docs.go b/internal/manifest/refdoc/field_docs.go index 6d6594c..029ac69 100644 --- a/internal/manifest/refdoc/field_docs.go +++ b/internal/manifest/refdoc/field_docs.go @@ -27,44 +27,41 @@ var yamlDoc = map[string]string{ "evidence.observability": "観測可能性の証跡(ブロック)。`slo` / `monitor` / `dashboard` を **役割ごと**に分け、各レーンに `refs`(URL 一覧)・任意 `provider`・`discovery` を持てる。", - "evidence.observability.slo": "SLO / SLI レーン(オブジェクト)。YAML では **配列だけ**(= `refs` の省略形、`discovery_type` は `manual`)も受け付ける。`observability.slo_exists` は `refs` に有効な `url` があれば PASS。", - "evidence.observability.slo.provider": "このレーンの外部プロバイダ名(任意。例: `datadog`)。", - "evidence.observability.slo.discovery": "charge がこのレーンで資源探索するときの条件(1 パターン)。MVP の Datadog 自動解決は monitor レーンのみ実装。", - "evidence.observability.slo.discovery.datadog": "Datadog API 向け探索条件(フィールドは Monitor 由来; SLO 自動解決は未実装でも manifest に先行記述可)。", - "evidence.observability.slo.discovery.datadog.hado_tags": "`hado:*` タグ。すべて一致するモニター等に絞る(最優先)。", - "evidence.observability.slo.discovery.datadog.service": "`service:…` タグの値(未指定時は manifest `service.name` を利用しうる)。", - "evidence.observability.slo.discovery.datadog.env": "`env:…` タグの値。", - "evidence.observability.slo.discovery.datadog.name_contains": "名前の部分一致(フォールバック)。", - "evidence.observability.slo.refs": "証跡 URL の一覧。`discovery_type` で手入力(`manual`)と charge 自動解決(`auto`)を区別。", - "evidence.observability.slo.refs.name": "表示名(任意)。", - "evidence.observability.slo.refs.url": "ブラウザで開ける URL。", - "evidence.observability.slo.refs.discovery_type": "`manual` または `auto`。", + "evidence.observability.slo": "SLO / SLI レーン(オブジェクト)。YAML では **配列だけ**(= `refs` の省略形、`discovery_type` は `manual`)も受け付ける。`observability.slo_exists` は `refs` に有効な `url` があれば PASS。", + "evidence.observability.slo.provider": "このレーンの外部プロバイダ名(任意。例: `datadog`)。", + "evidence.observability.slo.discovery": "charge がこのレーンで資源探索するときの条件(1 パターン)。`observability.slo_exists` gate と `discovery.datadog` があると SLO 一覧を同期する。", + "evidence.observability.slo.discovery.datadog": "Datadog SLO 一覧に対する絞り込み条件(monitor レーンと同型)。", + "evidence.observability.slo.discovery.datadog.hado_tags": "指定タグを **すべて** 持つ SLO にマッチ(AND)。", + "evidence.observability.slo.discovery.datadog.services": "Datadog `service:` タグの値。**いずれか 1 つ** にマッチ(OR)。", + "evidence.observability.slo.discovery.datadog.env": "`env:` タグの値。指定時は必須一致。", + "evidence.observability.slo.refs": "証跡 URL の一覧。`discovery_type` で手入力(`manual`)と charge 自動解決(`auto`)を区別。", + "evidence.observability.slo.refs.name": "表示名(任意)。", + "evidence.observability.slo.refs.url": "ブラウザで開ける URL。", + "evidence.observability.slo.refs.discovery_type": "`manual` または `auto`。", - "evidence.observability.monitor": "Monitor レーン。配列省略形 = `refs`(`discovery_type` 省略時 `manual`)。`hado charge --datadog-discover` は **このレーンの** `discovery.datadog` を読み、`refs` に `discovery_type: auto` を書く。", - "evidence.observability.monitor.provider": "このレーンの外部プロバイダ名(任意。例: `datadog`)。", - "evidence.observability.monitor.discovery": "Monitor 探索の条件(1 パターン)。`--datadog-discover` 時に必須。", - "evidence.observability.monitor.discovery.datadog": "`GET /api/v1/monitor` で列挙した一覧に対する絞り込み条件。", - "evidence.observability.monitor.discovery.datadog.hado_tags": "`hado:*` タグすべて一致(最優先)。", - "evidence.observability.monitor.discovery.datadog.service": "`service:…`(未指定時 manifest `service.name` 可)。", - "evidence.observability.monitor.discovery.datadog.env": "`env:…`。", - "evidence.observability.monitor.discovery.datadog.name_contains": "モニター名部分一致。", - "evidence.observability.monitor.refs": "証跡 URL の一覧。", - "evidence.observability.monitor.refs.name": "表示名(任意)。", - "evidence.observability.monitor.refs.url": "モニター UI URL。", - "evidence.observability.monitor.refs.discovery_type": "`manual` または `auto`。", + "evidence.observability.monitor": "Monitor レーン。配列省略形 = `refs`(`discovery_type` 省略時 `manual`)。`hado charge` は `observability.monitor_exists` gate と `discovery.datadog` があると Datadog module を実行し、`refs` に `discovery_type: auto` を書く。", + "evidence.observability.monitor.provider": "このレーンの外部プロバイダ名(任意。例: `datadog`)。", + "evidence.observability.monitor.discovery": "Monitor 探索の条件(1 パターン)。`hado charge` が必要な Datadog module を自動実行するときに使う。", + "evidence.observability.monitor.discovery.datadog": "`pup datadog monitor list` の結果に対する絞り込み条件。マッチした **すべて** を `refs` に同期する。", + "evidence.observability.monitor.discovery.datadog.hado_tags": "指定タグを **すべて** 持つ monitor にマッチ(AND)。", + "evidence.observability.monitor.discovery.datadog.services": "Datadog `service:` タグの値。**いずれか 1 つ** にマッチ(OR)。", + "evidence.observability.monitor.discovery.datadog.env": "`env:` タグの値。指定時は必須一致。", + "evidence.observability.monitor.refs": "証跡 URL の一覧。", + "evidence.observability.monitor.refs.name": "表示名(任意)。", + "evidence.observability.monitor.refs.url": "モニター UI URL。", + "evidence.observability.monitor.refs.discovery_type": "`manual` または `auto`。", - "evidence.observability.dashboard": "ダッシュボードレーン。配列省略形 = `refs`。", - "evidence.observability.dashboard.provider": "このレーンの外部プロバイダ名(任意)。", - "evidence.observability.dashboard.discovery": "探索条件(ダッシュボード自動解決は未実装)。", - "evidence.observability.dashboard.discovery.datadog": "Datadog 向け探索条件プレースホルダ(フィールド形は共通)。", - "evidence.observability.dashboard.discovery.datadog.hado_tags": "将来の discovery 用。", - "evidence.observability.dashboard.discovery.datadog.service": "同上。", - "evidence.observability.dashboard.discovery.datadog.env": "同上。", - "evidence.observability.dashboard.discovery.datadog.name_contains": "同上。", - "evidence.observability.dashboard.refs": "証跡 URL の一覧。", - "evidence.observability.dashboard.refs.name": "表示名(任意)。", - "evidence.observability.dashboard.refs.url": "URL。", - "evidence.observability.dashboard.refs.discovery_type": "`manual` または `auto`。", + "evidence.observability.dashboard": "ダッシュボードレーン。配列省略形 = `refs`。", + "evidence.observability.dashboard.provider": "このレーンの外部プロバイダ名(任意)。", + "evidence.observability.dashboard.discovery": "探索条件(ダッシュボード自動解決は未実装)。", + "evidence.observability.dashboard.discovery.datadog": "Datadog 向け探索条件プレースホルダ(フィールド形は共通)。", + "evidence.observability.dashboard.discovery.datadog.hado_tags": "将来の discovery 用(monitor / slo と同型)。", + "evidence.observability.dashboard.discovery.datadog.services": "同上。", + "evidence.observability.dashboard.discovery.datadog.env": "同上。", + "evidence.observability.dashboard.refs": "証跡 URL の一覧。", + "evidence.observability.dashboard.refs.name": "表示名(任意)。", + "evidence.observability.dashboard.refs.url": "URL。", + "evidence.observability.dashboard.refs.discovery_type": "`manual` または `auto`。", "evidence.infra": "インフラ関連の参照(ブロック)。", "evidence.infra.deployment_spec": "デプロイ仕様の参照(パス・URL・カタログ ID)。`infra.deployment_spec_exists`。", diff --git a/internal/manifest/refdoc/generate.go b/internal/manifest/refdoc/generate.go index a9b3eff..f552115 100644 --- a/internal/manifest/refdoc/generate.go +++ b/internal/manifest/refdoc/generate.go @@ -207,15 +207,12 @@ func referenceStringValue(path string) string { if strings.HasSuffix(path, ".provider") { return "datadog" } - if strings.HasSuffix(path, ".discovery.datadog.service") { - return "orders" + if strings.HasSuffix(path, ".discovery.datadog.services") { + return "orders-api" } if strings.HasSuffix(path, ".discovery.datadog.env") { return "prod" } - if strings.HasSuffix(path, ".discovery.datadog.name_contains") { - return "error rate" - } if strings.HasSuffix(path, ".refs.name") { return "example resource" } diff --git a/internal/manifest/types.go b/internal/manifest/types.go index 3497ed4..a6e5852 100644 --- a/internal/manifest/types.go +++ b/internal/manifest/types.go @@ -84,13 +84,12 @@ type ObservabilityDiscovery struct { Datadog *DatadogDiscovery `yaml:"datadog,omitempty" json:"datadog,omitempty"` } -// DatadogDiscovery lists criteria for matching monitor(s) (MVP: charge still requires exactly one match). -// Priority: hado_tags (all must match) else service/env tags else name_contains. +// DatadogDiscovery lists criteria for matching Datadog monitors or SLOs during charge discovery. +// All specified dimensions combine with AND; services matches when any listed service tag is present. type DatadogDiscovery struct { - HadoTags []string `yaml:"hado_tags,omitempty" json:"hado_tags,omitempty"` - Service string `yaml:"service,omitempty" json:"service,omitempty"` - Env string `yaml:"env,omitempty" json:"env,omitempty"` - NameContains string `yaml:"name_contains,omitempty" json:"name_contains,omitempty"` + HadoTags []string `yaml:"hado_tags,omitempty" json:"hado_tags,omitempty"` + Services []string `yaml:"services,omitempty" json:"services,omitempty"` + Env string `yaml:"env,omitempty" json:"env,omitempty"` } // InfraEvidence declares infrastructure-related evidence references (deployment spec, IaC pointer, etc.). diff --git a/internal/modulerunner/types.go b/internal/modulerunner/types.go index 3206f66..536e8ec 100644 --- a/internal/modulerunner/types.go +++ b/internal/modulerunner/types.go @@ -32,6 +32,14 @@ type Metric struct { Unit string `json:"unit,omitempty"` } +// ObservabilityRef is a narrow module-produced reference for observability evidence. +type ObservabilityRef struct { + Lane string `json:"lane"` + Name string `json:"name,omitempty"` + URL string `json:"url"` + DiscoveryType string `json:"discoveryType,omitempty"` +} + // RunRequest is written as JSON to module stdin. type RunRequest struct { APIVersion string `json:"apiVersion"` @@ -45,7 +53,8 @@ type RunRequest struct { // RunResult is read as JSON from module stdout. type RunResult struct { - APIVersion string `json:"apiVersion"` - Module ModuleIdentity `json:"module,omitempty"` - Metrics []Metric `json:"metrics,omitempty"` + APIVersion string `json:"apiVersion"` + Module ModuleIdentity `json:"module,omitempty"` + Metrics []Metric `json:"metrics,omitempty"` + ObservabilityRefs []ObservabilityRef `json:"observabilityRefs,omitempty"` } diff --git a/modules/datadog/README.md b/modules/datadog/README.md index ecd5ca7..6041c4c 100644 --- a/modules/datadog/README.md +++ b/modules/datadog/README.md @@ -1,3 +1,30 @@ -# Datadog Module +# Datadog module -Placeholder for Datadog observability, service catalog, ownership, monitor, and runbook evidence. +`datadog` is HADO's bundled observability module wrapper for Datadog Monitor and SLO discovery. + +The executable entrypoint lives at `modules/datadog/cmd/hado-datadog-pup`. It reads a Module Runner `RunRequest` from stdin, calls the external `pup` CLI to list Datadog monitors or SLOs as JSON, and writes a Module Runner `RunResult` with `observabilityRefs` to stdout. + +The observability charge pipeline runs this module automatically when the loaded Readiness Standard contains the matching observability gate and the manifest has `discovery.datadog` on that lane. + +Operations: + +- `observability.monitor.discover` — lists monitors via `pup datadog monitor list --format json` +- `observability.slo.discover` — lists SLOs via `pup datadog slo list --format json` + +Matching (`discovery.datadog`): + +- `hado_tags`: all listed tags must be present (AND). +- `services`: resource must have `service:` for at least one listed value (OR). +- `env`: resource must have `env:` when set. +- At least one of `hado_tags`, `services`, or `env` is required. + +All matching resources are returned. Zero matches cause the module to exit non-zero, so `charge` exits 2 without saving the manifest. + +By default the wrapper runs: + +```bash +pup datadog monitor list --format json +pup datadog slo list --format json +``` + +Override commands with `HADO_DATADOG_PUP_MONITOR_COMMAND` / `HADO_DATADOG_PUP_SLO_COMMAND` (fallback: `HADO_DATADOG_PUP_COMMAND` / `pup`) and arguments with `HADO_DATADOG_PUP_MONITOR_ARGS` / `HADO_DATADOG_PUP_SLO_ARGS` (monitor also accepts legacy `HADO_DATADOG_PUP_ARGS`). `DD_SITE` controls generated URLs when the `pup` JSON does not include a URL; the default is `datadoghq.com`. diff --git a/modules/datadog/cmd/hado-datadog-pup/main.go b/modules/datadog/cmd/hado-datadog-pup/main.go new file mode 100644 index 0000000..a55e0b4 --- /dev/null +++ b/modules/datadog/cmd/hado-datadog-pup/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "github.com/keyskey/hado/modules/datadog" +) + +func main() { + os.Exit(datadog.Run(os.Stdin, os.Stdout, os.Stderr)) +} diff --git a/modules/datadog/discover.go b/modules/datadog/discover.go new file mode 100644 index 0000000..4d0a7ed --- /dev/null +++ b/modules/datadog/discover.go @@ -0,0 +1,112 @@ +package datadog + +import ( + "fmt" + "strings" + + "github.com/keyskey/hado/internal/manifest" + "github.com/keyskey/hado/internal/modulerunner" +) + +func validateDiscoverySpec(d *manifest.DatadogDiscovery) error { + if d == nil { + return fmt.Errorf("datadog discover: discovery.datadog is required") + } + hasHado := len(compactStrings(d.HadoTags)) > 0 + hasServices := len(compactStrings(d.Services)) > 0 + hasEnv := strings.TrimSpace(d.Env) != "" + if !hasHado && !hasServices && !hasEnv { + return fmt.Errorf("datadog discover: set at least one of hado_tags, services, or env") + } + return nil +} + +func resourceMatches(tags []string, spec *manifest.DatadogDiscovery) bool { + if spec == nil { + return false + } + requiredHadoTags := compactStrings(spec.HadoTags) + if len(requiredHadoTags) > 0 && !tagsContainAll(tags, requiredHadoTags) { + return false + } + services := compactStrings(spec.Services) + if len(services) > 0 && !tagsContainAnyService(tags, services) { + return false + } + envVal := strings.TrimSpace(spec.Env) + if envVal != "" && !tagsContainAll(tags, []string{"env:" + envVal}) { + return false + } + return true +} + +func observabilityRefsFromMatches[T any]( + lane string, + matched []T, + name func(T) string, + url func(T) string, +) []modulerunner.ObservabilityRef { + refs := make([]modulerunner.ObservabilityRef, 0, len(matched)) + for _, item := range matched { + url := strings.TrimSpace(url(item)) + if url == "" { + continue + } + refs = append(refs, modulerunner.ObservabilityRef{ + Lane: lane, + Name: name(item), + URL: url, + DiscoveryType: manifest.ObservabilityDiscoveryAuto, + }) + } + return refs +} + +func compactStrings(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +func tagsContainAll(resourceTags, required []string) bool { + if len(required) == 0 { + return true + } + set := tagSet(resourceTags) + for _, requiredTag := range required { + key := strings.ToLower(strings.TrimSpace(requiredTag)) + if key == "" { + continue + } + if _, ok := set[key]; !ok { + return false + } + } + return true +} + +func tagsContainAnyService(resourceTags []string, services []string) bool { + set := tagSet(resourceTags) + for _, service := range services { + key := "service:" + strings.ToLower(strings.TrimSpace(service)) + if key == "service:" { + continue + } + if _, ok := set[key]; ok { + return true + } + } + return false +} + +func tagSet(tags []string) map[string]struct{} { + set := make(map[string]struct{}, len(tags)) + for _, tag := range tags { + set[strings.ToLower(strings.TrimSpace(tag))] = struct{}{} + } + return set +} diff --git a/modules/datadog/module.go b/modules/datadog/module.go new file mode 100644 index 0000000..1ebf9e8 --- /dev/null +++ b/modules/datadog/module.go @@ -0,0 +1,112 @@ +package datadog + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/keyskey/hado/internal/manifest" + "github.com/keyskey/hado/internal/modulerunner" +) + +const ( + moduleID = "hado.datadog.pup" + defaultPupCommand = "pup" + defaultSite = "datadoghq.com" + monitorDiscoverOperation = "observability.monitor.discover" + sloDiscoverOperation = "observability.slo.discover" +) + +type discoveryInput struct { + Lane string `json:"lane"` + Discovery manifest.DatadogDiscovery `json:"discovery"` +} + +// Run executes the Datadog pup wrapper module process. +func Run(stdin io.Reader, stdout, stderr io.Writer) int { + if err := run(stdin, stdout); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + return 0 +} + +func run(stdin io.Reader, stdout io.Writer) error { + var request modulerunner.RunRequest + if err := json.NewDecoder(stdin).Decode(&request); err != nil { + return fmt.Errorf("decode run request: %w", err) + } + if request.APIVersion != modulerunner.APIVersion { + return fmt.Errorf("apiVersion must be %q", modulerunner.APIVersion) + } + + input, err := decodeInputs(request) + if err != nil { + return err + } + if err := validateDiscoverySpec(&input.Discovery); err != nil { + return err + } + + var refs []modulerunner.ObservabilityRef + switch request.Operation { + case monitorDiscoverOperation: + if input.Lane != "monitor" { + return fmt.Errorf("unsupported observability lane %q", input.Lane) + } + refs, err = discoverMonitors(&input.Discovery) + case sloDiscoverOperation: + if input.Lane != "slo" { + return fmt.Errorf("unsupported observability lane %q", input.Lane) + } + refs, err = discoverSLOs(&input.Discovery) + default: + return fmt.Errorf("unsupported operation %q", request.Operation) + } + if err != nil { + return err + } + if len(refs) == 0 { + return fmt.Errorf("datadog discover: no %s matched criteria", input.Lane) + } + + return json.NewEncoder(stdout).Encode(modulerunner.RunResult{ + APIVersion: modulerunner.APIVersion, + Module: modulerunner.ModuleIdentity{ + ID: moduleID, + }, + ObservabilityRefs: refs, + }) +} + +func decodeInputs(request modulerunner.RunRequest) (discoveryInput, error) { + var input discoveryInput + if err := decodeAny(request.Inputs, &input); err != nil { + return discoveryInput{}, fmt.Errorf("decode inputs: %w", err) + } + return input, nil +} + +func decodeAny(in any, out any) error { + data, err := json.Marshal(in) + if err != nil { + return err + } + return json.Unmarshal(data, out) +} + +func pupExecutable() string { + if command := strings.TrimSpace(os.Getenv("HADO_DATADOG_PUP_COMMAND")); command != "" { + return command + } + return defaultPupCommand +} + +func site() string { + if value := strings.TrimSpace(os.Getenv("DD_SITE")); value != "" { + return value + } + return defaultSite +} diff --git a/modules/datadog/module_test.go b/modules/datadog/module_test.go new file mode 100644 index 0000000..e812970 --- /dev/null +++ b/modules/datadog/module_test.go @@ -0,0 +1,181 @@ +package datadog + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/keyskey/hado/internal/manifest" + "github.com/keyskey/hado/internal/modulerunner" +) + +func TestRunEmitsAllMatchingMonitorRefs(t *testing.T) { + installFakePup(t, `[{"id":42,"name":"mine","tags":["env:prod","hado:srv"]},{"id":7,"name":"other","tags":["env:dev"]},{"id":8,"name":"also","tags":["env:prod","hado:srv"]}]`, 0) + t.Setenv("DD_SITE", "datadoghq.eu") + + stdin := bytes.NewBufferString(`{"apiVersion":"hado.dev/v1","operation":"observability.monitor.discover","inputs":{"lane":"monitor","discovery":{"hado_tags":["hado:srv"],"env":"prod"}}}`) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run(stdin, &stdout, &stderr) + if code != 0 { + t.Fatalf("Run() code = %d stderr = %q", code, stderr.String()) + } + + var result modulerunner.RunResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if len(result.ObservabilityRefs) != 2 { + t.Fatalf("ObservabilityRefs = %#v", result.ObservabilityRefs) + } + if result.ObservabilityRefs[0].URL != "https://app.datadoghq.eu/monitors/42" || + result.ObservabilityRefs[1].URL != "https://app.datadoghq.eu/monitors/8" { + t.Fatalf("ObservabilityRefs = %#v", result.ObservabilityRefs) + } +} + +func TestRunMatchesAnyListedService(t *testing.T) { + installFakePup(t, `[{"id":"99","name":"api","tags":["service:orders-api","env:prod"]},{"id":"100","name":"worker","tags":["service:orders-worker","env:prod"]},{"id":"101","name":"other","tags":["service:billing","env:prod"]}]`, 0) + + stdin := bytes.NewBufferString(`{"apiVersion":"hado.dev/v1","operation":"observability.monitor.discover","inputs":{"lane":"monitor","discovery":{"services":["orders-api","orders-worker"],"env":"prod"}}}`) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run(stdin, &stdout, &stderr) + if code != 0 { + t.Fatalf("Run() code = %d stderr = %q", code, stderr.String()) + } + var result modulerunner.RunResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatal(err) + } + if len(result.ObservabilityRefs) != 2 { + t.Fatalf("ObservabilityRefs = %#v", result.ObservabilityRefs) + } +} + +func TestRunDiscoversSLORefs(t *testing.T) { + installFakeSLOPup(t, `[{"id":"abc","name":"availability","tags":["service:orders-api","hado:srv"]},{"id":"def","name":"latency","tags":["service:orders-worker","hado:srv"]}]`, 0) + + stdin := bytes.NewBufferString(`{"apiVersion":"hado.dev/v1","operation":"observability.slo.discover","inputs":{"lane":"slo","discovery":{"hado_tags":["hado:srv"]}}}`) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run(stdin, &stdout, &stderr) + if code != 0 { + t.Fatalf("Run() code = %d stderr = %q", code, stderr.String()) + } + var result modulerunner.RunResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatal(err) + } + if len(result.ObservabilityRefs) != 2 { + t.Fatalf("ObservabilityRefs = %#v", result.ObservabilityRefs) + } + if result.ObservabilityRefs[0].Lane != "slo" || result.ObservabilityRefs[0].URL != "https://app.datadoghq.com/slo?slo_id=abc" { + t.Fatalf("ObservabilityRefs = %#v", result.ObservabilityRefs) + } +} + +func TestRunFailsWhenNoMonitorMatches(t *testing.T) { + installFakePup(t, `[{"id":1,"name":"other","tags":["env:prod"]}]`, 0) + + stdin := bytes.NewBufferString(`{"apiVersion":"hado.dev/v1","operation":"observability.monitor.discover","inputs":{"lane":"monitor","discovery":{"hado_tags":["hado:srv"]}}}`) + var stdout bytes.Buffer + var stderr bytes.Buffer + + if code := Run(stdin, &stdout, &stderr); code == 0 { + t.Fatalf("Run() code = 0 stdout = %q", stdout.String()) + } + if !bytes.Contains(stderr.Bytes(), []byte("no monitor matched")) { + t.Fatalf("stderr = %q", stderr.String()) + } +} + +func TestRunFailsWhenDiscoverySpecMissingSelectors(t *testing.T) { + installFakePup(t, `[{"id":1,"name":"a","tags":["hado:srv"]}]`, 0) + + stdin := bytes.NewBufferString(`{"apiVersion":"hado.dev/v1","operation":"observability.monitor.discover","inputs":{"lane":"monitor","discovery":{}}}`) + var stdout bytes.Buffer + var stderr bytes.Buffer + + if code := Run(stdin, &stdout, &stderr); code == 0 { + t.Fatalf("Run() code = 0 stdout = %q", stdout.String()) + } + if !bytes.Contains(stderr.Bytes(), []byte("set at least one of hado_tags, services, or env")) { + t.Fatalf("stderr = %q", stderr.String()) + } +} + +func TestRunFailsWhenPupReturnsInvalidJSON(t *testing.T) { + installFakePup(t, `{`, 0) + + stdin := bytes.NewBufferString(`{"apiVersion":"hado.dev/v1","operation":"observability.monitor.discover","inputs":{"lane":"monitor","discovery":{"services":["orders"]}}}`) + var stdout bytes.Buffer + var stderr bytes.Buffer + + if code := Run(stdin, &stdout, &stderr); code == 0 { + t.Fatalf("Run() code = 0 stdout = %q", stdout.String()) + } + if !bytes.Contains(stderr.Bytes(), []byte("decode pup monitors")) { + t.Fatalf("stderr = %q", stderr.String()) + } +} + +func TestRunFailsWhenPupExitsNonZero(t *testing.T) { + installFakePup(t, `pup failed`, 3) + + stdin := bytes.NewBufferString(`{"apiVersion":"hado.dev/v1","operation":"observability.monitor.discover","inputs":{"lane":"monitor","discovery":{"services":["orders"]}}}`) + var stdout bytes.Buffer + var stderr bytes.Buffer + + if code := Run(stdin, &stdout, &stderr); code == 0 { + t.Fatalf("Run() code = 0 stdout = %q", stdout.String()) + } + if !bytes.Contains(stderr.Bytes(), []byte("run pup")) { + t.Fatalf("stderr = %q", stderr.String()) + } +} + +func installFakePup(t *testing.T, output string, exitCode int) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "pup") + content := "#!/bin/sh\n" + if exitCode == 0 { + content += "cat <<'JSON'\n" + output + "\nJSON\n" + } else { + content += "echo '" + output + "' >&2\nexit " + strconv.Itoa(exitCode) + "\n" + } + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("write fake pup: %v", err) + } + t.Setenv("HADO_DATADOG_PUP_COMMAND", path) +} + +func installFakeSLOPup(t *testing.T, output string, exitCode int) { + t.Helper() + installFakePup(t, output, exitCode) + t.Setenv("HADO_DATADOG_PUP_SLO_COMMAND", os.Getenv("HADO_DATADOG_PUP_COMMAND")) +} + +func TestResourceMatchesCombinesSelectorsWithAND(t *testing.T) { + spec := &manifest.DatadogDiscovery{ + HadoTags: []string{"hado:srv"}, + Services: []string{"orders-api", "orders-worker"}, + Env: "prod", + } + if !resourceMatches([]string{"hado:srv", "service:orders-api", "env:prod"}, spec) { + t.Fatal("expected match") + } + if resourceMatches([]string{"hado:srv", "service:billing", "env:prod"}, spec) { + t.Fatal("expected service mismatch") + } + if resourceMatches([]string{"hado:srv", "service:orders-api", "env:staging"}, spec) { + t.Fatal("expected env mismatch") + } +} diff --git a/modules/datadog/monitor.go b/modules/datadog/monitor.go new file mode 100644 index 0000000..29cab79 --- /dev/null +++ b/modules/datadog/monitor.go @@ -0,0 +1,182 @@ +package datadog + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/keyskey/hado/internal/manifest" + "github.com/keyskey/hado/internal/modulerunner" +) + +// Monitor is the subset of Datadog monitor data needed for discovery. +type Monitor struct { + ID int64 + Name string + Tags []string + URL string +} + +type pupMonitor struct { + ID json.RawMessage `json:"id"` + Name string `json:"name"` + Tags []string `json:"tags"` + URL string `json:"url"` +} + +func discoverMonitors(spec *manifest.DatadogDiscovery) ([]modulerunner.ObservabilityRef, error) { + monitors, err := listMonitors(monitorPupCommand(), monitorPupArgs()) + if err != nil { + return nil, err + } + matched := filterMonitors(monitors, spec) + return observabilityRefsFromMatches("monitor", matched, func(m Monitor) string { + return m.Name + }, func(m Monitor) string { + url := strings.TrimSpace(m.URL) + if url == "" { + url = monitorAppURL(site(), m.ID) + } + return url + }), nil +} + +func listMonitors(command string, args []string) ([]Monitor, error) { + cmd := exec.Command(command, args...) + data, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("run pup monitor: %w: %s", err, strings.TrimSpace(string(exitErr.Stderr))) + } + return nil, fmt.Errorf("run pup monitor: %w", err) + } + monitors, err := decodePupMonitors(data) + if err != nil { + return nil, fmt.Errorf("decode pup monitors: %w", err) + } + return monitors, nil +} + +func decodePupMonitors(data []byte) ([]Monitor, error) { + var raw any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + return monitorsFromRaw(raw) +} + +func monitorsFromRaw(raw any) ([]Monitor, error) { + switch value := raw.(type) { + case []any: + return decodeMonitorArray(value) + case map[string]any: + for _, key := range []string{"monitors", "results", "data"} { + if nested, ok := value[key]; ok { + return monitorsFromRaw(nested) + } + } + monitor, err := decodeMonitorObject(value) + if err != nil { + return nil, err + } + return []Monitor{monitor}, nil + default: + return nil, fmt.Errorf("expected JSON array or object, got %T", raw) + } +} + +func decodeMonitorArray(values []any) ([]Monitor, error) { + monitors := make([]Monitor, 0, len(values)) + for i, raw := range values { + obj, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("monitor %d: expected object, got %T", i, raw) + } + monitor, err := decodeMonitorObject(obj) + if err != nil { + return nil, fmt.Errorf("monitor %d: %w", i, err) + } + monitors = append(monitors, monitor) + } + return monitors, nil +} + +func decodeMonitorObject(obj map[string]any) (Monitor, error) { + data, err := json.Marshal(obj) + if err != nil { + return Monitor{}, err + } + var monitor pupMonitor + if err := json.Unmarshal(data, &monitor); err != nil { + return Monitor{}, err + } + id, err := parseMonitorID(monitor.ID) + if err != nil { + return Monitor{}, err + } + if monitor.Tags == nil { + monitor.Tags = []string{} + } + return Monitor{ + ID: id, + Name: monitor.Name, + Tags: monitor.Tags, + URL: monitor.URL, + }, nil +} + +func parseMonitorID(raw json.RawMessage) (int64, error) { + if len(raw) == 0 { + return 0, fmt.Errorf("missing id") + } + var numeric int64 + if err := json.Unmarshal(raw, &numeric); err == nil { + return numeric, nil + } + var text string + if err := json.Unmarshal(raw, &text); err != nil { + return 0, fmt.Errorf("id must be number or string") + } + parsed, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64) + if err != nil { + return 0, fmt.Errorf("id %q must be numeric", text) + } + return parsed, nil +} + +func filterMonitors(monitors []Monitor, spec *manifest.DatadogDiscovery) []Monitor { + out := make([]Monitor, 0, len(monitors)) + for _, monitor := range monitors { + if resourceMatches(monitor.Tags, spec) { + out = append(out, monitor) + } + } + return out +} + +func monitorAppURL(site string, monitorID int64) string { + if strings.TrimSpace(site) == "" { + site = defaultSite + } + return fmt.Sprintf("https://app.%s/monitors/%d", site, monitorID) +} + +func monitorPupCommand() string { + if command := strings.TrimSpace(os.Getenv("HADO_DATADOG_PUP_MONITOR_COMMAND")); command != "" { + return command + } + return pupExecutable() +} + +func monitorPupArgs() []string { + if args := strings.TrimSpace(os.Getenv("HADO_DATADOG_PUP_MONITOR_ARGS")); args != "" { + return strings.Fields(args) + } + if args := strings.TrimSpace(os.Getenv("HADO_DATADOG_PUP_ARGS")); args != "" { + return strings.Fields(args) + } + return []string{"datadog", "monitor", "list", "--format", "json"} +} diff --git a/modules/datadog/slo.go b/modules/datadog/slo.go new file mode 100644 index 0000000..89d8be8 --- /dev/null +++ b/modules/datadog/slo.go @@ -0,0 +1,178 @@ +package datadog + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/keyskey/hado/internal/manifest" + "github.com/keyskey/hado/internal/modulerunner" +) + +// SLO is the subset of Datadog SLO data needed for discovery. +type SLO struct { + ID string + Name string + Tags []string + URL string +} + +type pupSLO struct { + ID json.RawMessage `json:"id"` + Name string `json:"name"` + Tags []string `json:"tags"` + URL string `json:"url"` +} + +func discoverSLOs(spec *manifest.DatadogDiscovery) ([]modulerunner.ObservabilityRef, error) { + slos, err := listSLOs(sloPupCommand(), sloPupArgs()) + if err != nil { + return nil, err + } + matched := filterSLOs(slos, spec) + return observabilityRefsFromMatches("slo", matched, func(s SLO) string { + return s.Name + }, func(s SLO) string { + url := strings.TrimSpace(s.URL) + if url == "" { + url = sloAppURL(site(), s.ID) + } + return url + }), nil +} + +func listSLOs(command string, args []string) ([]SLO, error) { + cmd := exec.Command(command, args...) + data, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("run pup slo: %w: %s", err, strings.TrimSpace(string(exitErr.Stderr))) + } + return nil, fmt.Errorf("run pup slo: %w", err) + } + slos, err := decodePupSLOs(data) + if err != nil { + return nil, fmt.Errorf("decode pup slos: %w", err) + } + return slos, nil +} + +func decodePupSLOs(data []byte) ([]SLO, error) { + var raw any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + return slosFromRaw(raw) +} + +func slosFromRaw(raw any) ([]SLO, error) { + switch value := raw.(type) { + case []any: + return decodeSLOArray(value) + case map[string]any: + for _, key := range []string{"slos", "results", "data"} { + if nested, ok := value[key]; ok { + return slosFromRaw(nested) + } + } + slo, err := decodeSLOObject(value) + if err != nil { + return nil, err + } + return []SLO{slo}, nil + default: + return nil, fmt.Errorf("expected JSON array or object, got %T", raw) + } +} + +func decodeSLOArray(values []any) ([]SLO, error) { + slos := make([]SLO, 0, len(values)) + for i, raw := range values { + obj, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("slo %d: expected object, got %T", i, raw) + } + slo, err := decodeSLOObject(obj) + if err != nil { + return nil, fmt.Errorf("slo %d: %w", i, err) + } + slos = append(slos, slo) + } + return slos, nil +} + +func decodeSLOObject(obj map[string]any) (SLO, error) { + data, err := json.Marshal(obj) + if err != nil { + return SLO{}, err + } + var slo pupSLO + if err := json.Unmarshal(data, &slo); err != nil { + return SLO{}, err + } + id, err := parseSLOID(slo.ID) + if err != nil { + return SLO{}, err + } + if slo.Tags == nil { + slo.Tags = []string{} + } + return SLO{ + ID: id, + Name: slo.Name, + Tags: slo.Tags, + URL: slo.URL, + }, nil +} + +func parseSLOID(raw json.RawMessage) (string, error) { + if len(raw) == 0 { + return "", fmt.Errorf("missing id") + } + var text string + if err := json.Unmarshal(raw, &text); err == nil { + if trimmed := strings.TrimSpace(text); trimmed != "" { + return trimmed, nil + } + return "", fmt.Errorf("empty id") + } + var numeric int64 + if err := json.Unmarshal(raw, &numeric); err == nil { + return strconv.FormatInt(numeric, 10), nil + } + return "", fmt.Errorf("id must be string or number") +} + +func filterSLOs(slos []SLO, spec *manifest.DatadogDiscovery) []SLO { + out := make([]SLO, 0, len(slos)) + for _, slo := range slos { + if resourceMatches(slo.Tags, spec) { + out = append(out, slo) + } + } + return out +} + +func sloAppURL(site, sloID string) string { + if strings.TrimSpace(site) == "" { + site = defaultSite + } + return fmt.Sprintf("https://app.%s/slo?slo_id=%s", site, sloID) +} + +func sloPupCommand() string { + if command := strings.TrimSpace(os.Getenv("HADO_DATADOG_PUP_SLO_COMMAND")); command != "" { + return command + } + return pupExecutable() +} + +func sloPupArgs() []string { + if args := strings.TrimSpace(os.Getenv("HADO_DATADOG_PUP_SLO_ARGS")); args != "" { + return strings.Fields(args) + } + return []string{"datadog", "slo", "list", "--format", "json"} +} diff --git a/modules/gobce/module.go b/modules/gobce/module.go index 6e64b00..308378a 100644 --- a/modules/gobce/module.go +++ b/modules/gobce/module.go @@ -15,6 +15,7 @@ import ( const ( defaultCoverprofile = "coverage.out" defaultGobceCommand = "gobce" + analyzeOperation = "coverage.analyze" ) type gobceResult struct { @@ -39,7 +40,7 @@ func run(stdin io.Reader, stdout io.Writer) error { if request.APIVersion != modulerunner.APIVersion { return fmt.Errorf("apiVersion must be %q", modulerunner.APIVersion) } - if request.Operation != "coverage.analyze" { + if request.Operation != analyzeOperation { return fmt.Errorf("unsupported operation %q", request.Operation) } @@ -47,11 +48,11 @@ func run(stdin io.Reader, stdout io.Writer) error { if err != nil { return err } - metrics := coverage.Metrics{ - C0Coverage: &result.StatementCoverage, - C1Coverage: &result.EstimatedBranchCoverage, + measurement := coverage.Measurement{ + C0: &result.StatementCoverage, + C1: &result.EstimatedBranchCoverage, } - if err := metrics.Validate(); err != nil { + if err := measurement.ValidateComplete(); err != nil { return err }