diff --git a/.cursor/rules/hado-implementation-docs.mdc b/.cursor/rules/hado-implementation-docs.mdc index cfc422b..ca45070 100644 --- a/.cursor/rules/hado-implementation-docs.mdc +++ b/.cursor/rules/hado-implementation-docs.mdc @@ -15,7 +15,11 @@ alwaysApply: false 2. **ロードマップの「現状」** - [docs/roadmap.md](docs/roadmap.md) の「このリポジトリの現状」が古くなっていたら、要約を直す(詳細は `implementation-status.md` に寄せてよい)。 -3. **README / アーキテクチャ** +3. **`internal/manifest/types.go` の型を変えた場合** + - [`internal/manifest/field_docs.go`](internal/manifest/field_docs.go) に **YAML パスごとの説明**を追加・更新する(`TestManifestYAMLDocComplete` が不足・余剰キーで失敗する)。 + - [`docs/hado.manifest.reference.yaml`](docs/hado.manifest.reference.yaml) を `make gen-manifest-doc` で再生成して同じ PR に含める。 + +4. **README / アーキテクチャ** - 利用者向けの CLI 説明(ルート [README.md](README.md) や [docs/architecture.md](docs/architecture.md))が実装と矛盾していたら合わせる。 ゲートを `internal/gate/evaluate.go` に追加したら、**必ず** `implementation-status.md` の実装済みゲート一覧と、必要なら `roadmap.md` の Phase 3 記述を更新する。 diff --git a/.cursor/skills/hado-doc-sync/SKILL.md b/.cursor/skills/hado-doc-sync/SKILL.md index 5a9c831..b98d091 100644 --- a/.cursor/skills/hado-doc-sync/SKILL.md +++ b/.cursor/skills/hado-doc-sync/SKILL.md @@ -26,21 +26,24 @@ description: >- 2. **`docs/implementation-status.md` を更新** - 実装済みゲートの箇条書き(`internal/gate/evaluate.go` の `switch` と一致) - coverage adapter 一覧(`internal/coverage/parse.go` の `Format*` 定数と一致) - - `hado evaluate` のフラグ・`--output` の取りうる値・終了コードの説明(`cmd/hado/main.go` と一致) + - `hado target` / `charge` / `fire` / `manifest doc` のフラグ・`--output` の取りうる値・終了コードの説明(`cmd/hado` と一致) - MVP / 未実装として追いたい項目があれば表や箇条書きで維持(ロードマップの [docs/roadmap.md](docs/roadmap.md) と矛盾させない) -3. **`docs/roadmap.md`** +3. **`docs/hado.manifest.reference.yaml`** + - `internal/manifest/types.go` または `field_docs.go` を変えたら **`make gen-manifest-doc`** で再生成し、同じ変更セットに含める。 + +4. **`docs/roadmap.md`** 「このリポジトリの現状」の要約が古ければ 2〜3 文だけ直す。詳細は `implementation-status.md` に任せる。 -4. **利用者向け** - ルート `README.md` の Evaluate 例やフラグ説明がずれていたら合わせる。 +5. **利用者向け** + ルート `README.md` の CLI 例やフラグ説明がずれていたら合わせる。 -5. **push / PR 前** +6. **push / PR 前** `docs/` や `README.md` を触れたら `make lint`(少なくとも `make lint-markdown`)を通す。pre-push を使うなら `make setup-hooks`([docs/local-development.md](docs/local-development.md))。 ## やらないこと -- `make docstatus` や Go 製ジェネレータは**ない**(手書き+この Skill)。 +- `make docstatus` のような別コマンドは**ない**。Manifest の参考 YAML(コメント付き)は **`make gen-manifest-doc`**(`hado manifest doc`)で生成し、それ以外は手書き+この Skill。 - 実装と無関係な長いロードマップの書き換えは、ユーザーが求めた範囲に留める。 ## 参照 diff --git a/Makefile b/Makefile index 1dfbb36..75764c5 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help setup setup-hooks bootstrap-go ensure-go build check-docker ci-lint lint lint-go lint-yaml lint-markdown fmt fmt-go fmt-check test readiness-check pre-pr +.PHONY: help setup setup-hooks bootstrap-go ensure-go build check-docker ci-lint lint lint-go lint-yaml lint-markdown fmt fmt-go fmt-check test readiness-check pre-pr gen-manifest-doc # Optional local toolchain: official tarball under .gitignored .tools/go (see bootstrap-go). # Prefer it when present so Make works without a global install; otherwise use `go` on PATH. @@ -28,6 +28,7 @@ help: @echo " make fmt # Format Go source files" @echo " make fmt-check # Used in make ci-lint (does not run go test)" @echo " make test # Run Go tests" + @echo " make gen-manifest-doc # Regenerate docs/hado.manifest.reference.yaml (commented reference manifest)" @echo " make readiness-check # Generate HADO coverage evidence and run charge/fire" @echo " make ci-lint # fmt-check + lint (GitHub Lint job + pre-push; no go test)" @echo " make setup-hooks # pre-push runs: make ci-lint" @@ -84,6 +85,11 @@ build: ensure-go @mkdir -p bin $(GO_CMD) build -o "$(BINARY)" ./cmd/hado +gen-manifest-doc: ensure-go + @mkdir -p bin + $(GO_CMD) run ./cmd/hado manifest doc --out docs/hado.manifest.reference.yaml + @echo "Wrote docs/hado.manifest.reference.yaml" + check-docker: @command -v docker >/dev/null 2>&1 || { echo "docker is required for YAML/Markdown lint."; exit 1; } diff --git a/README.md b/README.md index d322516..d2a8a27 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ HADO という名前は「波動砲」から来ています。波動砲は、日 - [Project HADO ドキュメント](docs/README.md) - [実装状況(手保守; Cursor Skill `hado-doc-sync`)](docs/implementation-status.md) +- [HADO Manifest 参考(型から生成するコメント付き YAML)](docs/hado.manifest.reference.yaml)(`make gen-manifest-doc` で再生成) - [ローカル開発コマンド](docs/local-development.md) ## Build and run diff --git a/cmd/hado/fire/observability_test.go b/cmd/hado/fire/observability_test.go index 82eab57..79152a9 100644 --- a/cmd/hado/fire/observability_test.go +++ b/cmd/hado/fire/observability_test.go @@ -20,9 +20,15 @@ gates: manifestPath := writeFile(t, dir, "hado.yaml", `version: v1 evidence: observability: - slo: slo.yaml - monitors: monitors.tf - dashboard: https://example.com/board/1 + slos: + - name: slo + url: https://app.datadoghq.com/slo/manage?slo_id=x + monitors: + - name: mon + url: https://app.datadoghq.com/monitors/1 + dashboards: + - name: dash + url: https://example.com/board/1 `) var stdout, stderr bytes.Buffer diff --git a/cmd/hado/fire/run.go b/cmd/hado/fire/run.go index f271b35..82d926f 100644 --- a/cmd/hado/fire/run.go +++ b/cmd/hado/fire/run.go @@ -93,9 +93,9 @@ func applyManifestEvidence(metrics *gate.Metrics, hadoManifest manifest.Manifest metrics.OperationsRunbook = strings.TrimSpace(op.Runbook) } if obs := hadoManifest.Evidence.Observability; obs != nil { - metrics.ObservabilitySLO = strings.TrimSpace(obs.SLO) - metrics.ObservabilityMonitors = strings.TrimSpace(obs.Monitors) - metrics.ObservabilityDashboard = strings.TrimSpace(obs.Dashboard) + 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) diff --git a/cmd/hado/main.go b/cmd/hado/main.go index 4524a74..7baf582 100644 --- a/cmd/hado/main.go +++ b/cmd/hado/main.go @@ -7,6 +7,7 @@ import ( chargecmd "github.com/keyskey/hado/cmd/hado/charge" firecmd "github.com/keyskey/hado/cmd/hado/fire" + manifestcmd "github.com/keyskey/hado/cmd/hado/manifestcmd" targetcmd "github.com/keyskey/hado/cmd/hado/target" ) @@ -22,7 +23,7 @@ func main() { func run(args []string, stdout, stderr io.Writer) (int, error) { if len(args) == 0 { - fmt.Fprintln(stdout, "hado: production readiness CLI") + fmt.Fprintln(stdout, "hado: production readiness CLI (try: charge, fire, target, manifest doc)") return 0, nil } @@ -36,6 +37,8 @@ func run(args []string, stdout, stderr io.Writer) (int, error) { return firecmd.Run(args[1:], stdout, stderr) case "target": return targetcmd.Run(args[1:], os.Stdin, stdout, stderr) + case "manifest": + return manifestcmd.Run(args[1:], stdout, stderr) default: return 2, fmt.Errorf("unknown command %q", args[0]) } diff --git a/cmd/hado/manifestcmd/run.go b/cmd/hado/manifestcmd/run.go new file mode 100644 index 0000000..0f5717f --- /dev/null +++ b/cmd/hado/manifestcmd/run.go @@ -0,0 +1,43 @@ +package manifestcmd + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + + "github.com/keyskey/hado/internal/manifest" +) + +// Run handles "hado manifest doc" and related subcommands. +func Run(args []string, stdout, stderr io.Writer) (int, error) { + if len(args) == 0 { + return 2, errors.New("usage: hado manifest doc [--out path] # writes commented reference YAML") + } + if args[0] != "doc" { + return 2, fmt.Errorf("unknown manifest subcommand %q (try: doc)", args[0]) + } + fs := flag.NewFlagSet("manifest doc", flag.ContinueOnError) + fs.SetOutput(stderr) + outPath := fs.String("out", "", "write reference YAML to this file (default: stdout)") + if err := fs.Parse(args[1:]); err != nil { + return 2, err + } + + var w io.Writer = stdout + var f *os.File + if *outPath != "" { + var err error + f, err = os.Create(*outPath) + if err != nil { + return 2, err + } + defer f.Close() + w = f + } + if err := manifest.WriteManifestReferenceYAML(w); err != nil { + return 2, err + } + return 0, nil +} diff --git a/docs/README.md b/docs/README.md index 02049a0..206d6f7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,18 +26,22 @@ Project HADO は、サービスを本番環境へリリースする前に「本 コードとの対応を手で保守する。実装変更時は Cursor Rule `hado-implementation-docs` と Skill `hado-doc-sync` に従い更新する。 -6. [Go C1 カバレッジ計測ツール](gobce.md) +6. [HADO Manifest 参考 YAML(コメント付き; `make gen-manifest-doc`)](hado.manifest.reference.yaml) + + `internal/manifest` の型と `field_docs.go` から生成する。手編集しない。 + +7. [Go C1 カバレッジ計測ツール](gobce.md) 最初から別リポジトリとして開発する `gobce` の目的、スコープ、HADO 連携方針をまとめる。 -7. [Infrastructure Readiness とマニフェスト設計](infrastructure-readiness-and-manifest-design.md) +8. [Infrastructure Readiness とマニフェスト設計](infrastructure-readiness-and-manifest-design.md) 本体を薄く保ちつつ、Readiness Standard を組織・サービスタイプ・実行基盤・Tier ごとに細かく定義できるようにし、HADO Manifest のスキーマを安定させるための設計指針(腐敗防止層、Standard の分割・合成、Manifest の形)をまとめる。 -8. [未解決課題](open-design-decisions.md) +9. [未解決課題](open-design-decisions.md) Open Design Decisions、未解決の命名・設計・実装方針をまとめる。 -9. [ローカル開発コマンド](local-development.md) +10. [ローカル開発コマンド](local-development.md) `Makefile` で提供している `lint` / `format` / `test` 系コマンドと事前準備をまとめる。 diff --git a/docs/architecture.md b/docs/architecture.md index fcbcb0a..59a171b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -214,6 +214,8 @@ Report `hado.yaml` は、評価対象サービスが自分自身と evidence の場所を宣言するファイルである。単なる設定ではなく、リリース準備状態の入口になる manifest として扱う。 +**コメント付きの全プロパティ一覧:** [hado.manifest.reference.yaml](hado.manifest.reference.yaml) を `make gen-manifest-doc`(`hado manifest doc`)で再生成する。 + **実装済みのトップレベル(v1):** `service`(`id` / `name`)と `standard`(`id`:Readiness Standard の論理 id または標準 YAML へのパス)は `hado target` が書き込める。`evidence` 以下の形はこのリポジトリの [実装状況](implementation-status.md) に従う。 ```yaml diff --git a/docs/hado.manifest.reference.yaml b/docs/hado.manifest.reference.yaml new file mode 100644 index 0000000..27a87c7 --- /dev/null +++ b/docs/hado.manifest.reference.yaml @@ -0,0 +1,66 @@ +# 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 + +# Manifest スキーマの版。現行は `v1` を使う。 (論理型: string) +version: "v1" +# 評価対象サービスの識別子(ブロック全体は任意)。 (論理型: object) +service: + # サービス ID。未指定時は `target` で `service.name` と同じにできる。 (論理型: string) + id: "" + # サービス名。 (論理型: string) + name: "" +# 適用する Readiness Standard への参照(ブロック)。 (論理型: object) +standard: + # Standard のファイル名(例: `web-service.yaml`)またはパス。`standards-dir` / manifest 隣の `standards/` から解決される。 (論理型: string) + id: "" +# 本番準備の証跡宣言。ゲートごとに必要なブロックだけでよい(各サブブロックは多くが `omitempty`)。 (論理型: object) +evidence: + # カバレッジ成果物と adapter(ブロック)。C0/C1 ゲートがある standard で必要。 (論理型: object) + coverage: + # `CoverageInput` の配列。 (論理型: array of object) + inputs: + # パーサ名。`hado-json` / `go-coverprofile` / `gobce-json` など(実装は `internal/coverage`)。 (論理型: string) + - adapter: "hado-json" + # リポジトリまたは manifest 相対の成果物パス。 (論理型: string) + path: "coverage-metrics.json" + # 運用責任と障害対応の入口(ブロック)。 (論理型: object) + operations: + # オーナー(チーム名・Slack チャンネル等)。`operations.owner_exists` で非空判定。 (論理型: string) + owner: "" + # Runbook の URL またはパス。`operations.runbook_exists` で非空判定。 (論理型: string) + runbook: "" + # 観測可能性の証跡(ブロック)。SLO / モニター / ダッシュボードは **ベンダー UI 等で辿れる URL** のリストで宣言する(監査・運用オペ向け)。 (論理型: 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: "" + # インフラ関連の参照(ブロック)。 (論理型: object) + infra: + # デプロイ仕様の参照(パス・URL・カタログ ID)。`infra.deployment_spec_exists`。 (論理型: string) + deployment_spec: "" + # リリース・ロールバック(ブロック)。 (論理型: object) + release: + # ロールバック手順の参照。`release.rollback_plan_exists`。 (論理型: string) + rollback_plan: "" + # 自動リリースパイプライン(ブロック)。 (論理型: object) + automation: + # ワークフロー識別子のリスト(文字列の配列)。1 件以上非空で `release.automation_declared`。 (論理型: array of string) + workflow_refs: [] + # 任意メタデータ(例: `github_actions`)。現行ゲートでは未使用。 (論理型: array of string) + systems: [] diff --git a/docs/implementation-status.md b/docs/implementation-status.md index e752e42..a487eb4 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -11,8 +11,9 @@ | `target` | 実装済み(`--manifest` 必須。TTY / フラグで `service` / `standard`。既定で resolved standard に応じ **evidence のスキャフォールド**(空文字のキーなど)をマージ) | | `charge` | 実装済み(`--manifest` 必須。coverage artifact の adapter/path を manifest `evidence.coverage.inputs` に不足分マージ。既存値は置換しない) | | `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`) | -`evaluate` は廃止し、`target` / `charge` / `fire` に一本化した。 +`evaluate` は廃止し、`target` / `charge` / `fire` / `manifest doc` で CLI を構成する。 `target` の主なフラグ(`cmd/hado/target/run.go` の `Run`): @@ -23,7 +24,7 @@ - `--standards-dir`(任意; スキャフォールド用の standard YAML を探すディレクトリ。既定は manifest と同じ階層の `standards/`) - `--rewrite-placeholders`(既定 `true`。`false` で service/standard のみ更新し evidence は触らない) -`fire` は manifest の該当フィールドが **空(前後の空白を除いた長さ 0)** のとき、existence gate では **未設定**として評価します。 +`fire` は manifest の該当フィールドが **未設定** のとき、existence gate では失敗とみなします。文字列証跡は **前後の空白を除いた長さ 0**、 observability の `slos` / `monitors` / `dashboards` は **いずれの要素にも非空の `url` が無い** 場合に未設定です。 `charge` の主なフラグ(`cmd/hado/charge/run.go`): @@ -37,6 +38,10 @@ - `--standard`(任意。未指定時は manifest の `standard.id` を利用、指定時は上書き) - `--output`:`text` または `json`(それ以外はエラー) +`hado manifest doc` の主なフラグ(`cmd/hado/manifestcmd/run.go`): + +- `--out`(任意。未指定時は標準出力へ書き込む) + `--output text` は各 gate の判定行に `severity` を表示し、FAIL 行には「リリース前に必須対応か / リリース後対応可か」の運用ヒントを併記する。総合判定(`HADO: READY/BLOCKED/ERROR`)は一覧の最後に出力する。TTY では ANSI カラーを付与し、`PASS` は緑、`FAIL` は赤/黄(required+critical の FAIL を最強調)で表示する(`NO_COLOR` が設定されている場合は無効)。 **Coverage 入力の必須条件:** Readiness Standard が `test.c0_coverage` または `test.c1_coverage` のいずれかを含む場合、`charge` で `--coverage-input` を渡すか、manifest の `evidence.coverage.inputs` が必要。`fire` 実行時にどちらも無いとエラー終了(exit 2)。 @@ -53,9 +58,9 @@ required として宣言されているが、ここに無い gate id は **error - `test.c1_coverage` - `operations.owner_exists` - `operations.runbook_exists` -- `observability.slo_exists`(manifest `evidence.observability.slo` が非空) -- `observability.monitor_exists`(`evidence.observability.monitors` が非空) -- `observability.dashboard_exists`(`evidence.observability.dashboard` が非空) +- `observability.slo_exists`(`evidence.observability.slos` のいずれかに trim 後非空の `url`) +- `observability.monitor_exists`(`evidence.observability.monitors` に同上) +- `observability.dashboard_exists`(`evidence.observability.dashboards` に同上) - `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` は任意のメタデータで現ゲートでは未使用) @@ -86,8 +91,10 @@ 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 と生成器の完全一致)。 + - `evidence.operations`(owner, runbook) -- `evidence.observability`(`slo`, `monitors`, `dashboard` …各フィールドが該当 gate の「存在」判定に使われる) +- `evidence.observability`(`slos` / `monitors` / `dashboards` …各リストの **いずれかの要素に `url`(trim 後非空)** があれば該当 gate PASS。監査・オペではベンダー UI 等へ直接辿れる URL を想定) - `evidence.infra`(`deployment_spec`) - `evidence.release`(`rollback_plan`; `automation.workflow_refs`, 任意で `automation.systems`) - `evidence.coverage.inputs`(`adapter`, `path`) diff --git a/docs/local-development.md b/docs/local-development.md index aa4ec9d..c4bc7c0 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -53,6 +53,7 @@ make lint # YAML, Markdown, Go の lint make fmt # Go ファイルを gofmt で整形 make fmt-check # Go の整形漏れチェック (CI 相当) make test # Go テスト +make gen-manifest-doc # docs/hado.manifest.reference.yaml を型から再生成 make readiness-check # coverage evidence 生成 + HADO 自身の readiness 評価 make ci-lint # fmt-check + lint(CI Lint ジョブ・pre-push と同じ; go test なし) make pre-pr # ci-lint + test(ローカルで PR 前にまとめて叩く用) diff --git a/docs/production-readiness-evaluation.md b/docs/production-readiness-evaluation.md index 4435931..3d66550 100644 --- a/docs/production-readiness-evaluation.md +++ b/docs/production-readiness-evaluation.md @@ -87,11 +87,14 @@ Evidence は、gate 評価に使う証拠である。 coverage.out Go test coverage profile -slo.yaml - SLO / SLI 定義 +evidence.observability.slos[].url + SLO / SLI をブラウザで開ける URL(例: Datadog SLO 管理画面) -datadog-monitors.yaml -Datadog monitor 定義 +evidence.observability.monitors[].url + アラート / モニターの URL(例: Datadog monitor) + +evidence.observability.dashboards[].url + ダッシュボードの URL Datadog service catalog metadata owner, lifecycle, system, tier, runbook, oncall など diff --git a/hado.yaml b/hado.yaml index 704a84c..5eab9ff 100644 --- a/hado.yaml +++ b/hado.yaml @@ -12,12 +12,6 @@ evidence: operations: owner: keyskey runbook: "" - observability: - slo: "" - monitors: "" - dashboard: "" - infra: - deployment_spec: "" release: rollback_plan: "" automation: diff --git a/internal/gate/evaluate.go b/internal/gate/evaluate.go index c26208c..36b5f10 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.ObservabilitySLO != "", "SLO / SLI evidence is defined.", "SLO / SLI evidence is not defined.") + 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).") evaluation.Results = append(evaluation.Results, result) case standard.ObservabilityMonitorExistsGateID: - result := evaluateExistsGate(gate, metrics.ObservabilityMonitors != "", "Monitor evidence is defined.", "Monitor evidence is not defined.") + 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).") evaluation.Results = append(evaluation.Results, result) case standard.ObservabilityDashboardExistsGateID: - result := evaluateExistsGate(gate, metrics.ObservabilityDashboard != "", "Dashboard evidence is defined.", "Dashboard evidence is not defined.") + 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).") 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/gate/evaluate_observability_test.go b/internal/gate/evaluate_observability_test.go index 0e107d9..1af18ba 100644 --- a/internal/gate/evaluate_observability_test.go +++ b/internal/gate/evaluate_observability_test.go @@ -7,41 +7,46 @@ import ( ) func TestEvaluateObservabilityReady(t *testing.T) { - evaluation, err := Evaluate(standard.Standard{ - ID: "test-standard", + st := standard.Standard{ Gates: []standard.Gate{ {ID: standard.ObservabilitySLOExistsGateID, Required: true}, {ID: standard.ObservabilityMonitorExistsGateID, Required: true}, {ID: standard.ObservabilityDashboardExistsGateID, Required: true}, }, - }, Metrics{ - ObservabilitySLO: "slo.yaml", - ObservabilityMonitors: "monitors.yaml", - ObservabilityDashboard: "https://example.com/dash", + } + eval, err := Evaluate(st, Metrics{ + ObservabilitySLOPresent: true, + ObservabilityMonitorsPresent: true, + ObservabilityDashboardPresent: true, }) if err != nil { - t.Fatalf("Evaluate: %v", err) + t.Fatal(err) } - if evaluation.Status != DecisionReady { - t.Fatalf("status = %q, want %q", evaluation.Status, DecisionReady) + for _, r := range eval.Results { + if !r.Passed { + t.Fatalf("%s: want pass, got %s", r.ID, r.Message) + } } } func TestEvaluateObservabilityBlockedWhenDashboardEmpty(t *testing.T) { - evaluation, err := Evaluate(standard.Standard{ - ID: "test-standard", + st := standard.Standard{ Gates: []standard.Gate{ {ID: standard.ObservabilityDashboardExistsGateID, Severity: standard.SeverityCritical, Required: true}, }, - }, Metrics{ - ObservabilitySLO: "ok", - ObservabilityMonitors: "ok", - ObservabilityDashboard: "", + } + eval, err := Evaluate(st, Metrics{ + ObservabilitySLOPresent: true, + ObservabilityMonitorsPresent: true, + ObservabilityDashboardPresent: false, }) if err != nil { - t.Fatalf("Evaluate: %v", err) + t.Fatal(err) + } + if len(eval.Results) != 1 { + t.Fatalf("results = %#v", eval.Results) } - if evaluation.Status != DecisionBlocked { - t.Fatalf("status = %q, want %q", evaluation.Status, DecisionBlocked) + if eval.Results[0].Passed { + t.Fatal("expected dashboard gate to fail") } } diff --git a/internal/gate/types.go b/internal/gate/types.go index b03b363..5759e09 100644 --- a/internal/gate/types.go +++ b/internal/gate/types.go @@ -16,16 +16,16 @@ const ( // Metrics are normalized values supplied by modules or evidence parsers. type Metrics struct { - C0CoveragePercent *float64 - C1CoveragePercent *float64 - OperationsOwner string - OperationsRunbook string - ObservabilitySLO string - ObservabilityMonitors string - ObservabilityDashboard string - ReleaseRollbackPlan string - ReleaseAutomationDeclared bool - InfraDeploymentSpec string + C0CoveragePercent *float64 + C1CoveragePercent *float64 + OperationsOwner string + OperationsRunbook string + ObservabilitySLOPresent bool + ObservabilityMonitorsPresent bool + ObservabilityDashboardPresent bool + ReleaseRollbackPlan string + ReleaseAutomationDeclared bool + InfraDeploymentSpec string } // Result captures a single gate evaluation. diff --git a/internal/manifest/doc.go b/internal/manifest/doc.go index 0fa6a90..23fd438 100644 --- a/internal/manifest/doc.go +++ b/internal/manifest/doc.go @@ -1,2 +1,5 @@ // 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. package manifest diff --git a/internal/manifest/evidence_scaffold.go b/internal/manifest/evidence_scaffold.go index d3059a7..ccebba4 100644 --- a/internal/manifest/evidence_scaffold.go +++ b/internal/manifest/evidence_scaffold.go @@ -13,7 +13,7 @@ type ApplyEvidenceScaffoldOptions struct { } // ApplyEvidenceScaffold ensures evidence blocks and string fields exist for gates declared in the standard. -// String fields are left empty so YAML lists keys (e.g. owner: "") for humans or charge to fill later. +// String fields and observability link rows are left empty so YAML lists keys for humans or charge to fill later. // Only gate IDs understood by the evaluator are mapped; unknown gates are skipped. func ApplyEvidenceScaffold(m *Manifest, st standard.Standard, opts ApplyEvidenceScaffoldOptions) { merge := opts.MergeOnly @@ -59,20 +59,20 @@ func ApplyEvidenceScaffold(m *Manifest, st standard.Standard, opts ApplyEvidence } if st.RequiresGate(standard.ObservabilitySLOExistsGateID) { setObs() - if !(merge && strings.TrimSpace(m.Evidence.Observability.SLO) != "") { - m.Evidence.Observability.SLO = "" + if !(merge && ObservabilityLinksHaveURL(m.Evidence.Observability.SLOs)) { + m.Evidence.Observability.SLOs = []ObservabilityLink{{}} } } if st.RequiresGate(standard.ObservabilityMonitorExistsGateID) { setObs() - if !(merge && strings.TrimSpace(m.Evidence.Observability.Monitors) != "") { - m.Evidence.Observability.Monitors = "" + if !(merge && ObservabilityLinksHaveURL(m.Evidence.Observability.Monitors)) { + m.Evidence.Observability.Monitors = []ObservabilityLink{{}} } } if st.RequiresGate(standard.ObservabilityDashboardExistsGateID) { setObs() - if !(merge && strings.TrimSpace(m.Evidence.Observability.Dashboard) != "") { - m.Evidence.Observability.Dashboard = "" + if !(merge && ObservabilityLinksHaveURL(m.Evidence.Observability.Dashboards)) { + m.Evidence.Observability.Dashboards = []ObservabilityLink{{}} } } diff --git a/internal/manifest/field_docs.go b/internal/manifest/field_docs.go new file mode 100644 index 0000000..0550710 --- /dev/null +++ b/internal/manifest/field_docs.go @@ -0,0 +1,45 @@ +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/observability_test.go b/internal/manifest/observability_test.go index 0c342ae..76e550c 100644 --- a/internal/manifest/observability_test.go +++ b/internal/manifest/observability_test.go @@ -11,9 +11,15 @@ func TestLoadReturnsObservabilityEvidence(t *testing.T) { if err := os.WriteFile(manifestPath, []byte(`version: v1 evidence: observability: - slo: slo.yaml - monitors: monitors.yaml - dashboard: dash.json + slos: + - name: api availability + url: https://app.datadoghq.com/slo?slo_id=a + monitors: + - name: latency + url: https://app.datadoghq.com/monitors/1 + dashboards: + - name: perf + url: https://app.datadoghq.com/dashboard/bbb `), 0o600); err != nil { t.Fatalf("write manifest: %v", err) } @@ -26,13 +32,14 @@ evidence: if hadoManifest.Evidence.Observability == nil { t.Fatal("observability evidence is nil") } - if got := hadoManifest.Evidence.Observability.SLO; got != "slo.yaml" { - t.Fatalf("observability.slo = %q", got) + 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 got := hadoManifest.Evidence.Observability.Monitors; got != "monitors.yaml" { - t.Fatalf("observability.monitors = %q", got) + if len(o.Monitors) != 1 || o.Monitors[0].URL != "https://app.datadoghq.com/monitors/1" { + t.Fatalf("monitors = %+v", o.Monitors) } - if got := hadoManifest.Evidence.Observability.Dashboard; got != "dash.json" { - t.Fatalf("observability.dashboard = %q", got) + if len(o.Dashboards) != 1 || o.Dashboards[0].URL != "https://app.datadoghq.com/dashboard/bbb" { + t.Fatalf("dashboards = %+v", o.Dashboards) } } diff --git a/internal/manifest/refdoc.go b/internal/manifest/refdoc.go new file mode 100644 index 0000000..de28bea --- /dev/null +++ b/internal/manifest/refdoc.go @@ -0,0 +1,102 @@ +package manifest + +import ( + "reflect" + "slices" + "strings" +) + +type pathEntry struct { + path string + typeDesc string + omitempty bool +} + +func manifestYAMLPaths() ([]pathEntry, error) { + var out []pathEntry + walkManifestPaths(reflect.TypeOf(Manifest{}), "", false, &out) + slices.SortFunc(out, func(a, b pathEntry) int { + return strings.Compare(a.path, b.path) + }) + return out, nil +} + +func walkManifestPaths(t reflect.Type, prefix string, parentOmitempty bool, out *[]pathEntry) { + if t.Kind() != reflect.Struct { + return + } + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if !sf.IsExported() { + continue + } + y := yamlKey(sf.Tag) + if y == "" || y == "-" { + continue + } + omitempty := parentOmitempty || yamlOmitempty(sf.Tag) + path := y + if prefix != "" { + path = prefix + "." + y + } + ft := sf.Type + for ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + switch ft.Kind() { + case reflect.Struct: + typeDesc := "object" + *out = append(*out, pathEntry{path: path, typeDesc: typeDesc, omitempty: omitempty}) + walkManifestPaths(ft, path, omitempty, out) + case reflect.Slice: + elem := ft.Elem() + for elem.Kind() == reflect.Ptr { + elem = elem.Elem() + } + if elem.Kind() == reflect.Struct { + *out = append(*out, pathEntry{path: path, typeDesc: "array of object", omitempty: omitempty}) + walkManifestPaths(elem, path, omitempty, out) + } else { + *out = append(*out, pathEntry{ + path: path, + typeDesc: "array of " + scalarTypeName(elem), + omitempty: omitempty, + }) + } + default: + *out = append(*out, pathEntry{ + path: path, + typeDesc: scalarTypeName(ft), + omitempty: omitempty, + }) + } + } +} + +func yamlKey(tag reflect.StructTag) string { + t := tag.Get("yaml") + if i := strings.Index(t, ","); i >= 0 { + return t[:i] + } + return t +} + +func yamlOmitempty(tag reflect.StructTag) bool { + return strings.Contains(tag.Get("yaml"), "omitempty") +} + +func scalarTypeName(t reflect.Type) string { + switch t.Kind() { + case reflect.String: + return "string" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return "integer" + case reflect.Float32, reflect.Float64: + return "float" + case reflect.Bool: + return "boolean" + default: + return t.String() + } +} diff --git a/internal/manifest/refdoc_test.go b/internal/manifest/refdoc_test.go new file mode 100644 index 0000000..d5336de --- /dev/null +++ b/internal/manifest/refdoc_test.go @@ -0,0 +1,105 @@ +package manifest + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestManifestYAMLDocComplete(t *testing.T) { + paths, err := manifestYAMLPaths() + 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) + } + } + var orphan []string + for k := range manifestYAMLDoc { + 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, ", ")) + } +} + +func TestWriteManifestReferenceYAML_loads(t *testing.T) { + var sb strings.Builder + if err := WriteManifestReferenceYAML(&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()) + if err != nil { + t.Fatalf("parseManifestBytes: %v\n---\n%s", err, truncate(data, 800)) + } + if m.Version != "v1" { + t.Fatalf("Version = %q want v1", m.Version) + } + if m.Evidence.Coverage == nil || len(m.Evidence.Coverage.Inputs) != 1 { + t.Fatalf("expected one coverage input, got %#v", m.Evidence.Coverage) + } +} + +func TestCommittedReferenceYAMLMatchesGenerator(t *testing.T) { + root := findModuleRoot(t) + refPath := filepath.Join(root, "docs", "hado.manifest.reference.yaml") + committed, err := os.ReadFile(refPath) + if err != nil { + 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 { + t.Fatal(err) + } + if normalizeEOL(string(committed)) != normalizeEOL(gen.String()) { + t.Fatalf("docs/hado.manifest.reference.yaml is out of sync with WriteManifestReferenceYAML.\n"+ + "Run from repo root: make gen-manifest-doc\npath: %s", refPath) + } +} + +func findModuleRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + dir := wd + for i := 0; i < 12; i++ { + mod := filepath.Join(dir, "go.mod") + if st, err := os.Stat(mod); err == nil && !st.IsDir() { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + t.Fatalf("go.mod not found from cwd %q (run go test from module root)", wd) + return "" +} + +func normalizeEOL(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + return strings.ReplaceAll(s, "\r", "\n") +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/manifest/refyaml.go b/internal/manifest/refyaml.go new file mode 100644 index 0000000..8b217ad --- /dev/null +++ b/internal/manifest/refyaml.go @@ -0,0 +1,232 @@ +package manifest + +import ( + "fmt" + "io" + "reflect" + "strings" +) + +// 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() + if err != nil { + return err + } + typeByPath := make(map[string]string, len(paths)) + for _, p := range paths { + typeByPath[p.path] = p.typeDesc + } + var b strings.Builder + writeYAMLFileHeader(&b) + if err := emitStructYAML(reflect.TypeOf(Manifest{}), "", 0, false, typeByPath, &b); err != nil { + return err + } + _, err = io.WriteString(w, b.String()) + return err +} + +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("\n") +} + +func emitStructYAML(t reflect.Type, pathPrefix string, level int, parentOmitempty bool, typeByPath map[string]string, b *strings.Builder) error { + if t.Kind() != reflect.Struct { + return fmt.Errorf("emitStructYAML: not a struct") + } + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if !sf.IsExported() { + continue + } + y := yamlKey(sf.Tag) + if y == "" || y == "-" { + continue + } + childParentOmit := parentOmitempty || yamlOmitempty(sf.Tag) + _ = childParentOmit + + path := y + if pathPrefix != "" { + path = pathPrefix + "." + y + } + doc := manifestYAMLDoc[path] + if strings.TrimSpace(doc) == "" { + return fmt.Errorf("manifestYAMLDoc missing description for path %q", path) + } + writeYAMLCommentBlock(level, docWithLogicalType(path, doc, typeByPath), b) + + ft := sf.Type + for ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + pad := indentSpaces(level) + + switch ft.Kind() { + case reflect.Struct: + b.WriteString(pad) + b.WriteString(y) + b.WriteString(":\n") + if err := emitStructYAML(ft, path, level+1, childParentOmit, typeByPath, b); err != nil { + return err + } + case reflect.Slice: + elem := ft.Elem() + for elem.Kind() == reflect.Ptr { + elem = elem.Elem() + } + b.WriteString(pad) + b.WriteString(y) + if elem.Kind() == reflect.Struct { + b.WriteString(":\n") + if err := emitSliceOfStructYAML(elem, path, level, typeByPath, b); err != nil { + return err + } + } else { + b.WriteString(": ") + if err := writeYAMLScalarNoNewline(ft, path, b); err != nil { + return err + } + b.WriteString("\n") + } + default: + b.WriteString(pad) + b.WriteString(y) + b.WriteString(": ") + if err := writeYAMLScalarNoNewline(ft, path, b); err != nil { + return err + } + b.WriteString("\n") + } + } + return nil +} + +func emitSliceOfStructYAML(elem reflect.Type, elemPathPrefix string, level int, typeByPath map[string]string, b *strings.Builder) error { + padList := indentSpaces(level + 1) + padCont := indentSpaces(level + 2) + + fields, err := exportedYAMLFields(elem) + if err != nil { + return err + } + if len(fields) == 0 { + return fmt.Errorf("slice %s: no exported yaml fields", elemPathPrefix) + } + + for fi, sf := range fields { + y := yamlKey(sf.Tag) + path := elemPathPrefix + "." + y + doc := manifestYAMLDoc[path] + if strings.TrimSpace(doc) == "" { + return fmt.Errorf("manifestYAMLDoc missing %q", path) + } + ft := sf.Type + for ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + + if fi == 0 { + writeYAMLCommentBlock(level+1, docWithLogicalType(path, doc, typeByPath), b) + b.WriteString(padList) + b.WriteString("- ") + b.WriteString(y) + b.WriteString(": ") + if err := writeYAMLScalarNoNewline(ft, path, b); err != nil { + return err + } + b.WriteString("\n") + continue + } + writeYAMLCommentBlock(level+2, docWithLogicalType(path, doc, typeByPath), b) + b.WriteString(padCont) + b.WriteString(y) + b.WriteString(": ") + if err := writeYAMLScalarNoNewline(ft, path, b); err != nil { + return err + } + b.WriteString("\n") + } + return nil +} + +func exportedYAMLFields(t reflect.Type) ([]reflect.StructField, error) { + if t.Kind() != reflect.Struct { + return nil, fmt.Errorf("not struct") + } + var out []reflect.StructField + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if !sf.IsExported() { + continue + } + if k := yamlKey(sf.Tag); k != "" && k != "-" { + out = append(out, sf) + } + } + return out, nil +} + +func writeYAMLScalarNoNewline(ft reflect.Type, path string, b *strings.Builder) error { + switch ft.Kind() { + case reflect.String: + fmt.Fprintf(b, "%q", referenceStringValue(path)) + case reflect.Slice: + el := ft.Elem() + for el.Kind() == reflect.Ptr { + el = el.Elem() + } + if el.Kind() == reflect.String { + b.WriteString("[]") + } else { + return fmt.Errorf("unsupported slice element for path %s", path) + } + default: + return fmt.Errorf("unsupported scalar kind %s for path %s", ft.Kind(), path) + } + return nil +} + +func referenceStringValue(path string) string { + switch path { + case "version": + return "v1" + case "evidence.coverage.inputs.adapter": + return "hado-json" + case "evidence.coverage.inputs.path": + return "coverage-metrics.json" + default: + return "" + } +} + +func writeYAMLCommentBlock(level int, doc string, b *strings.Builder) { + pad := indentSpaces(level) + for _, line := range strings.Split(doc, "\n") { + line = strings.TrimSpace(line) + if line == "" { + b.WriteString(pad) + b.WriteString("#\n") + continue + } + b.WriteString(pad) + b.WriteString("# ") + b.WriteString(line) + b.WriteString("\n") + } +} + +func docWithLogicalType(path, doc string, typeByPath map[string]string) string { + if typ, ok := typeByPath[path]; ok && typ != "" { + return doc + " (論理型: " + typ + ")" + } + return doc +} + +func indentSpaces(level int) string { + return strings.Repeat(" ", level) +} diff --git a/internal/manifest/types.go b/internal/manifest/types.go index 69dafc1..7843965 100644 --- a/internal/manifest/types.go +++ b/internal/manifest/types.go @@ -1,5 +1,7 @@ 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"` @@ -48,11 +50,27 @@ type OperationsEvidence struct { Runbook string `yaml:"runbook" json:"runbook,omitempty"` } -// ObservabilityEvidence declares references to SLO, monitors, and dashboard evidence (paths, URLs, or catalog IDs). +// 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"` +} + +// ObservabilityEvidence declares observability evidence as lists of links (typically vendor UI URLs). type ObservabilityEvidence struct { - SLO string `yaml:"slo" json:"slo,omitempty"` - Monitors string `yaml:"monitors" json:"monitors,omitempty"` - Dashboard string `yaml:"dashboard" json:"dashboard,omitempty"` + SLOs []ObservabilityLink `yaml:"slos,omitempty" json:"slos,omitempty"` + Monitors []ObservabilityLink `yaml:"monitors,omitempty" json:"monitors,omitempty"` + Dashboards []ObservabilityLink `yaml:"dashboards,omitempty" json:"dashboards,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 } // InfraEvidence declares infrastructure-related evidence references (deployment spec, IaC pointer, etc.). diff --git a/internal/standard/standard_test.go b/internal/standard/standard_test.go index 7feba0a..7e39ff2 100644 --- a/internal/standard/standard_test.go +++ b/internal/standard/standard_test.go @@ -110,11 +110,11 @@ func TestLoadCLIServiceStandard(t *testing.T) { if !loaded.RequiresGate(OperationsRunbookExistsGateID) { t.Fatal("cli-service standard should require operations runbook gate") } - if !loaded.RequiresGate(ObservabilityMonitorExistsGateID) { - t.Fatal("cli-service standard should declare observability monitor gate") + if loaded.RequiresGate(ObservabilityMonitorExistsGateID) { + t.Fatal("cli-service standard should omit observability gates (CLI workload; no SLO/monitor/dashboard in manifest)") } - if !loaded.RequiresGate(InfraDeploymentSpecExistsGateID) { - t.Fatal("cli-service standard should declare infra deployment spec gate") + if loaded.RequiresGate(InfraDeploymentSpecExistsGateID) { + t.Fatal("cli-service standard should omit infra.deployment_spec_exists (CLI workload)") } if !loaded.RequiresGate(ReleaseRollbackPlanExistsGateID) { t.Fatal("cli-service standard should declare release rollback plan gate") diff --git a/internal/standard/types.go b/internal/standard/types.go index bdd46e8..aa66e4b 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 declared SLO / SLI evidence (manifest reference non-empty). + // ObservabilitySLOExistsGateID gates on at least one URL in evidence.observability.slos. ObservabilitySLOExistsGateID = "observability.slo_exists" - // ObservabilityMonitorExistsGateID gates on monitor / alert evidence reference. + // ObservabilityMonitorExistsGateID gates on at least one URL in evidence.observability.monitors. ObservabilityMonitorExistsGateID = "observability.monitor_exists" - // ObservabilityDashboardExistsGateID gates on dashboard evidence reference. + // ObservabilityDashboardExistsGateID gates on at least one URL in evidence.observability.dashboards. ObservabilityDashboardExistsGateID = "observability.dashboard_exists" // InfraDeploymentSpecExistsGateID gates on a deployment / workload spec reference (path, bundle id, URL, etc.). diff --git a/standards/cli-service.yaml b/standards/cli-service.yaml index 9e6c07b..6761e93 100644 --- a/standards/cli-service.yaml +++ b/standards/cli-service.yaml @@ -17,10 +17,6 @@ gates: required: true - id: operations.runbook_exists required: false - - id: observability.monitor_exists - required: false - - id: infra.deployment_spec_exists - required: false - id: release.rollback_plan_exists required: false - id: release.automation_declared