diff --git a/.cursor/rules/internal-go-packages.mdc b/.cursor/rules/internal-go-packages.mdc index 7c45cb6..300d948 100644 --- a/.cursor/rules/internal-go-packages.mdc +++ b/.cursor/rules/internal-go-packages.mdc @@ -12,6 +12,7 @@ New or refactored code under `internal/` should follow this structure so package - **Only** the package comment and `package `. No types, constants, functions, or imports. - One sentence describing what the package does; expand in prose if needed. +- For multi-file packages, include a **layout table** listing each file's role (see `internal/manifest/doc.go`). ## Split the rest by responsibility @@ -19,10 +20,11 @@ Use small, purpose-named files (not a second catch-all `doc.go`): | File (examples) | Contents | |-----------------|----------| -| `types.go` | Exported structs and package-level constants | -| `load.go` | Reading from disk / config (YAML, JSON, etc.) | +| `types.go` | Exported structs and package-level constants (**no methods**) | +| `io.go` | Load / Save / parse (disk + codec) | | `validate.go` | `Validate` and related checks | -| `.go` | Focused logic (e.g. `profile.go`, `parse.go`, `specs.go`, `evaluate.go`, `inputs.go`) | +| `.go` | One file per evidence domain; gate helpers and domain logic | +| `_yaml.go` | Custom `UnmarshalYAML` when needed (optional) | Prefer **one main concern per file**. If a file grows large, split on boundaries (e.g. parsing vs CLI/spec helpers) rather than merging unrelated APIs. @@ -31,11 +33,13 @@ 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/manifest` — `types.go`, `load.go`, `validate.go`, `inputs.go` +- `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) ## Do not - Put implementation in `doc.go`. - 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. diff --git a/README.md b/README.md index d2a8a27..d8f3c4b 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ TTY で実行すると、現在の manifest の値をデフォルトにしなが `hado` は `target` / `charge` / `fire` の 3 コマンドで運用します。 - `hado target`: service / standard と evidence のひな形を manifest に書く -- `hado charge`: evidence の参照(例: coverage artifact の adapter/path)を manifest にマージする +- `hado charge`: evidence の参照(例: coverage artifact の adapter/path)を 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 fire` の終了コードは `0`(ready)、`1`(blocked)、`2`(error)です。`BLOCKED` のときは CI で扱いやすいように 1 で終了します。 @@ -108,4 +108,4 @@ gobce analyze --coverprofile coverage.out --format json --output gobce.json --manifest hado.yaml ``` -`hado charge` の `--coverage-input` は manifest の `evidence.coverage.inputs` へ不足分をマージします。既存値は置換しません。 +`hado charge` の `--coverage-input` は manifest の `evidence.coverage.inputs` へ不足分をマージします。既存値は置換しません。`--datadog-discover` を付けたときは、`evidence.observability.monitor.discovery.datadog` が必須で、失敗時(0 件・複数件・認証エラーなど)は exit 2 としその時点の manifest ファイルは更新されません(同一コマンドで coverage マージも済んでいても保存されません)。 diff --git a/cmd/hado/charge/datadog.go b/cmd/hado/charge/datadog.go new file mode 100644 index 0000000..8869770 --- /dev/null +++ b/cmd/hado/charge/datadog.go @@ -0,0 +1,173 @@ +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 new file mode 100644 index 0000000..1a60b8a --- /dev/null +++ b/cmd/hado/charge/datadog_discover_test.go @@ -0,0 +1,125 @@ +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 0264d0c..49abb9a 100644 --- a/cmd/hado/charge/run.go +++ b/cmd/hado/charge/run.go @@ -1,9 +1,11 @@ package charge import ( + "context" "flag" "fmt" "io" + "net/http" "path/filepath" "strings" @@ -31,6 +33,7 @@ func Run(args []string, stdout, stderr io.Writer) (int, error) { standardsDir := fs.String("standards-dir", "", "directory containing .yaml standards (default: /standards)") var coverageInputs stringList fs.Var(&coverageInputs, "coverage-input", "coverage input as :; merges into manifest coverage inputs; adapters: hado-json, go-coverprofile, gobce-json") + 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 } @@ -59,6 +62,13 @@ func Run(args []string, stdout, stderr io.Writer) (int, error) { if err := mergeCoverageInputs(&m, coverageInputs); err != nil { return 2, err } + + if *datadogDiscover { + if err := runDatadogDiscover(context.Background(), &m, http.DefaultClient, ""); err != nil { + return 2, err + } + } + if requiresCoverage(st) && len(m.CoverageAdapterInputs()) == 0 { return 2, fmt.Errorf("charge requires --coverage-input or manifest evidence.coverage.inputs for coverage gates") } diff --git a/cmd/hado/fire/observability_test.go b/cmd/hado/fire/observability_test.go index 79152a9..0ef8c84 100644 --- a/cmd/hado/fire/observability_test.go +++ b/cmd/hado/fire/observability_test.go @@ -20,15 +20,18 @@ gates: manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 evidence: observability: - slos: + slo: - name: slo url: https://app.datadoghq.com/slo/manage?slo_id=x - monitors: + discovery_type: manual + monitor: - name: mon url: https://app.datadoghq.com/monitors/1 - dashboards: + discovery_type: manual + dashboard: - name: dash url: https://example.com/board/1 + discovery_type: manual `) var stdout, stderr bytes.Buffer @@ -46,3 +49,48 @@ evidence: t.Fatalf("stdout = %q, want observability.dashboard_exists in output", stdout.String()) } } + +func TestFireReadyWithObservabilityAutoRefMonitorOnly(t *testing.T) { + dir := t.TempDir() + standardPath := writeFile(t, dir, "standard.yaml", `id: test +gates: + - id: observability.slo_exists + required: true + - id: observability.monitor_exists + required: true + - id: observability.dashboard_exists + required: true +`) + manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 +evidence: + observability: + slo: + - name: slo + url: https://app.datadoghq.com/slo/manage?slo_id=x + discovery_type: manual + monitor: + refs: + - url: https://app.datadoghq.com/monitors/42 + name: prod error rate + discovery_type: auto + dashboard: + - name: dash + url: https://example.com/board/1 + discovery_type: manual +`) + + var stdout, stderr bytes.Buffer + exitCode, err := Run([]string{ + "--standard", standardPath, + "--manifest", manifestPath, + }, &stdout, &stderr) + if err != nil { + t.Fatalf("run fire: %v", err) + } + if exitCode != 0 { + t.Fatalf("exit code = %d, want 0; stderr=%q", exitCode, stderr.String()) + } + if !strings.Contains(stdout.String(), "observability.monitor_exists") { + t.Fatalf("stdout = %q, want observability.monitor_exists in output", stdout.String()) + } +} diff --git a/cmd/hado/fire/run.go b/cmd/hado/fire/run.go index 82d926f..0eb6a42 100644 --- a/cmd/hado/fire/run.go +++ b/cmd/hado/fire/run.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "path/filepath" - "strings" "github.com/keyskey/hado/internal/coverage" "github.com/keyskey/hado/internal/gate" @@ -88,20 +87,15 @@ func requiresCoverage(readinessStandard standard.Standard) bool { } func applyManifestEvidence(metrics *gate.Metrics, hadoManifest manifest.Manifest) { - if op := hadoManifest.Evidence.Operations; op != nil { - metrics.OperationsOwner = strings.TrimSpace(op.Owner) - metrics.OperationsRunbook = strings.TrimSpace(op.Runbook) - } - if obs := hadoManifest.Evidence.Observability; obs != nil { - metrics.ObservabilitySLOPresent = manifest.ObservabilityLinksHaveURL(obs.SLOs) - metrics.ObservabilityMonitorsPresent = manifest.ObservabilityLinksHaveURL(obs.Monitors) - metrics.ObservabilityDashboardPresent = manifest.ObservabilityLinksHaveURL(obs.Dashboards) - } - if rel := hadoManifest.Evidence.Release; rel != nil { - metrics.ReleaseRollbackPlan = strings.TrimSpace(rel.RollbackPlan) - metrics.ReleaseAutomationDeclared = rel.AutomationDeclared() - } - if inf := hadoManifest.Evidence.Infra; inf != nil { - metrics.InfraDeploymentSpec = strings.TrimSpace(inf.DeploymentSpec) + ev := hadoManifest.Evidence + metrics.OperationsOwner = ev.Operations.OwnerForGate() + metrics.OperationsRunbook = ev.Operations.RunbookForGate() + if obs := ev.Observability; obs != nil { + metrics.ObservabilitySLOPresent = obs.SLOExistForGate() + metrics.ObservabilityMonitorsPresent = obs.MonitorExistForGate() + metrics.ObservabilityDashboardPresent = obs.DashboardExistForGate() } + metrics.ReleaseRollbackPlan = ev.Release.RollbackPlanForGate() + metrics.ReleaseAutomationDeclared = ev.Release.AutomationExistForGate() + metrics.InfraDeploymentSpec = ev.Infra.DeploymentSpecForGate() } diff --git a/cmd/hado/manifestcmd/run.go b/cmd/hado/manifestcmd/run.go index 0f5717f..23d237e 100644 --- a/cmd/hado/manifestcmd/run.go +++ b/cmd/hado/manifestcmd/run.go @@ -7,7 +7,7 @@ import ( "io" "os" - "github.com/keyskey/hado/internal/manifest" + "github.com/keyskey/hado/internal/manifest/refdoc" ) // Run handles "hado manifest doc" and related subcommands. @@ -36,7 +36,7 @@ func Run(args []string, stdout, stderr io.Writer) (int, error) { defer f.Close() w = f } - if err := manifest.WriteManifestReferenceYAML(w); err != nil { + if err := refdoc.Write(w); err != nil { return 2, err } return 0, nil diff --git a/docs/README.md b/docs/README.md index 206d6f7..e7d18b3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,38 +10,42 @@ Project HADO は、サービスを本番環境へリリースする前に「本 Project HADO が目指すゴール、コンセプト、プロダクト原則、用語、non-goals など、最もハイレベルな前提をまとめる。 -2. [アーキテクチャ](architecture.md) +1. [アーキテクチャ](architecture.md) HADO 本体の論理アーキテクチャ、技術選定、**CLI の 3 段階(`target` / `charge` / `fire`)**、開発者体験、HADO Manifest、Readiness Standard、Module / Plugin アーキテクチャ、およびこのリポジトリの `internal` パッケージ構成をまとめる。 -3. [Production Readiness の計測と評価](production-readiness-evaluation.md) +1. [Production Readiness の計測と評価](production-readiness-evaluation.md) Production Readiness をどのカテゴリ・gate・evidence・decision として評価するかをまとめる。 -4. [開発計画とロードマップ](roadmap.md) +1. [開発計画とロードマップ](roadmap.md) MVP、開発ステップ、First Implementation Bias、フェーズごとの成果物をまとめる。 -5. [実装状況](implementation-status.md) +1. [実装状況](implementation-status.md) コードとの対応を手で保守する。実装変更時は Cursor Rule `hado-implementation-docs` と Skill `hado-doc-sync` に従い更新する。 -6. [HADO Manifest 参考 YAML(コメント付き; `make gen-manifest-doc`)](hado.manifest.reference.yaml) +1. [HADO Manifest 参考 YAML(コメント付き; `make gen-manifest-doc`)](hado.manifest.reference.yaml) `internal/manifest` の型と `field_docs.go` から生成する。手編集しない。 -7. [Go C1 カバレッジ計測ツール](gobce.md) +1. [Go C1 カバレッジ計測ツール](gobce.md) 最初から別リポジトリとして開発する `gobce` の目的、スコープ、HADO 連携方針をまとめる。 -8. [Infrastructure Readiness とマニフェスト設計](infrastructure-readiness-and-manifest-design.md) +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 の形)をまとめる。 -9. [未解決課題](open-design-decisions.md) +1. [未解決課題](open-design-decisions.md) Open Design Decisions、未解決の命名・設計・実装方針をまとめる。 -10. [ローカル開発コマンド](local-development.md) +1. [ローカル開発コマンド](local-development.md) `Makefile` で提供している `lint` / `format` / `test` 系コマンドと事前準備をまとめる。 diff --git a/docs/hado.manifest.reference.yaml b/docs/hado.manifest.reference.yaml index 27a87c7..1b11fe1 100644 --- a/docs/hado.manifest.reference.yaml +++ b/docs/hado.manifest.reference.yaml @@ -1,6 +1,6 @@ # HADO manifest reference — GENERATED FILE; do not edit by hand. # Regenerate: make gen-manifest-doc (or: go run ./cmd/hado manifest doc --out docs/hado.manifest.reference.yaml) -# Types: internal/manifest/types.go Descriptions: internal/manifest/field_docs.go +# Types: internal/manifest/types.go Descriptions: internal/manifest/refdoc/field_docs.go # Manifest スキーマの版。現行は `v1` を使う。 (論理型: string) version: "v1" @@ -30,26 +30,80 @@ evidence: owner: "" # Runbook の URL またはパス。`operations.runbook_exists` で非空判定。 (論理型: string) runbook: "" - # 観測可能性の証跡(ブロック)。SLO / モニター / ダッシュボードは **ベンダー UI 等で辿れる URL** のリストで宣言する(監査・運用オペ向け)。 (論理型: object) + # 観測可能性の証跡(ブロック)。`slo` / `monitor` / `dashboard` を **役割ごと**に分け、各レーンに `refs`(URL 一覧)・任意 `provider`・`discovery` を持てる。 (論理型: object) observability: - # SLO / SLI への名前付きリンクの配列。`observability.slo_exists` はいずれか 1 件の `url`(trim 後非空)で PASS。 (論理型: array of object) - slos: - # 人間可読な表示名(任意)。 (論理型: string) - - name: "" - # ブラウザで開ける SLO の URL(例: Datadog SLO の管理画面)。 (論理型: string) - url: "" - # モニターへの名前付きリンクの配列。`observability.monitor_exists` はいずれか 1 件の `url` で PASS。 (論理型: array of object) - monitors: - # 人間可読な表示名(任意)。 (論理型: string) - - name: "" - # モニターの URL(例: Datadog monitor)。 (論理型: string) - url: "" - # ダッシュボードへの名前付きリンクの配列。`observability.dashboard_exists` はいずれか 1 件の `url` で PASS。 (論理型: array of object) - dashboards: - # 人間可読な表示名(任意)。 (論理型: string) - - name: "" - # ダッシュボードの URL。 (論理型: string) - url: "" + # SLO / SLI レーン(オブジェクト)。YAML では **配列だけ**(= `refs` の省略形、`discovery_type` は `manual`)も受け付ける。`observability.slo_exists` は `refs` に有効な `url` があれば PASS。 (論理型: object) + slo: + # このレーンの外部プロバイダ名(任意。例: `datadog`)。 (論理型: string) + provider: "datadog" + # charge がこのレーンで資源探索するときの条件(1 パターン)。MVP の Datadog 自動解決は monitor レーンのみ実装。 (論理型: object) + discovery: + # Datadog API 向け探索条件(フィールドは Monitor 由来; SLO 自動解決は未実装でも manifest に先行記述可)。 (論理型: object) + datadog: + # `hado:*` タグ。すべて一致するモニター等に絞る(最優先)。 (論理型: array of string) + hado_tags: [] + # `service:…` タグの値(未指定時は manifest `service.name` を利用しうる)。 (論理型: string) + service: "orders" + # `env:…` タグの値。 (論理型: string) + env: "prod" + # 名前の部分一致(フォールバック)。 (論理型: string) + name_contains: "error rate" + # 証跡 URL の一覧。`discovery_type` で手入力(`manual`)と charge 自動解決(`auto`)を区別。 (論理型: array of object) + refs: + # 表示名(任意)。 (論理型: string) + - name: "example resource" + # ブラウザで開ける URL。 (論理型: string) + 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: + # このレーンの外部プロバイダ名(任意。例: `datadog`)。 (論理型: string) + provider: "datadog" + # Monitor 探索の条件(1 パターン)。`--datadog-discover` 時に必須。 (論理型: object) + discovery: + # `GET /api/v1/monitor` で列挙した一覧に対する絞り込み条件。 (論理型: object) + datadog: + # `hado:*` タグすべて一致(最優先)。 (論理型: array of string) + hado_tags: [] + # `service:…`(未指定時 manifest `service.name` 可)。 (論理型: string) + service: "orders" + # `env:…`。 (論理型: string) + env: "prod" + # モニター名部分一致。 (論理型: string) + name_contains: "error rate" + # 証跡 URL の一覧。 (論理型: array of object) + refs: + # 表示名(任意)。 (論理型: string) + - name: "example resource" + # モニター UI URL。 (論理型: string) + url: "https://app.datadoghq.com/monitors/12345" + # `manual` または `auto`。 (論理型: string) + discovery_type: "auto" + # ダッシュボードレーン。配列省略形 = `refs`。 (論理型: object) + dashboard: + # このレーンの外部プロバイダ名(任意)。 (論理型: string) + provider: "datadog" + # 探索条件(ダッシュボード自動解決は未実装)。 (論理型: object) + discovery: + # Datadog 向け探索条件プレースホルダ(フィールド形は共通)。 (論理型: object) + datadog: + # 将来の discovery 用。 (論理型: array of string) + hado_tags: [] + # 同上。 (論理型: string) + service: "orders" + # 同上。 (論理型: string) + env: "prod" + # 同上。 (論理型: string) + name_contains: "error rate" + # 証跡 URL の一覧。 (論理型: array of object) + refs: + # 表示名(任意)。 (論理型: string) + - name: "example resource" + # URL。 (論理型: string) + url: "https://app.datadoghq.com/dashboard/abc" + # `manual` または `auto`。 (論理型: string) + discovery_type: "manual" # インフラ関連の参照(ブロック)。 (論理型: object) infra: # デプロイ仕様の参照(パス・URL・カタログ ID)。`infra.deployment_spec_exists`。 (論理型: string) diff --git a/docs/implementation-status.md b/docs/implementation-status.md index a487eb4..cc44f5b 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -9,7 +9,7 @@ | 引数なし | 一行ヘルプ | | `version` / `-v` / `--version` | 実装済み | | `target` | 実装済み(`--manifest` 必須。TTY / フラグで `service` / `standard`。既定で resolved standard に応じ **evidence のスキャフォールド**(空文字のキーなど)をマージ) | -| `charge` | 実装済み(`--manifest` 必須。coverage artifact の adapter/path を manifest `evidence.coverage.inputs` に不足分マージ。既存値は置換しない) | +| `charge` | 実装済み(`--manifest` 必須。coverage artifact の adapter/path を manifest `evidence.coverage.inputs` に不足分マージ。既存値は置換しない。**`--datadog-discover`** で Datadog Monitor を探索し **`evidence.observability.monitor.refs`** に `discovery_type: auto` を upsert) | | `fire` | 実装済み(`--manifest` 必須。判定専用。manifest の 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`) | @@ -24,13 +24,14 @@ - `--standards-dir`(任意; スキャフォールド用の standard YAML を探すディレクトリ。既定は manifest と同じ階層の `standards/`) - `--rewrite-placeholders`(既定 `true`。`false` で service/standard のみ更新し evidence は触らない) -`fire` は manifest の該当フィールドが **未設定** のとき、existence gate では失敗とみなします。文字列証跡は **前後の空白を除いた長さ 0**、 observability の `slos` / `monitors` / `dashboards` は **いずれの要素にも非空の `url` が無い** 場合に未設定です。 +`fire` は manifest の該当フィールドが **未設定** のとき、existence gate では失敗とみなします。Observability は各レーン(`slo` / `monitor` / `dashboard`)について **`refs` に trim 後非空の `url` を持つ要素が無い** 場合に未設定です。 `charge` の主なフラグ(`cmd/hado/charge/run.go`): - `--manifest`(必須) - `--standard`(任意。未指定時は manifest の `standard.id` を利用) - `--coverage-input`(繰り返し可; `:`。**指定時も既存 manifest 値は置換せず不足分だけマージ**) +- `--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`): @@ -58,9 +59,9 @@ required として宣言されているが、ここに無い gate id は **error - `test.c1_coverage` - `operations.owner_exists` - `operations.runbook_exists` -- `observability.slo_exists`(`evidence.observability.slos` のいずれかに trim 後非空の `url`) -- `observability.monitor_exists`(`evidence.observability.monitors` に同上) -- `observability.dashboard_exists`(`evidence.observability.dashboards` に同上) +- `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` は任意のメタデータで現ゲートでは未使用) @@ -91,17 +92,22 @@ required として宣言されているが、ここに無い gate id は **error ## Manifest(`internal/manifest`) -Manifest の **全プロパティが列挙された参考 YAML**(各キーにコメントで説明・論理型)は [hado.manifest.reference.yaml](hado.manifest.reference.yaml)(`make gen-manifest-doc` / `hado manifest doc`)。`types.go` を変えたら `field_docs.go` を更新し **再生成後**に `internal/manifest` の次のテストが通ること: **`TestManifestYAMLDocComplete`**(`field_docs` ↔ 型)、**`TestWriteManifestReferenceYAML_loads`**(生成物が `Load` 相当でパース可能)、**`TestCommittedReferenceYAMLMatchesGenerator`**(コミット済み YAML と生成器の完全一致)。 +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`(`slos` / `monitors` / `dashboards` …各リストの **いずれかの要素に `url`(trim 後非空)** があれば該当 gate PASS。監査・オペではベンダー UI 等へ直接辿れる URL を想定) +- `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.inputs`(`adapter`, `path`) +## 外部連携(`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)。コードにまだ無い例: -- module runner、インフラ向け threshold 型 gate(例: PDB の数値比較)、Markdown レポート、GitHub PR 連携 +- module runner(JSON-over-stdio 等)、インフラ向け threshold 型 gate(例: PDB の数値比較)、Markdown レポート、GitHub PR 連携 +- Datadog の SLO / Dashboard 自動解決、Rollbar / PagerDuty 等の他ベンダー charge - `test.uncovered_branch` など gobce findings の評価結果への載せ方 diff --git a/docs/infrastructure-readiness-and-manifest-design.md b/docs/infrastructure-readiness-and-manifest-design.md index fbbf2a0..9d72228 100644 --- a/docs/infrastructure-readiness-and-manifest-design.md +++ b/docs/infrastructure-readiness-and-manifest-design.md @@ -158,5 +158,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) - [Production Readiness の計測と評価](production-readiness-evaluation.md) - [未解決課題](open-design-decisions.md) diff --git a/docs/observability-readiness.md b/docs/observability-readiness.md new file mode 100644 index 0000000..71e18b1 --- /dev/null +++ b/docs/observability-readiness.md @@ -0,0 +1,110 @@ +# 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 b447d51..d275d8f 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -9,6 +9,19 @@ Project HADO は、サービスを本番環境へリリースする前に「本 HADO は CI、監視基盤、セキュリティスキャナ、コード品質ツールを置き換えるものではない。それらの上に位置する「信頼性の意思決定レイヤー」である。既存ツールから証拠を集め、リリース準備状態の基準に照らして評価し、リリース可否・理由・次に取るべきアクションを返す。 +## Production Readiness Linker + +(本リポジトリでの位置づけ。)HADO は **監視設定の構文チェッカー**ではなく、**本番準備の証跡を集め・リンクし・ゲートで検証する**ことを目的とする。中心は次の原則である。 + +```text +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 の自動解決を実装) +``` + +ゲート評価(`hado fire`)は Readiness Standard で宣言された **gate ID と閾値**に対し、manifest 由来の **正規化済み値**(数値・非空・宣言の有無など)を照合する。観測可能性まわりのスキーマ・Datadog 連携の具体的な書き方は [Observability readiness と manifest](observability-readiness.md) を参照する。 + ## 最終的に目指すゴール HADO が目指すのは、SRE、Platform Engineering、Security、Compliance、Product Engineering が共通して使える、Production Readiness as Code の標準レイヤーである。 @@ -75,6 +88,7 @@ HADO = 出航前に「撃てる状態」を証明する readiness amplif 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 の実行、adapter による正規化、CI 内の `go test` 成果物の取り込みなどはここに集約されうる。永続の主たる形は **更新後の manifest**(および manifest が指すパス上の artifact)である。 3. hado fire … 発射判定(gate を評価し、リリース可否だけを返す) diff --git a/docs/production-readiness-evaluation.md b/docs/production-readiness-evaluation.md index 3d66550..9628069 100644 --- a/docs/production-readiness-evaluation.md +++ b/docs/production-readiness-evaluation.md @@ -77,6 +77,14 @@ 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))。 + +Phase 2 以降の **assert**(閾値・SLO 目標・ダッシュボードの必須ウィジェット等)は、Policy language(Readiness Standard の YAML)の拡張として別途扱う([未解決課題](open-design-decisions.md))。 + ## Evidence Evidence は、gate 評価に使う証拠である。 @@ -87,13 +95,13 @@ Evidence は、gate 評価に使う証拠である。 coverage.out Go test coverage profile -evidence.observability.slos[].url +evidence.observability.slo.refs[].url(または slo 配列省略形) SLO / SLI をブラウザで開ける URL(例: Datadog SLO 管理画面) -evidence.observability.monitors[].url +evidence.observability.monitor.refs[].url(または monitor 配列省略形) アラート / モニターの URL(例: Datadog monitor) -evidence.observability.dashboards[].url +evidence.observability.dashboard.refs[].url(または dashboard 配列省略形) ダッシュボードの URL Datadog service catalog metadata diff --git a/docs/roadmap.md b/docs/roadmap.md index 033cf61..4e45e05 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -58,7 +58,7 @@ GitHub Actions 上で、Go service の最低限の Production Readiness を評 - coverage adapter 層と C0 / C1 coverage gate(`internal/coverage`, `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`(evidence 参照の補完と判定を分離した 2 段階) +- `hado charge` / `hado fire`(evidence 参照の補完と判定を分離した 2 段階。**Datadog Monitor の discovery** は `charge --datadog-discover` と `internal/integration/datadog`) **CLI 体系:** `hado target` → `hado charge` → `hado fire`。`charge` は既存 manifest 値を保持しながら不足分をマージし、`fire` は manifest 入力のみで判定する。 diff --git a/internal/gate/evaluate.go b/internal/gate/evaluate.go index 36b5f10..3e748d0 100644 --- a/internal/gate/evaluate.go +++ b/internal/gate/evaluate.go @@ -32,13 +32,13 @@ func Evaluate(s standard.Standard, metrics Metrics) (Evaluation, error) { result := evaluateExistsGate(gate, metrics.OperationsRunbook != "", "Operations runbook is defined.", "Operations runbook is not defined.") evaluation.Results = append(evaluation.Results, result) case standard.ObservabilitySLOExistsGateID: - result := evaluateExistsGate(gate, metrics.ObservabilitySLOPresent, "SLO / SLI evidence is defined (at least one URL in evidence.observability.slos).", "SLO / SLI evidence is not defined (add slos[].url).") + result := evaluateExistsGate(gate, metrics.ObservabilitySLOPresent, "SLO / SLI evidence is defined (refs under evidence.observability.slo).", "SLO / SLI evidence is not defined (add slo.refs[].url).") evaluation.Results = append(evaluation.Results, result) case standard.ObservabilityMonitorExistsGateID: - result := evaluateExistsGate(gate, metrics.ObservabilityMonitorsPresent, "Monitor evidence is defined (at least one URL in evidence.observability.monitors).", "Monitor evidence is not defined (add monitors[].url).") + result := evaluateExistsGate(gate, metrics.ObservabilityMonitorsPresent, "Monitor evidence is defined (refs under evidence.observability.monitor).", "Monitor evidence is not defined (add monitor.refs[].url or charge with Datadog discover).") evaluation.Results = append(evaluation.Results, result) case standard.ObservabilityDashboardExistsGateID: - result := evaluateExistsGate(gate, metrics.ObservabilityDashboardPresent, "Dashboard evidence is defined (at least one URL in evidence.observability.dashboards).", "Dashboard evidence is not defined (add dashboards[].url).") + result := evaluateExistsGate(gate, metrics.ObservabilityDashboardPresent, "Dashboard evidence is defined (refs under evidence.observability.dashboard).", "Dashboard evidence is not defined (add dashboard.refs[].url).") evaluation.Results = append(evaluation.Results, result) case standard.InfraDeploymentSpecExistsGateID: result := evaluateExistsGate(gate, metrics.InfraDeploymentSpec != "", "Deployment spec reference is defined.", "Deployment spec reference is not defined.") diff --git a/internal/manifest/README.md b/internal/manifest/README.md new file mode 100644 index 0000000..806d688 --- /dev/null +++ b/internal/manifest/README.md @@ -0,0 +1,114 @@ +# `internal/manifest` 設計ポリシー + +HADO Manifest の **型・読み書き・gate 向けヘルパ・scaffold** を置くパッケージ。 +**gate の判定ロジック本体**は `internal/gate`、**Readiness Standard** は `internal/standard` に置く(ここには持ち込まない)。 + +人間向けの詳細仕様は `docs/`(例: [observability-readiness.md](../../docs/observability-readiness.md))。この README は **コードの置き場と命名のレール**。 + +--- + +## ファイルレイアウト(必須) + +| ファイル | 置くもの | +| --- | --- | +| `doc.go` | package コメントのみ(実装・import 禁止) | +| `types.go` | struct / const **のみ**(メソッド禁止) | +| `io.go` | `Load`, `LoadOrEmpty`, `Save`, YAML parse | +| `validate.go` | `Manifest.Validate` | +| `path.go` | `ResolveStandardPath` | +| `scaffold.go` | `ApplyEvidenceScaffold` | +| `coverage.go` | coverage evidence 向けヘルパ | +| `operations.go` | operations evidence 向けヘルパ | +| `observability.go` | observability evidence 向けヘルパ | +| `observability_yaml.go` | `ObservabilityLaneEvidence` のカスタム `UnmarshalYAML` | +| `infra.go` | infra evidence 向けヘルパ | +| `release.go` | release evidence 向けヘルパ | +| `_test.go` | 上記ドメインのユニットテスト + 必要な Load テスト | +| `io_test.go` / `path_test.go` / `scaffold_test.go` | 横断機能のテスト | +| `refdoc/` | 参考 YAML 生成専用([refdoc/README.md](refdoc/README.md)) | + +### やってはいけないこと + +- `types.go` にメソッドを足す(薄い helper でも `.go` へ) +- evidence ドメインごとのファイルを省略する(ロジックが 3 行でも **必ず** `.go` + `_test.go`) +- gate 判定を `cmd/hado/fire` 等に直書きする(manifest ヘルパ経由にする) +- 参考 YAML 生成をルート package に戻す(`refdoc/` に置く) +- `doc.go` に実装を書く + +--- + +## Evidence ドメインと gate の対応 + +`Evidence` の各ブロックは **1 ファイル 1 ドメイン**。gate id と manifest 実装の対応は次の表が正本。 + +| `evidence.*` ブロック | ファイル | gate id(例) | manifest 側ヘルパ | +| --- | --- | --- | --- | +| `coverage` | `coverage.go` | `test.c0_coverage`, `test.c1_coverage` | `CoverageAdapterInputs`(パス解決) | +| `operations` | `operations.go` | `operations.owner_exists` | `OwnerForGate` / `OwnerExistForGate` | +| | | `operations.runbook_exists` | `RunbookForGate` / `RunbookExistForGate` | +| `observability` | `observability.go` | `observability.slo_exists` | `SLOExistForGate` | +| | | `observability.monitor_exists` | `MonitorExistForGate` | +| | | `observability.dashboard_exists` | `DashboardExistForGate` | +| `infra` | `infra.go` | `infra.deployment_spec_exists` | `DeploymentSpecForGate` / `DeploymentSpecExistForGate` | +| `release` | `release.go` | `release.rollback_plan_exists` | `RollbackPlanForGate` / `RollbackPlanExistForGate` | +| | | `release.automation_declared` | `AutomationExistForGate` | + +構造化ログ・PII マスキング等は **Compliance / Security** で扱う予定。`evidence.observability` に `log` レーンは置かない。 + +`internal/gate/evaluate.go` は **メトリクス**を見るだけ。メトリクスを埋めるのは `cmd/hado/fire` の `applyManifestEvidence` で、そこから **上表のヘルパだけ**を呼ぶ。 + +--- + +## 命名規則 + +### 型(`types.go`) + +- トップレベル evidence ブロック: `OperationsEvidence`, `ObservabilityEvidence`, … +- observability の各レーン: `ObservabilityLaneEvidence`(`slo` / `monitor` / … キーに対応) +- ネスト型: `ReleaseAutomationEvidence` のように **ドメイン + サブ概念 + Evidence** + +### gate 向けメソッド(`.go`) + +- 文字列フィールド: `ForGate() string`(trim 済み。nil レシーバ安全) +- existence gate: `ExistForGate() bool` または `ExistForGate() bool` +- コメントに **対応 gate id** を書く(例: `// OwnerExistForGate implements operations.owner_exists.`) + +### YAML + +- カスタム `UnmarshalYAML` が必要な型だけ `_yaml.go` を追加 +- ファイル名は **ドメイン名**(`observability_yaml.go`)。中間概念名だけのファイル名(例: `role_evidence_yaml.go`)は使わない + +--- + +## 新しい evidence / gate を足すとき + +1. **`types.go`** — struct と YAML タグを追加(メソッドなし) +2. **`.go`** — gate 用ヘルパを追加(薄くてもファイルは作る) +3. **`_test.go`** — ヘルパ + 代表 YAML の Load テスト +4. **`scaffold.go`** — standard に gate があるときのひな形(merge ルールは既存に合わせる) +5. **`internal/gate`** — `Metrics` と `Evaluate`(判定本体) +6. **`cmd/hado/fire/run.go`** — `applyManifestEvidence` から新ヘルパを呼ぶ +7. **`refdoc/field_docs.go`** — 新フィールドの説明 → `make gen-manifest-doc` +8. **`docs/`** — 利用者向け仕様(`hado-doc-sync` Skill 参照) + +observability のように **レーン単位**の拡張は `ObservabilityLaneEvidence` と `observability.go` / `observability_yaml.go` に集約する。 + +--- + +## 横断機能の境界 + +| 関心事 | 置き場 | +| --- | --- | +| ディスク I/O・YAML シリアライズ | `io.go` | +| manifest 内の整合性チェック | `validate.go` | +| standard YAML のパス解決 | `path.go` | +| target が埋める evidence ひな形 | `scaffold.go` | +| コメント付き参考 YAML | `refdoc/` | + +--- + +## 関連 + +- リポジトリ全体の internal パッケージ規約: [.cursor/rules/internal-go-packages.mdc](../../.cursor/rules/internal-go-packages.mdc) +- 実装状況・CLI: [docs/implementation-status.md](../../docs/implementation-status.md) +- Manifest 参考 YAML: [docs/hado.manifest.reference.yaml](../../docs/hado.manifest.reference.yaml)(`make gen-manifest-doc`) diff --git a/internal/manifest/inputs.go b/internal/manifest/coverage.go similarity index 100% rename from internal/manifest/inputs.go rename to internal/manifest/coverage.go diff --git a/internal/manifest/manifest_test.go b/internal/manifest/coverage_test.go similarity index 60% rename from internal/manifest/manifest_test.go rename to internal/manifest/coverage_test.go index a3520b2..257a18f 100644 --- a/internal/manifest/manifest_test.go +++ b/internal/manifest/coverage_test.go @@ -43,34 +43,7 @@ evidence: } } -func TestLoadReturnsOperationsEvidence(t *testing.T) { - manifestPath := filepath.Join(t.TempDir(), "hado.yaml") - if err := os.WriteFile(manifestPath, []byte(`version: v1 -evidence: - operations: - owner: platform-team - runbook: https://example.com/runbooks/order-api -`), 0o600); err != nil { - t.Fatalf("write manifest: %v", err) - } - - hadoManifest, err := Load(manifestPath) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - - if hadoManifest.Evidence.Operations == nil { - t.Fatal("operations evidence is nil") - } - if hadoManifest.Evidence.Operations.Owner != "platform-team" { - t.Fatalf("operations owner = %q, want platform-team", hadoManifest.Evidence.Operations.Owner) - } - if hadoManifest.Evidence.Operations.Runbook != "https://example.com/runbooks/order-api" { - t.Fatalf("operations runbook = %q, want runbook URL", hadoManifest.Evidence.Operations.Runbook) - } -} - -func TestLoadProjectManifest(t *testing.T) { +func TestLoadProjectManifestCoverageInputs(t *testing.T) { hadoManifest, err := Load(filepath.Join("..", "..", "hado.yaml")) if err != nil { t.Fatalf("Load() error = %v", err) @@ -86,15 +59,6 @@ func TestLoadProjectManifest(t *testing.T) { if filepath.Base(inputs[0].Path) != "hado-coverage.json" { t.Fatalf("coverage input path = %q, want hado-coverage.json", inputs[0].Path) } - if hadoManifest.Evidence.Operations == nil { - t.Fatal("operations evidence is nil") - } - if hadoManifest.Evidence.Operations.Owner != "keyskey" { - t.Fatalf("operations owner = %q, want keyskey", hadoManifest.Evidence.Operations.Owner) - } - if hadoManifest.Evidence.Operations.Runbook != "" { - t.Fatalf("operations runbook = %q, want empty", hadoManifest.Evidence.Operations.Runbook) - } } func TestLoadRejectsCoverageInputWithoutAdapter(t *testing.T) { diff --git a/internal/manifest/doc.go b/internal/manifest/doc.go index 23fd438..91a0b9d 100644 --- a/internal/manifest/doc.go +++ b/internal/manifest/doc.go @@ -1,5 +1,19 @@ // Package manifest loads and validates HADO manifests. // -// Reference YAML (docs/hado.manifest.reference.yaml) is generated by WriteManifestReferenceYAML; -// per-field descriptions are in field_docs.go. +// Design policy and file layout: see README.md in this directory. +// +// Layout (one concern per file): +// +// types.go — struct and const definitions only +// io.go — Load, LoadOrEmpty, Save +// validate.go — Manifest.Validate +// path.go — ResolveStandardPath +// scaffold.go — ApplyEvidenceScaffold +// coverage.go — coverage evidence helpers +// operations.go — operations evidence helpers (operations.* gates) +// observability.go — observability evidence helpers (observability.* gates) +// observability_yaml.go — ObservabilityLaneEvidence YAML unmarshaling +// infra.go — infra evidence helpers (infra.* gates) +// release.go — release evidence helpers (release.* gates) +// refdoc/ — reference YAML generation (field_docs, paths, Write) package manifest diff --git a/internal/manifest/field_docs.go b/internal/manifest/field_docs.go deleted file mode 100644 index 0550710..0000000 --- a/internal/manifest/field_docs.go +++ /dev/null @@ -1,45 +0,0 @@ -package manifest - -// manifestYAMLDoc maps dotted YAML paths (as produced by manifestYAMLPaths) to human descriptions. -// Keep in sync with struct fields in types.go; refdoc_test fails if keys drift. -var manifestYAMLDoc = map[string]string{ - "version": "Manifest スキーマの版。現行は `v1` を使う。", - - "service": "評価対象サービスの識別子(ブロック全体は任意)。", - "service.id": "サービス ID。未指定時は `target` で `service.name` と同じにできる。", - "service.name": "サービス名。", - - "standard": "適用する Readiness Standard への参照(ブロック)。", - "standard.id": "Standard のファイル名(例: `web-service.yaml`)またはパス。`standards-dir` / manifest 隣の `standards/` から解決される。", - - "evidence": "本番準備の証跡宣言。ゲートごとに必要なブロックだけでよい(各サブブロックは多くが `omitempty`)。", - - "evidence.coverage": "カバレッジ成果物と adapter(ブロック)。C0/C1 ゲートがある standard で必要。", - "evidence.coverage.inputs": "`CoverageInput` の配列。", - "evidence.coverage.inputs.adapter": "パーサ名。`hado-json` / `go-coverprofile` / `gobce-json` など(実装は `internal/coverage`)。", - "evidence.coverage.inputs.path": "リポジトリまたは manifest 相対の成果物パス。", - - "evidence.operations": "運用責任と障害対応の入口(ブロック)。", - "evidence.operations.owner": "オーナー(チーム名・Slack チャンネル等)。`operations.owner_exists` で非空判定。", - "evidence.operations.runbook": "Runbook の URL またはパス。`operations.runbook_exists` で非空判定。", - - "evidence.observability": "観測可能性の証跡(ブロック)。SLO / モニター / ダッシュボードは **ベンダー UI 等で辿れる URL** のリストで宣言する(監査・運用オペ向け)。", - "evidence.observability.slos": "SLO / SLI への名前付きリンクの配列。`observability.slo_exists` はいずれか 1 件の `url`(trim 後非空)で PASS。", - "evidence.observability.slos.name": "人間可読な表示名(任意)。", - "evidence.observability.slos.url": "ブラウザで開ける SLO の URL(例: Datadog SLO の管理画面)。", - "evidence.observability.monitors": "モニターへの名前付きリンクの配列。`observability.monitor_exists` はいずれか 1 件の `url` で PASS。", - "evidence.observability.monitors.name": "人間可読な表示名(任意)。", - "evidence.observability.monitors.url": "モニターの URL(例: Datadog monitor)。", - "evidence.observability.dashboards": "ダッシュボードへの名前付きリンクの配列。`observability.dashboard_exists` はいずれか 1 件の `url` で PASS。", - "evidence.observability.dashboards.name": "人間可読な表示名(任意)。", - "evidence.observability.dashboards.url": "ダッシュボードの URL。", - - "evidence.infra": "インフラ関連の参照(ブロック)。", - "evidence.infra.deployment_spec": "デプロイ仕様の参照(パス・URL・カタログ ID)。`infra.deployment_spec_exists`。", - - "evidence.release": "リリース・ロールバック(ブロック)。", - "evidence.release.rollback_plan": "ロールバック手順の参照。`release.rollback_plan_exists`。", - "evidence.release.automation": "自動リリースパイプライン(ブロック)。", - "evidence.release.automation.workflow_refs": "ワークフロー識別子のリスト(文字列の配列)。1 件以上非空で `release.automation_declared`。", - "evidence.release.automation.systems": "任意メタデータ(例: `github_actions`)。現行ゲートでは未使用。", -} diff --git a/internal/manifest/infra.go b/internal/manifest/infra.go new file mode 100644 index 0000000..1c28fe8 --- /dev/null +++ b/internal/manifest/infra.go @@ -0,0 +1,16 @@ +package manifest + +import "strings" + +// DeploymentSpecForGate returns trim-normalized deployment spec for infra.deployment_spec_exists. +func (i *InfraEvidence) DeploymentSpecForGate() string { + if i == nil { + return "" + } + return strings.TrimSpace(i.DeploymentSpec) +} + +// DeploymentSpecExistForGate implements infra.deployment_spec_exists. +func (i *InfraEvidence) DeploymentSpecExistForGate() bool { + return i.DeploymentSpecForGate() != "" +} diff --git a/internal/manifest/infra_test.go b/internal/manifest/infra_test.go index 5a3d2cc..c29a838 100644 --- a/internal/manifest/infra_test.go +++ b/internal/manifest/infra_test.go @@ -6,6 +6,16 @@ import ( "testing" ) +func TestInfraEvidenceDeploymentSpecExistForGate(t *testing.T) { + t.Parallel() + if (&InfraEvidence{}).DeploymentSpecExistForGate() { + t.Fatal("want false for empty") + } + if !(&InfraEvidence{DeploymentSpec: " deploy/ "}).DeploymentSpecExistForGate() { + t.Fatal("want true after trim") + } +} + func TestLoadReturnsInfraEvidence(t *testing.T) { manifestPath := filepath.Join(t.TempDir(), "hado.yaml") if err := os.WriteFile(manifestPath, []byte(`version: v1 @@ -27,4 +37,7 @@ evidence: if got := hadoManifest.Evidence.Infra.DeploymentSpec; got != "deploy/" { t.Fatalf("infra.deployment_spec = %q", got) } + if !hadoManifest.Evidence.Infra.DeploymentSpecExistForGate() { + t.Fatal("DeploymentSpecExistForGate() = false, want true") + } } diff --git a/internal/manifest/write.go b/internal/manifest/io.go similarity index 87% rename from internal/manifest/write.go rename to internal/manifest/io.go index e0bbd11..0dc2ef5 100644 --- a/internal/manifest/write.go +++ b/internal/manifest/io.go @@ -9,6 +9,15 @@ import ( "gopkg.in/yaml.v3" ) +// Load reads a HADO manifest from disk. +func Load(path string) (Manifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return Manifest{}, fmt.Errorf("read manifest: %w", err) + } + return parseManifestBytes(data, filepath.Dir(path)) +} + // LoadOrEmpty reads a manifest from path. If the file does not exist, it returns a new // manifest with version v1 and baseDir set from path. Other read or parse errors are returned. func LoadOrEmpty(path string) (Manifest, error) { diff --git a/internal/manifest/write_test.go b/internal/manifest/io_test.go similarity index 69% rename from internal/manifest/write_test.go rename to internal/manifest/io_test.go index e63319c..ddd9a51 100644 --- a/internal/manifest/write_test.go +++ b/internal/manifest/io_test.go @@ -71,3 +71,31 @@ func TestLoadOrEmptyThenSavePreservesEvidence(t *testing.T) { t.Fatalf("loaded = service %+v standard %+v", loaded.Service, loaded.Standard) } } + +func TestSaveUsesTwoSpaceIndent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "hado.yaml") + m := Manifest{ + Version: "v1", + Service: Service{Name: "svc", ID: "svc"}, + Standard: StandardRef{ID: "web-service"}, + } + if err := m.Save(path); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := string(data) + if i := strings.Index(s, "service:\n"); i >= 0 { + after := s[i+len("service:\n"):] + first := strings.SplitN(after, "\n", 2)[0] + if strings.HasPrefix(first, " ") { + t.Fatalf("first line under service should not use 4-space indent; got %q\n%s", first, s) + } + if first != "" && !strings.HasPrefix(first, " ") { + t.Fatalf("first line under service should use 2-space indent; got %q\n%s", first, s) + } + } +} diff --git a/internal/manifest/load.go b/internal/manifest/load.go deleted file mode 100644 index 7d5ffce..0000000 --- a/internal/manifest/load.go +++ /dev/null @@ -1,16 +0,0 @@ -package manifest - -import ( - "fmt" - "os" - "path/filepath" -) - -// Load reads a HADO manifest from disk. -func Load(path string) (Manifest, error) { - data, err := os.ReadFile(path) - if err != nil { - return Manifest{}, fmt.Errorf("read manifest: %w", err) - } - return parseManifestBytes(data, filepath.Dir(path)) -} diff --git a/internal/manifest/observability.go b/internal/manifest/observability.go new file mode 100644 index 0000000..e62cce8 --- /dev/null +++ b/internal/manifest/observability.go @@ -0,0 +1,36 @@ +package manifest + +import "strings" + +// ExistsForGate reports whether this lane satisfies an existence-style observability gate: +// any ref has a non-empty URL. +func (b *ObservabilityLaneEvidence) ExistsForGate() bool { + if b == nil { + return false + } + return observabilityRefsHaveURL(b.Refs) +} + +func observabilityRefsHaveURL(refs []ObservabilityRef) bool { + for _, r := range refs { + if strings.TrimSpace(r.URL) != "" { + return true + } + } + return false +} + +// SLOExistForGate implements observability.slo_exists. +func (o *ObservabilityEvidence) SLOExistForGate() bool { + return o != nil && o.SLO != nil && o.SLO.ExistsForGate() +} + +// MonitorExistForGate implements observability.monitor_exists. +func (o *ObservabilityEvidence) MonitorExistForGate() bool { + return o != nil && o.Monitor != nil && o.Monitor.ExistsForGate() +} + +// DashboardExistForGate implements observability.dashboard_exists. +func (o *ObservabilityEvidence) DashboardExistForGate() bool { + return o != nil && o.Dashboard != nil && o.Dashboard.ExistsForGate() +} diff --git a/internal/manifest/observability_test.go b/internal/manifest/observability_test.go index 76e550c..eeeeea7 100644 --- a/internal/manifest/observability_test.go +++ b/internal/manifest/observability_test.go @@ -11,15 +11,17 @@ func TestLoadReturnsObservabilityEvidence(t *testing.T) { if err := os.WriteFile(manifestPath, []byte(`version: v1 evidence: observability: - slos: + slo: - name: api availability url: https://app.datadoghq.com/slo?slo_id=a - monitors: + discovery_type: manual + monitor: - name: latency url: https://app.datadoghq.com/monitors/1 - dashboards: + dashboard: - name: perf url: https://app.datadoghq.com/dashboard/bbb + discovery_type: manual `), 0o600); err != nil { t.Fatalf("write manifest: %v", err) } @@ -33,13 +35,49 @@ evidence: t.Fatal("observability evidence is nil") } o := hadoManifest.Evidence.Observability - if len(o.SLOs) != 1 || o.SLOs[0].Name != "api availability" || o.SLOs[0].URL != "https://app.datadoghq.com/slo?slo_id=a" { - t.Fatalf("slos = %+v", o.SLOs) + if o.SLO == nil || len(o.SLO.Refs) != 1 || o.SLO.Refs[0].Name != "api availability" || o.SLO.Refs[0].DiscoveryType != ObservabilityDiscoveryManual { + t.Fatalf("slo = %+v", o.SLO) } - if len(o.Monitors) != 1 || o.Monitors[0].URL != "https://app.datadoghq.com/monitors/1" { - t.Fatalf("monitors = %+v", o.Monitors) + if o.Monitor == nil || len(o.Monitor.Refs) != 1 || o.Monitor.Refs[0].URL != "https://app.datadoghq.com/monitors/1" { + t.Fatalf("monitor = %+v", o.Monitor) } - if len(o.Dashboards) != 1 || o.Dashboards[0].URL != "https://app.datadoghq.com/dashboard/bbb" { - t.Fatalf("dashboards = %+v", o.Dashboards) + if o.Monitor.Refs[0].DiscoveryType != ObservabilityDiscoveryManual { + t.Fatalf("monitor ref discovery_type = %q", o.Monitor.Refs[0].DiscoveryType) + } + if o.Dashboard == nil || len(o.Dashboard.Refs) != 1 || o.Dashboard.Refs[0].URL != "https://app.datadoghq.com/dashboard/bbb" { + t.Fatalf("dashboard = %+v", o.Dashboard) + } +} + +func TestMonitorExistForGate_manualRef(t *testing.T) { + o := &ObservabilityEvidence{ + Monitor: &ObservabilityLaneEvidence{ + Refs: []ObservabilityRef{ + {URL: "https://manual.example/mon", DiscoveryType: ObservabilityDiscoveryManual}, + {URL: "https://app.datadoghq.com/monitors/1", DiscoveryType: ObservabilityDiscoveryAuto}, + }, + }, + } + if !o.MonitorExistForGate() { + t.Fatal("want true when monitor.refs has URL") + } +} + +func TestMonitorExistForGate_autoRefOnly(t *testing.T) { + o := &ObservabilityEvidence{ + Monitor: &ObservabilityLaneEvidence{ + Refs: []ObservabilityRef{ + {URL: "https://app.datadoghq.com/monitors/99", DiscoveryType: ObservabilityDiscoveryAuto}, + }, + }, + } + if !o.MonitorExistForGate() { + t.Fatal("want true from auto ref URL") + } +} + +func TestMonitorExistForGate_empty(t *testing.T) { + if (&ObservabilityEvidence{}).MonitorExistForGate() { + t.Fatal("want false") } } diff --git a/internal/manifest/observability_yaml.go b/internal/manifest/observability_yaml.go new file mode 100644 index 0000000..8c27b13 --- /dev/null +++ b/internal/manifest/observability_yaml.go @@ -0,0 +1,59 @@ +package manifest + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +// UnmarshalYAML accepts either a YAML sequence of refs (shorthand: discovery_type defaults to manual) +// or a mapping with refs, provider, and discovery. +func (r *ObservabilityLaneEvidence) UnmarshalYAML(n *yaml.Node) error { + if n == nil { + return fmt.Errorf("observability lane evidence: nil yaml node") + } + switch n.Kind { + case yaml.SequenceNode: + var refs []ObservabilityRef + if err := n.Decode(&refs); err != nil { + return fmt.Errorf("observability lane evidence (list form): %w", err) + } + for i := range refs { + if strings.TrimSpace(refs[i].DiscoveryType) == "" { + refs[i].DiscoveryType = ObservabilityDiscoveryManual + } + } + *r = ObservabilityLaneEvidence{Refs: refs} + return nil + case yaml.MappingNode: + type raw struct { + Provider string `yaml:"provider"` + Discovery *ObservabilityDiscovery `yaml:"discovery"` + Refs []ObservabilityRef `yaml:"refs"` + } + var x raw + if err := n.Decode(&x); err != nil { + return fmt.Errorf("observability lane evidence (mapping form): %w", err) + } + for i := range x.Refs { + if strings.TrimSpace(x.Refs[i].DiscoveryType) == "" { + x.Refs[i].DiscoveryType = ObservabilityDiscoveryManual + } + } + *r = ObservabilityLaneEvidence{ + Provider: x.Provider, + Discovery: x.Discovery, + Refs: x.Refs, + } + return nil + case yaml.ScalarNode: + if n.Tag == "!!null" || n.Value == "" || n.Value == "null" { + *r = ObservabilityLaneEvidence{} + return nil + } + return fmt.Errorf("observability lane evidence: unexpected scalar %q", n.Value) + default: + return fmt.Errorf("observability lane evidence: unsupported yaml kind %v", n.Kind) + } +} diff --git a/internal/manifest/observability_yaml_test.go b/internal/manifest/observability_yaml_test.go new file mode 100644 index 0000000..402bb71 --- /dev/null +++ b/internal/manifest/observability_yaml_test.go @@ -0,0 +1,48 @@ +package manifest + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +func TestObservabilityLaneEvidenceYAML_listForm(t *testing.T) { + const data = `observability: + slo: + - name: a + url: https://example.com/slo +` + var v struct { + Observability struct { + SLO *ObservabilityLaneEvidence `yaml:"slo"` + } `yaml:"observability"` + } + if err := yaml.Unmarshal([]byte(data), &v); err != nil { + t.Fatal(err) + } + s := v.Observability.SLO + if s == nil || len(s.Refs) != 1 || s.Refs[0].URL != "https://example.com/slo" || s.Refs[0].DiscoveryType != ObservabilityDiscoveryManual { + t.Fatalf("got %#v", s) + } +} + +func TestObservabilityLaneEvidenceYAML_mappingForm(t *testing.T) { + const data = `monitor: + refs: + - url: https://example.com/manual + discovery_type: manual + - url: https://app.datadoghq.com/monitors/1 + discovery_type: auto + name: prod error rate +` + var v struct { + Monitor *ObservabilityLaneEvidence `yaml:"monitor"` + } + if err := yaml.Unmarshal([]byte(data), &v); err != nil { + t.Fatal(err) + } + m := v.Monitor + if m == nil || len(m.Refs) != 2 { + t.Fatalf("got %#v", m) + } +} diff --git a/internal/manifest/operations.go b/internal/manifest/operations.go new file mode 100644 index 0000000..3ccde38 --- /dev/null +++ b/internal/manifest/operations.go @@ -0,0 +1,29 @@ +package manifest + +import "strings" + +// OwnerForGate returns trim-normalized owner for operations.owner_exists. +func (o *OperationsEvidence) OwnerForGate() string { + if o == nil { + return "" + } + return strings.TrimSpace(o.Owner) +} + +// RunbookForGate returns trim-normalized runbook for operations.runbook_exists. +func (o *OperationsEvidence) RunbookForGate() string { + if o == nil { + return "" + } + return strings.TrimSpace(o.Runbook) +} + +// OwnerExistForGate implements operations.owner_exists. +func (o *OperationsEvidence) OwnerExistForGate() bool { + return o.OwnerForGate() != "" +} + +// RunbookExistForGate implements operations.runbook_exists. +func (o *OperationsEvidence) RunbookExistForGate() bool { + return o.RunbookForGate() != "" +} diff --git a/internal/manifest/operations_test.go b/internal/manifest/operations_test.go new file mode 100644 index 0000000..7921673 --- /dev/null +++ b/internal/manifest/operations_test.go @@ -0,0 +1,70 @@ +package manifest + +import ( + "os" + "path/filepath" + "testing" +) + +func TestOperationsEvidenceOwnerExistForGate(t *testing.T) { + t.Parallel() + if (&OperationsEvidence{}).OwnerExistForGate() { + t.Fatal("want false for empty") + } + if !(&OperationsEvidence{Owner: " team "}).OwnerExistForGate() { + t.Fatal("want true for non-empty after trim") + } +} + +func TestOperationsEvidenceRunbookExistForGate(t *testing.T) { + t.Parallel() + if !(&OperationsEvidence{Runbook: "https://example.com/rb"}).RunbookExistForGate() { + t.Fatal("want true") + } +} + +func TestLoadReturnsOperationsEvidence(t *testing.T) { + manifestPath := filepath.Join(t.TempDir(), "hado.yaml") + if err := os.WriteFile(manifestPath, []byte(`version: v1 +evidence: + operations: + owner: platform-team + runbook: https://example.com/runbooks/order-api +`), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + + hadoManifest, err := Load(manifestPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if hadoManifest.Evidence.Operations == nil { + t.Fatal("operations evidence is nil") + } + if hadoManifest.Evidence.Operations.Owner != "platform-team" { + t.Fatalf("operations owner = %q, want platform-team", hadoManifest.Evidence.Operations.Owner) + } + if hadoManifest.Evidence.Operations.Runbook != "https://example.com/runbooks/order-api" { + t.Fatalf("operations runbook = %q, want runbook URL", hadoManifest.Evidence.Operations.Runbook) + } + if !hadoManifest.Evidence.Operations.OwnerExistForGate() { + t.Fatal("OwnerExistForGate() = false, want true") + } +} + +func TestLoadProjectManifestOperations(t *testing.T) { + hadoManifest, err := Load(filepath.Join("..", "..", "hado.yaml")) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if hadoManifest.Evidence.Operations == nil { + t.Fatal("operations evidence is nil") + } + if hadoManifest.Evidence.Operations.Owner != "keyskey" { + t.Fatalf("operations owner = %q, want keyskey", hadoManifest.Evidence.Operations.Owner) + } + if hadoManifest.Evidence.Operations.Runbook != "" { + t.Fatalf("operations runbook = %q, want empty", hadoManifest.Evidence.Operations.Runbook) + } +} diff --git a/internal/manifest/standard_path.go b/internal/manifest/path.go similarity index 100% rename from internal/manifest/standard_path.go rename to internal/manifest/path.go diff --git a/internal/manifest/standard_path_test.go b/internal/manifest/path_test.go similarity index 100% rename from internal/manifest/standard_path_test.go rename to internal/manifest/path_test.go diff --git a/internal/manifest/refdoc/README.md b/internal/manifest/refdoc/README.md new file mode 100644 index 0000000..1ded796 --- /dev/null +++ b/internal/manifest/refdoc/README.md @@ -0,0 +1,35 @@ +# `internal/manifest/refdoc` + +**コメント付き参考 Manifest YAML**(`docs/hado.manifest.reference.yaml`)を生成するサブパッケージ。 +ランタイムの `Load` / gate 評価とは分離している。 + +## ファイル + +| ファイル | 役割 | +| --- | --- | +| `field_docs.go` | 各 YAML パス(`evidence.observability.slo.refs.url` 等)の日本語説明 | +| `paths.go` | `manifest/types.go` からリフレクションでパス一覧を生成 | +| `generate.go` | `Write(io.Writer)` — コメント付き YAML を出力 | +| `refdoc_test.go` | `field_docs` ↔ 型の同期、生成物の `manifest.Load` 可能性、コミット済み YAML 一致 | + +## 変更手順 + +1. `internal/manifest/types.go` を変更 +2. **`field_docs.go`** に同じパスの説明を追加(欠けると `TestYAMLDocComplete` が FAIL) +3. サンプル値の変更が必要なら `generate.go` の `referenceStringValue` を更新 +4. リポジトリルートで **`make gen-manifest-doc`** +5. **`go test ./internal/manifest/refdoc/...`** が PASS であること + +## CLI + +```bash +go run ./cmd/hado manifest doc --out docs/hado.manifest.reference.yaml +``` + +`cmd/hado/manifestcmd` は `refdoc.Write` を直接呼ぶ(`manifest` パッケージ経由の re-export はしない)。 + +## やってはいけないこと + +- `field_docs.go` を `manifest` ルートに戻す +- 参考 YAML 生成ロジックを `fire` / `charge` に混ぜる +- `docs/hado.manifest.reference.yaml` を手編集する(必ず生成器経由) diff --git a/internal/manifest/refdoc/doc.go b/internal/manifest/refdoc/doc.go new file mode 100644 index 0000000..5436e0e --- /dev/null +++ b/internal/manifest/refdoc/doc.go @@ -0,0 +1,2 @@ +// Package refdoc generates the commented manifest reference YAML from manifest types and field descriptions. +package refdoc diff --git a/internal/manifest/refdoc/field_docs.go b/internal/manifest/refdoc/field_docs.go new file mode 100644 index 0000000..976dec5 --- /dev/null +++ b/internal/manifest/refdoc/field_docs.go @@ -0,0 +1,75 @@ +package refdoc + +// yamlDoc maps dotted YAML paths (as produced by yamlPaths) to human descriptions. +// Keep in sync with struct fields in manifest/types.go; refdoc_test fails if keys drift. +var yamlDoc = map[string]string{ + "version": "Manifest スキーマの版。現行は `v1` を使う。", + + "service": "評価対象サービスの識別子(ブロック全体は任意)。", + "service.id": "サービス ID。未指定時は `target` で `service.name` と同じにできる。", + "service.name": "サービス名。", + + "standard": "適用する Readiness Standard への参照(ブロック)。", + "standard.id": "Standard のファイル名(例: `web-service.yaml`)またはパス。`standards-dir` / manifest 隣の `standards/` から解決される。", + + "evidence": "本番準備の証跡宣言。ゲートごとに必要なブロックだけでよい(各サブブロックは多くが `omitempty`)。", + + "evidence.coverage": "カバレッジ成果物と adapter(ブロック)。C0/C1 ゲートがある standard で必要。", + "evidence.coverage.inputs": "`CoverageInput` の配列。", + "evidence.coverage.inputs.adapter": "パーサ名。`hado-json` / `go-coverprofile` / `gobce-json` など(実装は `internal/coverage`)。", + "evidence.coverage.inputs.path": "リポジトリまたは manifest 相対の成果物パス。", + + "evidence.operations": "運用責任と障害対応の入口(ブロック)。", + "evidence.operations.owner": "オーナー(チーム名・Slack チャンネル等)。`operations.owner_exists` で非空判定。", + "evidence.operations.runbook": "Runbook の URL またはパス。`operations.runbook_exists` で非空判定。", + + "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.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.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.infra": "インフラ関連の参照(ブロック)。", + "evidence.infra.deployment_spec": "デプロイ仕様の参照(パス・URL・カタログ ID)。`infra.deployment_spec_exists`。", + + "evidence.release": "リリース・ロールバック(ブロック)。", + "evidence.release.rollback_plan": "ロールバック手順の参照。`release.rollback_plan_exists`。", + "evidence.release.automation": "自動リリースパイプライン(ブロック)。", + "evidence.release.automation.workflow_refs": "ワークフロー識別子のリスト(文字列の配列)。1 件以上非空で `release.automation_declared`。", + "evidence.release.automation.systems": "任意メタデータ(例: `github_actions`)。現行ゲートでは未使用。", +} diff --git a/internal/manifest/refyaml.go b/internal/manifest/refdoc/generate.go similarity index 77% rename from internal/manifest/refyaml.go rename to internal/manifest/refdoc/generate.go index 8b217ad..e5fd207 100644 --- a/internal/manifest/refyaml.go +++ b/internal/manifest/refdoc/generate.go @@ -1,16 +1,18 @@ -package manifest +package refdoc import ( "fmt" "io" "reflect" "strings" + + "github.com/keyskey/hado/internal/manifest" ) -// WriteManifestReferenceYAML writes a commented YAML manifest enumerating every supported field. -// Descriptions come from manifestYAMLDoc; shape follows types.go. The output is valid YAML for parseManifestBytes. -func WriteManifestReferenceYAML(w io.Writer) error { - paths, err := manifestYAMLPaths() +// Write emits a commented YAML manifest enumerating every supported field. +// Descriptions come from field_docs.go; shape follows manifest/types.go. +func Write(w io.Writer) error { + paths, err := yamlPaths() if err != nil { return err } @@ -20,7 +22,7 @@ func WriteManifestReferenceYAML(w io.Writer) error { } var b strings.Builder writeYAMLFileHeader(&b) - if err := emitStructYAML(reflect.TypeOf(Manifest{}), "", 0, false, typeByPath, &b); err != nil { + if err := emitStructYAML(reflect.TypeOf(manifest.Manifest{}), "", 0, false, typeByPath, &b); err != nil { return err } _, err = io.WriteString(w, b.String()) @@ -30,7 +32,7 @@ func WriteManifestReferenceYAML(w io.Writer) error { func writeYAMLFileHeader(b *strings.Builder) { b.WriteString("# HADO manifest reference — GENERATED FILE; do not edit by hand.\n") b.WriteString("# Regenerate: make gen-manifest-doc (or: go run ./cmd/hado manifest doc --out docs/hado.manifest.reference.yaml)\n") - b.WriteString("# Types: internal/manifest/types.go Descriptions: internal/manifest/field_docs.go\n") + b.WriteString("# Types: internal/manifest/types.go Descriptions: internal/manifest/refdoc/field_docs.go\n") b.WriteString("\n") } @@ -54,9 +56,9 @@ func emitStructYAML(t reflect.Type, pathPrefix string, level int, parentOmitempt if pathPrefix != "" { path = pathPrefix + "." + y } - doc := manifestYAMLDoc[path] + doc := yamlDoc[path] if strings.TrimSpace(doc) == "" { - return fmt.Errorf("manifestYAMLDoc missing description for path %q", path) + return fmt.Errorf("yamlDoc missing description for path %q", path) } writeYAMLCommentBlock(level, docWithLogicalType(path, doc, typeByPath), b) @@ -121,9 +123,9 @@ func emitSliceOfStructYAML(elem reflect.Type, elemPathPrefix string, level int, for fi, sf := range fields { y := yamlKey(sf.Tag) path := elemPathPrefix + "." + y - doc := manifestYAMLDoc[path] + doc := yamlDoc[path] if strings.TrimSpace(doc) == "" { - return fmt.Errorf("manifestYAMLDoc missing %q", path) + return fmt.Errorf("yamlDoc missing %q", path) } ft := sf.Type for ft.Kind() == reflect.Ptr { @@ -199,9 +201,39 @@ func referenceStringValue(path string) string { return "hado-json" case "evidence.coverage.inputs.path": return "coverage-metrics.json" - default: - return "" } + if strings.HasSuffix(path, ".provider") { + return "datadog" + } + if strings.HasSuffix(path, ".discovery.datadog.service") { + return "orders" + } + 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" + } + if strings.HasSuffix(path, ".refs.discovery_type") { + if strings.Contains(path, ".monitor.") { + return manifest.ObservabilityDiscoveryAuto + } + return manifest.ObservabilityDiscoveryManual + } + if strings.HasSuffix(path, ".refs.url") { + switch { + case strings.Contains(path, ".slo."): + return "https://app.datadoghq.com/slo?slo_id=example" + case strings.Contains(path, ".dashboard."): + return "https://app.datadoghq.com/dashboard/abc" + default: + return "https://app.datadoghq.com/monitors/12345" + } + } + return "" } func writeYAMLCommentBlock(level int, doc string, b *strings.Builder) { diff --git a/internal/manifest/refdoc.go b/internal/manifest/refdoc/paths.go similarity index 92% rename from internal/manifest/refdoc.go rename to internal/manifest/refdoc/paths.go index de28bea..64b511f 100644 --- a/internal/manifest/refdoc.go +++ b/internal/manifest/refdoc/paths.go @@ -1,6 +1,7 @@ -package manifest +package refdoc import ( + "github.com/keyskey/hado/internal/manifest" "reflect" "slices" "strings" @@ -12,9 +13,9 @@ type pathEntry struct { omitempty bool } -func manifestYAMLPaths() ([]pathEntry, error) { +func yamlPaths() ([]pathEntry, error) { var out []pathEntry - walkManifestPaths(reflect.TypeOf(Manifest{}), "", false, &out) + walkManifestPaths(reflect.TypeOf(manifest.Manifest{}), "", false, &out) slices.SortFunc(out, func(a, b pathEntry) int { return strings.Compare(a.path, b.path) }) diff --git a/internal/manifest/refdoc_test.go b/internal/manifest/refdoc/refdoc_test.go similarity index 72% rename from internal/manifest/refdoc_test.go rename to internal/manifest/refdoc/refdoc_test.go index d5336de..4f0b08e 100644 --- a/internal/manifest/refdoc_test.go +++ b/internal/manifest/refdoc/refdoc_test.go @@ -1,4 +1,4 @@ -package manifest +package refdoc import ( "os" @@ -6,44 +6,51 @@ import ( "slices" "strings" "testing" + + "github.com/keyskey/hado/internal/manifest" ) -func TestManifestYAMLDocComplete(t *testing.T) { - paths, err := manifestYAMLPaths() +func TestYAMLDocComplete(t *testing.T) { + paths, err := yamlPaths() if err != nil { t.Fatal(err) } var fromTypes []string for _, p := range paths { fromTypes = append(fromTypes, p.path) - if _, ok := manifestYAMLDoc[p.path]; !ok { - t.Errorf("manifestYAMLDoc missing description for path %q (add to field_docs.go)", p.path) + if _, ok := yamlDoc[p.path]; !ok { + t.Errorf("yamlDoc missing description for path %q (add to field_docs.go)", p.path) } } var orphan []string - for k := range manifestYAMLDoc { + for k := range yamlDoc { if !slices.Contains(fromTypes, k) { orphan = append(orphan, k) } } if len(orphan) > 0 { slices.Sort(orphan) - t.Errorf("manifestYAMLDoc has keys not produced by types walk (remove or fix types): %s", strings.Join(orphan, ", ")) + t.Errorf("yamlDoc has keys not produced by types walk (remove or fix types): %s", strings.Join(orphan, ", ")) } } -func TestWriteManifestReferenceYAML_loads(t *testing.T) { +func TestWriteReferenceYAML_loads(t *testing.T) { var sb strings.Builder - if err := WriteManifestReferenceYAML(&sb); err != nil { + if err := Write(&sb); err != nil { t.Fatal(err) } data := sb.String() if !strings.Contains(data, "version:") || !strings.Contains(data, "evidence:") { t.Fatalf("unexpected output: %s", truncate(data, 200)) } - m, err := parseManifestBytes([]byte(data), t.TempDir()) + dir := t.TempDir() + path := filepath.Join(dir, "ref.yaml") + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + m, err := manifest.Load(path) if err != nil { - t.Fatalf("parseManifestBytes: %v\n---\n%s", err, truncate(data, 800)) + t.Fatalf("Load: %v\n---\n%s", err, truncate(data, 800)) } if m.Version != "v1" { t.Fatalf("Version = %q want v1", m.Version) @@ -61,11 +68,11 @@ func TestCommittedReferenceYAMLMatchesGenerator(t *testing.T) { t.Fatalf("read %s: %v (run from repo root or ensure file exists)", refPath, err) } var gen strings.Builder - if err := WriteManifestReferenceYAML(&gen); err != nil { + if err := Write(&gen); err != nil { t.Fatal(err) } if normalizeEOL(string(committed)) != normalizeEOL(gen.String()) { - t.Fatalf("docs/hado.manifest.reference.yaml is out of sync with WriteManifestReferenceYAML.\n"+ + t.Fatalf("docs/hado.manifest.reference.yaml is out of sync with refdoc.Write.\n"+ "Run from repo root: make gen-manifest-doc\npath: %s", refPath) } } diff --git a/internal/manifest/release.go b/internal/manifest/release.go index 81bc773..43c7aed 100644 --- a/internal/manifest/release.go +++ b/internal/manifest/release.go @@ -2,8 +2,21 @@ package manifest import "strings" -// AutomationDeclared reports whether manifest declares at least one non-empty release workflow reference. -func (r *ReleaseEvidence) AutomationDeclared() bool { +// RollbackPlanForGate returns trim-normalized rollback plan for release.rollback_plan_exists. +func (r *ReleaseEvidence) RollbackPlanForGate() string { + if r == nil { + return "" + } + return strings.TrimSpace(r.RollbackPlan) +} + +// RollbackPlanExistForGate implements release.rollback_plan_exists. +func (r *ReleaseEvidence) RollbackPlanExistForGate() bool { + return r.RollbackPlanForGate() != "" +} + +// AutomationExistForGate implements release.automation_declared. +func (r *ReleaseEvidence) AutomationExistForGate() bool { if r == nil { return false } diff --git a/internal/manifest/release_load_test.go b/internal/manifest/release_load_test.go deleted file mode 100644 index 3e1337d..0000000 --- a/internal/manifest/release_load_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package manifest - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadReturnsReleaseRollbackPlan(t *testing.T) { - manifestPath := filepath.Join(t.TempDir(), "hado.yaml") - if err := os.WriteFile(manifestPath, []byte(`version: v1 -evidence: - release: - rollback_plan: docs/rollback.md -`), 0o600); err != nil { - t.Fatalf("write manifest: %v", err) - } - - hadoManifest, err := Load(manifestPath) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - - if hadoManifest.Evidence.Release == nil { - t.Fatal("release evidence is nil") - } - if got := hadoManifest.Evidence.Release.RollbackPlan; got != "docs/rollback.md" { - t.Fatalf("release.rollback_plan = %q", got) - } -} - -func TestLoadReturnsReleaseAutomationEvidence(t *testing.T) { - manifestPath := filepath.Join(t.TempDir(), "hado.yaml") - if err := os.WriteFile(manifestPath, []byte(`version: v1 -evidence: - release: - automation: - workflow_refs: - - ci/release.yaml - systems: - - argo_workflow -`), 0o600); err != nil { - t.Fatalf("write manifest: %v", err) - } - - hadoManifest, err := Load(manifestPath) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - - if refs := hadoManifest.Evidence.Release.Automation.WorkflowRefs; len(refs) != 1 || refs[0] != "ci/release.yaml" { - t.Fatalf("release.automation.workflow_refs = %#v", refs) - } - if sys := hadoManifest.Evidence.Release.Automation.Systems; len(sys) != 1 || sys[0] != "argo_workflow" { - t.Fatalf("release.automation.systems = %#v", sys) - } - if !hadoManifest.Evidence.Release.AutomationDeclared() { - t.Fatal("AutomationDeclared() = false, want true") - } -} diff --git a/internal/manifest/release_test.go b/internal/manifest/release_test.go index df07dbd..853b374 100644 --- a/internal/manifest/release_test.go +++ b/internal/manifest/release_test.go @@ -1,8 +1,12 @@ package manifest -import "testing" +import ( + "os" + "path/filepath" + "testing" +) -func TestReleaseEvidenceAutomationDeclared(t *testing.T) { +func TestReleaseEvidenceAutomationExistForGate(t *testing.T) { t.Parallel() cases := []struct { name string @@ -18,9 +22,72 @@ func TestReleaseEvidenceAutomationDeclared(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() - if got := tc.rel.AutomationDeclared(); got != tc.want { - t.Fatalf("AutomationDeclared() = %v, want %v", got, tc.want) + if got := tc.rel.AutomationExistForGate(); got != tc.want { + t.Fatalf("AutomationExistForGate() = %v, want %v", got, tc.want) } }) } } + +func TestReleaseEvidenceRollbackPlanExistForGate(t *testing.T) { + t.Parallel() + if !(&ReleaseEvidence{RollbackPlan: " docs/rb.md "}).RollbackPlanExistForGate() { + t.Fatal("want true") + } +} + +func TestLoadReturnsReleaseRollbackPlan(t *testing.T) { + manifestPath := filepath.Join(t.TempDir(), "hado.yaml") + if err := os.WriteFile(manifestPath, []byte(`version: v1 +evidence: + release: + rollback_plan: docs/rollback.md +`), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + + hadoManifest, err := Load(manifestPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if hadoManifest.Evidence.Release == nil { + t.Fatal("release evidence is nil") + } + if got := hadoManifest.Evidence.Release.RollbackPlan; got != "docs/rollback.md" { + t.Fatalf("release.rollback_plan = %q", got) + } + if !hadoManifest.Evidence.Release.RollbackPlanExistForGate() { + t.Fatal("RollbackPlanExistForGate() = false, want true") + } +} + +func TestLoadReturnsReleaseAutomationEvidence(t *testing.T) { + manifestPath := filepath.Join(t.TempDir(), "hado.yaml") + if err := os.WriteFile(manifestPath, []byte(`version: v1 +evidence: + release: + automation: + workflow_refs: + - ci/release.yaml + systems: + - argo_workflow +`), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + + hadoManifest, err := Load(manifestPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if refs := hadoManifest.Evidence.Release.Automation.WorkflowRefs; len(refs) != 1 || refs[0] != "ci/release.yaml" { + t.Fatalf("release.automation.workflow_refs = %#v", refs) + } + if sys := hadoManifest.Evidence.Release.Automation.Systems; len(sys) != 1 || sys[0] != "argo_workflow" { + t.Fatalf("release.automation.systems = %#v", sys) + } + if !hadoManifest.Evidence.Release.AutomationExistForGate() { + t.Fatal("AutomationExistForGate() = false, want true") + } +} diff --git a/internal/manifest/evidence_scaffold.go b/internal/manifest/scaffold.go similarity index 70% rename from internal/manifest/evidence_scaffold.go rename to internal/manifest/scaffold.go index ccebba4..e9fe530 100644 --- a/internal/manifest/evidence_scaffold.go +++ b/internal/manifest/scaffold.go @@ -1,8 +1,6 @@ package manifest import ( - "strings" - "github.com/keyskey/hado/internal/standard" ) @@ -41,13 +39,13 @@ func ApplyEvidenceScaffold(m *Manifest, st standard.Standard, opts ApplyEvidence } if st.RequiresGate(standard.OperationsOwnerExistsGateID) { setOps() - if !(merge && strings.TrimSpace(m.Evidence.Operations.Owner) != "") { + if !(merge && m.Evidence.Operations.OwnerExistForGate()) { m.Evidence.Operations.Owner = "" } } if st.RequiresGate(standard.OperationsRunbookExistsGateID) { setOps() - if !(merge && strings.TrimSpace(m.Evidence.Operations.Runbook) != "") { + if !(merge && m.Evidence.Operations.RunbookExistForGate()) { m.Evidence.Operations.Runbook = "" } } @@ -59,20 +57,38 @@ func ApplyEvidenceScaffold(m *Manifest, st standard.Standard, opts ApplyEvidence } if st.RequiresGate(standard.ObservabilitySLOExistsGateID) { setObs() - if !(merge && ObservabilityLinksHaveURL(m.Evidence.Observability.SLOs)) { - m.Evidence.Observability.SLOs = []ObservabilityLink{{}} + o := m.Evidence.Observability + if merge && o.SLO != nil && o.SLO.ExistsForGate() { + // keep existing slo lane + } else { + if o.SLO == nil { + o.SLO = &ObservabilityLaneEvidence{} + } + o.SLO.Refs = []ObservabilityRef{{DiscoveryType: ObservabilityDiscoveryManual}} } } if st.RequiresGate(standard.ObservabilityMonitorExistsGateID) { setObs() - if !(merge && ObservabilityLinksHaveURL(m.Evidence.Observability.Monitors)) { - m.Evidence.Observability.Monitors = []ObservabilityLink{{}} + o := m.Evidence.Observability + if merge && o.Monitor != nil && o.Monitor.ExistsForGate() { + // keep existing monitor lane + } else { + if o.Monitor == nil { + o.Monitor = &ObservabilityLaneEvidence{} + } + o.Monitor.Refs = []ObservabilityRef{{DiscoveryType: ObservabilityDiscoveryManual}} } } if st.RequiresGate(standard.ObservabilityDashboardExistsGateID) { setObs() - if !(merge && ObservabilityLinksHaveURL(m.Evidence.Observability.Dashboards)) { - m.Evidence.Observability.Dashboards = []ObservabilityLink{{}} + o := m.Evidence.Observability + if merge && o.Dashboard != nil && o.Dashboard.ExistsForGate() { + // keep existing dashboard lane + } else { + if o.Dashboard == nil { + o.Dashboard = &ObservabilityLaneEvidence{} + } + o.Dashboard.Refs = []ObservabilityRef{{DiscoveryType: ObservabilityDiscoveryManual}} } } @@ -80,7 +96,7 @@ func ApplyEvidenceScaffold(m *Manifest, st standard.Standard, opts ApplyEvidence if m.Evidence.Infra == nil { m.Evidence.Infra = &InfraEvidence{} } - if !(merge && strings.TrimSpace(m.Evidence.Infra.DeploymentSpec) != "") { + if !(merge && m.Evidence.Infra.DeploymentSpecExistForGate()) { m.Evidence.Infra.DeploymentSpec = "" } } @@ -89,7 +105,7 @@ func ApplyEvidenceScaffold(m *Manifest, st standard.Standard, opts ApplyEvidence if m.Evidence.Release == nil { m.Evidence.Release = &ReleaseEvidence{} } - if !(merge && strings.TrimSpace(m.Evidence.Release.RollbackPlan) != "") { + if !(merge && m.Evidence.Release.RollbackPlanExistForGate()) { m.Evidence.Release.RollbackPlan = "" } } diff --git a/internal/manifest/evidence_scaffold_test.go b/internal/manifest/scaffold_test.go similarity index 100% rename from internal/manifest/evidence_scaffold_test.go rename to internal/manifest/scaffold_test.go diff --git a/internal/manifest/types.go b/internal/manifest/types.go index 7843965..3ba075c 100644 --- a/internal/manifest/types.go +++ b/internal/manifest/types.go @@ -1,7 +1,5 @@ package manifest -import "strings" - // Manifest declares the evaluated service and the evidence HADO should read. type Manifest struct { Version string `yaml:"version" json:"version,omitempty"` @@ -50,27 +48,47 @@ type OperationsEvidence struct { Runbook string `yaml:"runbook" json:"runbook,omitempty"` } -// ObservabilityLink is a named, browser-openable URL for SLO, monitor, or dashboard evidence (audit / ops). -type ObservabilityLink struct { - Name string `yaml:"name,omitempty" json:"name,omitempty"` - URL string `yaml:"url" json:"url"` +const ( + // ObservabilityDiscoveryManual marks a ref URL entered by a human in manifest. + ObservabilityDiscoveryManual = "manual" + // ObservabilityDiscoveryAuto marks a ref URL written by charge discovery. + ObservabilityDiscoveryAuto = "auto" +) + +// ObservabilityRef is a named evidence URL with how it was obtained (manual vs charge auto-discovery). +type ObservabilityRef struct { + Name string `yaml:"name,omitempty" json:"name,omitempty"` + URL string `yaml:"url" json:"url"` + DiscoveryType string `yaml:"discovery_type" json:"discovery_type"` } -// ObservabilityEvidence declares observability evidence as lists of links (typically vendor UI URLs). +// ObservabilityLaneEvidence groups one signal lane (slo, monitor, dashboard): unified refs, +// optional provider, and a single discovery recipe for charge. +type ObservabilityLaneEvidence struct { + Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` + Discovery *ObservabilityDiscovery `yaml:"discovery,omitempty" json:"discovery,omitempty"` + Refs []ObservabilityRef `yaml:"refs,omitempty" json:"refs,omitempty"` +} + +// ObservabilityEvidence declares observability evidence per signal lane (chimera-friendly). type ObservabilityEvidence struct { - SLOs []ObservabilityLink `yaml:"slos,omitempty" json:"slos,omitempty"` - Monitors []ObservabilityLink `yaml:"monitors,omitempty" json:"monitors,omitempty"` - Dashboards []ObservabilityLink `yaml:"dashboards,omitempty" json:"dashboards,omitempty"` + SLO *ObservabilityLaneEvidence `yaml:"slo,omitempty" json:"slo,omitempty"` + Monitor *ObservabilityLaneEvidence `yaml:"monitor,omitempty" json:"monitor,omitempty"` + Dashboard *ObservabilityLaneEvidence `yaml:"dashboard,omitempty" json:"dashboard,omitempty"` +} + +// ObservabilityDiscovery selects how charge finds resources in external systems for this lane. +type ObservabilityDiscovery struct { + Datadog *DatadogDiscovery `yaml:"datadog,omitempty" json:"datadog,omitempty"` } -// ObservabilityLinksHaveURL reports whether at least one entry has a non-empty URL after trimming spaces. -func ObservabilityLinksHaveURL(links []ObservabilityLink) bool { - for _, l := range links { - if strings.TrimSpace(l.URL) != "" { - return true - } - } - return false +// 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. +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"` } // InfraEvidence declares infrastructure-related evidence references (deployment spec, IaC pointer, etc.). diff --git a/internal/manifest/write_indent_test.go b/internal/manifest/write_indent_test.go deleted file mode 100644 index 2caa5c6..0000000 --- a/internal/manifest/write_indent_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package manifest - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestSaveUsesTwoSpaceIndent(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "hado.yaml") - m := Manifest{ - Version: "v1", - Service: Service{Name: "svc", ID: "svc"}, - Standard: StandardRef{ID: "web-service"}, - } - if err := m.Save(path); err != nil { - t.Fatal(err) - } - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - s := string(data) - // Nested keys directly under "service:" should be indented with 2 spaces, not yaml.Marshal's default 4. - if i := strings.Index(s, "service:\n"); i >= 0 { - after := s[i+len("service:\n"):] - first := strings.SplitN(after, "\n", 2)[0] - if strings.HasPrefix(first, " ") { - t.Fatalf("first line under service should not use 4-space indent; got %q\n%s", first, s) - } - if first != "" && !strings.HasPrefix(first, " ") { - t.Fatalf("first line under service should use 2-space indent; got %q\n%s", first, s) - } - } -} diff --git a/internal/standard/types.go b/internal/standard/types.go index aa66e4b..0278cb9 100644 --- a/internal/standard/types.go +++ b/internal/standard/types.go @@ -20,11 +20,11 @@ const ( // OperationsRunbookExistsGateID is the gate id used for operational runbook readiness. OperationsRunbookExistsGateID = "operations.runbook_exists" - // ObservabilitySLOExistsGateID gates on at least one URL in evidence.observability.slos. + // ObservabilitySLOExistsGateID gates on at least one URL in evidence.observability.slo.refs. ObservabilitySLOExistsGateID = "observability.slo_exists" - // ObservabilityMonitorExistsGateID gates on at least one URL in evidence.observability.monitors. + // ObservabilityMonitorExistsGateID gates on at least one URL in evidence.observability.monitor.refs. ObservabilityMonitorExistsGateID = "observability.monitor_exists" - // ObservabilityDashboardExistsGateID gates on at least one URL in evidence.observability.dashboards. + // ObservabilityDashboardExistsGateID gates on at least one URL in evidence.observability.dashboard.refs. ObservabilityDashboardExistsGateID = "observability.dashboard_exists" // InfraDeploymentSpecExistsGateID gates on a deployment / workload spec reference (path, bundle id, URL, etc.).