Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
/dist/
*.test
coverage.out
hado-coverage.json

# Local toolchains
/.tools/
Expand Down
18 changes: 13 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ GO_CMD = $(if $(wildcard $(TOOLS_GO)),$(TOOLS_GO),go)
GOFMT_CMD = $(if $(wildcard $(TOOLS_GO)),$(dir $(TOOLS_GO))gofmt,gofmt)
GO_BOOTSTRAP_VERSION ?= 1.22.12

GO_FILES := $(shell git ls-files '*.go')
GO_FILES := $(shell git ls-files '*.go' | while read f; do test -f "$$f" && printf '%s\n' "$$f"; done)
BINARY := bin/hado
HADO_GOBCE_BINARY := bin/hado-gobce
GO_BIN = $(shell $(GO_CMD) env GOPATH 2>/dev/null)/bin
GOBCE = $(GO_BIN)/gobce
GOBCE_PACKAGE ?= github.com/keyskey/gobce/cmd/gobce@latest
COVERPROFILE ?= coverage.out
READINESS_COVERAGE ?= hado-coverage.json
READINESS_MANIFEST ?= hado.yaml
READINESS_STANDARD ?= standards/cli-service.yaml
MARKDOWNLINT_CLI2_IMAGE ?= davidanson/markdownlint-cli2:v0.22.1
Expand All @@ -23,7 +23,7 @@ help:
@echo " make bootstrap-go # Install Go $(GO_BOOTSTRAP_VERSION) into .tools/go (network; not global)"
@echo " make ensure-go # Use .tools/go if complete, else PATH go, else bootstrap"
@echo " make setup # ensure-go + go install gobce"
@echo " make build # Build hado CLI binary"
@echo " make build # Build hado CLI and bundled module wrapper binaries"
@echo " make lint # Run YAML, Markdown, and Go lint checks"
@echo " make fmt # Format Go source files"
@echo " make fmt-check # Used in make ci-lint (does not run go test)"
Expand Down Expand Up @@ -84,6 +84,7 @@ setup-hooks:
build: ensure-go
@mkdir -p bin
$(GO_CMD) build -o "$(BINARY)" ./cmd/hado
$(GO_CMD) build -o "$(HADO_GOBCE_BINARY)" ./modules/gobce/cmd/hado-gobce

gen-manifest-doc: ensure-go
@mkdir -p bin
Expand Down Expand Up @@ -132,8 +133,15 @@ test: ensure-go
readiness-check: ensure-go
@command -v "$(GOBCE)" >/dev/null 2>&1 || { echo "gobce is required. Run: make setup"; exit 1; }
$(GO_CMD) test ./... -coverprofile="$(COVERPROFILE)"
"$(GOBCE)" analyze --coverprofile "$(COVERPROFILE)" --format json --output "$(READINESS_COVERAGE)"
$(GO_CMD) run ./cmd/hado fire --standard "$(READINESS_STANDARD)" --manifest "$(READINESS_MANIFEST)"
@mkdir -p bin
$(GO_CMD) build -o "$(HADO_GOBCE_BINARY)" ./modules/gobce/cmd/hado-gobce
@tmpdir="$$(mktemp -d)"; \
trap 'rm -rf "$$tmpdir"' EXIT; \
cp "$(READINESS_MANIFEST)" "$$tmpdir/hado.yaml"; \
standard="$(CURDIR)/$(READINESS_STANDARD)"; \
PATH="$(CURDIR)/bin:$(GO_BIN):$$PATH" HADO_COVERPROFILE="$(CURDIR)/$(COVERPROFILE)" \
$(GO_CMD) run ./cmd/hado charge --standard "$$standard" --manifest "$$tmpdir/hado.yaml"; \
$(GO_CMD) run ./cmd/hado fire --standard "$$standard" --manifest "$$tmpdir/hado.yaml"

pre-pr: ci-lint test
@echo "pre-pr: OK."
44 changes: 19 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ make build
./bin/hado
```

`make build` は HADO CLI に加えて coverage module wrapper の `bin/hado-gobce` もビルドします。

## Target manifest(service / standard)

`hado target` は、HADO Manifest に **評価対象の service** と **適用する Readiness Standard** を書き込み、**解決した standard に含まれるゲート**に対応する `evidence` の **キーとひな形**(文字列は空、coverage は `hado-json` + `coverage-metrics.json` の既定)を **マージ**します。既に値があるフィールドは上書きしません(`--rewrite-placeholders=false` でこの evidence のマージをスキップ可能)。`standards/<standard-id>.yaml` は `--standards-dir` または manifest 隣の `standards/` に置きます。
`hado target` は、HADO Manifest に **評価対象の service** と **適用する Readiness Standard** を書き込み、**解決した standard に含まれるゲート**に対応する `evidence` の **キーとひな形**(文字列は空、coverage は `module: gobce`)を **マージ**します。既に値があるフィールドは上書きしません(`--rewrite-placeholders=false` でこの evidence のマージをスキップ可能)。`standards/<standard-id>.yaml` は `--standards-dir` または manifest 隣の `standards/` に置きます。

```bash
./bin/hado target --manifest hado.yaml \
Expand All @@ -52,60 +54,52 @@ TTY で実行すると、現在の manifest の値をデフォルトにしなが
`hado` は `target` / `charge` / `fire` の 3 コマンドで運用します。

- `hado target`: service / standard と evidence のひな形を 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 charge`: coverage module を実行して `evidence.coverage.metrics` を manifest に書き戻す。任意で **`--datadog-discover`** により Datadog Monitor を API から特定し **`evidence.observability.monitor.refs`** に `discovery_type: auto` を upsert する(`DD_API_KEY` / `DD_APP_KEY`、詳細は [docs/observability-readiness.md](docs/observability-readiness.md))
- `hado fire`: manifest に揃った evidence を Readiness Standard の gate と照合して判定する

`hado fire` の終了コードは `0`(ready)、`1`(blocked)、`2`(error)です。`BLOCKED` のときは CI で扱いやすいように 1 で終了します。

HADO core は、特定の runtime、tool、SaaS、infrastructure provider の
フォーマットに直接依存しません。Coverage、Operation、Observability、
Infrastructure、Application、Security などの readiness domain は、
adapter や module が evidence を正規化し、standard の gate が判定します。
module が evidence を正規化し、standard の gate が判定します。

現在の evaluator は、coverage・operations・observability・release(rollback と
自動リリース用 `workflow_refs` の宣言)・infra(deployment 参照)の各 evidence を
Manifest から読み、対応する existence 系 gate を評価できます(詳細は
[docs/implementation-status.md](docs/implementation-status.md))。
Coverage tool 固有の出力は adapter が `c0Coverage` / `c1Coverage` に正規化します。
Coverage tool 固有の出力は coverage module が `test.c0_coverage` / `test.c1_coverage` に正規化し、`hado charge` が manifest に保存します。
`module: gobce` は HADO 同梱の `hado-gobce` wrapper を起動し、内部で `gobce analyze` を実行します。

```bash
printf '{"c0Coverage": 82.1, "c1Coverage": 72.5}\n' > coverage-metrics.json

cat > hado.yaml <<'YAML'
version: v1
evidence:
coverage:
inputs:
- adapter: hado-json
path: coverage-metrics.json
module: gobce
operations:
owner: platform-team
runbook: https://example.com/runbooks/order-api
YAML

./bin/hado charge \
--manifest hado.yaml \
--coverage-input hado-json:coverage-metrics.json
--manifest hado.yaml

./bin/hado fire \
--standard standards/web-service.yaml \
--manifest hado.yaml
```

Go coverprofile や `keyskey/gobce` の JSON output も adapter 経由で扱えます。

```bash
go test ./... -coverprofile=coverage.out
gobce analyze --coverprofile coverage.out --format json --output gobce.json

./bin/hado charge \
--manifest hado.yaml \
--coverage-input go-coverprofile:coverage.out \
--coverage-input gobce-json:gobce.json
`hado charge` 後の manifest には次のように coverage 結果が直接書き戻されます。

./bin/hado fire \
--standard standards/web-service.yaml \
--manifest hado.yaml
```yaml
evidence:
coverage:
module: gobce
measured_at: 2026-05-26T14:30:00Z
metrics:
c0: 82.1
c1: 68.4
```

`hado charge` の `--coverage-input` は manifest の `evidence.coverage.inputs` へ不足分をマージします。既存値は置換しません。`--datadog-discover` を付けたときは、`evidence.observability.monitor.discovery.datadog` が必須で、失敗時(0 件・複数件・認証エラーなど)は exit 2 としその時点の manifest ファイルは更新されません(同一コマンドで coverage マージも済んでいても保存されません)
必要なら `hado charge --coverage-module gobce` で manifest の `evidence.coverage.module` を補助的に指定できます。`--datadog-discover` を付けたときは、`evidence.observability.monitor.discovery.datadog` が必須で、失敗時(0 件・複数件・認証エラーなど)は exit 2 としその時点の manifest ファイルは更新されません。
143 changes: 94 additions & 49 deletions cmd/hado/charge/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,20 @@ import (
"io"
"net/http"
"path/filepath"
"strings"
"time"

"github.com/keyskey/hado/internal/coverage"
"github.com/keyskey/hado/internal/manifest"
"github.com/keyskey/hado/internal/modulerunner"
"github.com/keyskey/hado/internal/standard"
)

type stringList []string

func (values *stringList) String() string {
return strings.Join(*values, ",")
}

func (values *stringList) Set(value string) error {
*values = append(*values, value)
return nil
}

func Run(args []string, stdout, stderr io.Writer) (int, error) {
fs := flag.NewFlagSet("charge", flag.ContinueOnError)
fs.SetOutput(stderr)
manifestPath := fs.String("manifest", "", "path to HADO manifest YAML")
standardRef := fs.String("standard", "", "Readiness Standard id or path (optional; defaults to manifest standard.id)")
standardsDir := fs.String("standards-dir", "", "directory containing <id>.yaml standards (default: <manifest-dir>/standards)")
var coverageInputs stringList
fs.Var(&coverageInputs, "coverage-input", "coverage input as <adapter>:<path>; merges into manifest coverage inputs; adapters: hado-json, go-coverprofile, gobce-json")
coverageModule := fs.String("coverage-module", "", "coverage module to write to evidence.coverage.module before running charge (example: gobce)")
datadogDiscover := fs.Bool("datadog-discover", false, "resolve one Datadog monitor via API and write monitor.refs (discovery_type auto; requires DD_API_KEY, DD_APP_KEY, evidence.observability.monitor.discovery.datadog)")
if err := fs.Parse(args); err != nil {
return 2, err
Expand All @@ -59,8 +47,11 @@ func Run(args []string, stdout, stderr io.Writer) (int, error) {
return 2, err
}

if err := mergeCoverageInputs(&m, coverageInputs); err != nil {
return 2, err
if *coverageModule != "" {
if m.Evidence.Coverage == nil {
m.Evidence.Coverage = &manifest.CoverageEvidence{}
}
m.Evidence.Coverage.Module = *coverageModule
}

if *datadogDiscover {
Expand All @@ -69,52 +60,106 @@ func Run(args []string, stdout, stderr io.Writer) (int, error) {
}
}

if requiresCoverage(st) && len(m.CoverageAdapterInputs()) == 0 {
return 2, fmt.Errorf("charge requires --coverage-input or manifest evidence.coverage.inputs for coverage gates")
}
if len(m.CoverageAdapterInputs()) > 0 {
if _, err := coverage.ParseAdapterInputs(m.CoverageAdapterInputs()); err != nil {
measuredAt := ""
if requiresCoverage(st) {
module := m.CoverageModule()
if module == "" {
return 2, fmt.Errorf("charge requires evidence.coverage.module for coverage gates")
}
result, err := runCoverageModule(context.Background(), module, m)
if err != nil {
return 2, err
}
metrics, err := coverageMetricsFromModuleResult(result)
if err != nil {
return 2, err
}
measuredAt = time.Now().UTC().Format(time.RFC3339)
if m.Evidence.Coverage == nil {
m.Evidence.Coverage = &manifest.CoverageEvidence{}
}
m.Evidence.Coverage.Module = module
m.Evidence.Coverage.MeasuredAt = measuredAt
m.Evidence.Coverage.Metrics = metrics
}
if err := m.Save(*manifestPath); err != nil {
return 2, err
}
fmt.Fprintf(stdout, "Wrote manifest %s (coverage inputs: %d)\n", *manifestPath, len(m.CoverageAdapterInputs()))
if measuredAt != "" {
fmt.Fprintf(stdout, "Wrote manifest %s (coverage module: %s, measured_at: %s)\n", *manifestPath, m.CoverageModule(), measuredAt)
} else {
fmt.Fprintf(stdout, "Wrote manifest %s\n", *manifestPath)
}
return 0, nil
}

func mergeCoverageInputs(m *manifest.Manifest, specs []string) error {
if len(specs) == 0 {
return nil
}
if m.Evidence.Coverage == nil {
m.Evidence.Coverage = &manifest.CoverageEvidence{}
func requiresCoverage(st standard.Standard) bool {
return st.RequiresGate(standard.C0CoverageGateID) || st.RequiresGate(standard.C1CoverageGateID)
}

func runCoverageModule(ctx context.Context, module string, m manifest.Manifest) (modulerunner.RunResult, error) {
spec, err := coverageModuleSpec(module)
if err != nil {
return modulerunner.RunResult{}, err
}
return modulerunner.Run(ctx, spec, modulerunner.RunRequest{
APIVersion: modulerunner.APIVersion,
Operation: "coverage.analyze",
Service: map[string]string{
"id": m.Service.ID,
"name": m.Service.Name,
},
Evidence: map[string]any{
"coverage": map[string]string{"module": module},
},
})
}

existing := make(map[string]struct{}, len(m.Evidence.Coverage.Inputs))
for _, input := range m.Evidence.Coverage.Inputs {
key := input.Adapter + "\x00" + input.Path
existing[key] = struct{}{}
func coverageModuleSpec(module string) (modulerunner.Spec, error) {
switch module {
case "gobce":
return modulerunner.Spec{
ID: "gobce",
Command: "hado-gobce",
Timeout: 2 * time.Minute,
MaxStdoutBytes: modulerunner.DefaultMaxStdoutBytes,
}, nil
default:
return modulerunner.Spec{}, fmt.Errorf("unsupported coverage module %q", module)
}
for _, spec := range specs {
parsed, err := coverage.ParseCoverageSpec(spec)
if err != nil {
return err
}
key := parsed.Format + "\x00" + parsed.Path
if _, ok := existing[key]; ok {
continue
}

func coverageMetricsFromModuleResult(result modulerunner.RunResult) (*manifest.CoverageMetrics, error) {
var c0 *float64
var c1 *float64
for _, metric := range result.Metrics {
value := metric.Value
switch metric.ID {
case standard.C0CoverageGateID:
c0 = &value
case standard.C1CoverageGateID:
c1 = &value
}
m.Evidence.Coverage.Inputs = append(m.Evidence.Coverage.Inputs, manifest.CoverageInput{
Adapter: parsed.Format,
Path: parsed.Path,
})
existing[key] = struct{}{}
}
return nil
metrics := &manifest.CoverageMetrics{C0: c0, C1: c1}
if err := validateCoverageModuleMetrics(metrics); err != nil {
return nil, err
}
return metrics, nil
}

func requiresCoverage(st standard.Standard) bool {
return st.RequiresGate(standard.C0CoverageGateID) || st.RequiresGate(standard.C1CoverageGateID)
func validateCoverageModuleMetrics(metrics *manifest.CoverageMetrics) error {
if metrics.C0 == nil {
return fmt.Errorf("coverage module result missing %s", standard.C0CoverageGateID)
}
if metrics.C1 == nil {
return fmt.Errorf("coverage module result missing %s", standard.C1CoverageGateID)
}
if *metrics.C0 < 0 || *metrics.C0 > 100 {
return fmt.Errorf("coverage module result %s must be between 0 and 100", standard.C0CoverageGateID)
}
if *metrics.C1 < 0 || *metrics.C1 > 100 {
return fmt.Errorf("coverage module result %s must be between 0 and 100", standard.C1CoverageGateID)
}
return nil
}
Loading
Loading