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
12 changes: 8 additions & 4 deletions .cursor/rules/internal-go-packages.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,19 @@ New or refactored code under `internal/` should follow this structure so package

- **Only** the package comment and `package <name>`. 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

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 |
| `<domain>.go` | Focused logic (e.g. `profile.go`, `parse.go`, `specs.go`, `evaluate.go`, `inputs.go`) |
| `<domain>.go` | One file per evidence domain; gate helpers and domain logic |
| `<domain>_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.

Expand All @@ -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 `<domain>.go` file even when the helper is small.

`internal/integration` may stay **doc-only** if it is intentionally a contract marker with no implementation.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 で終了します。
Expand Down Expand Up @@ -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 マージも済んでいても保存されません)。
173 changes: 173 additions & 0 deletions cmd/hado/charge/datadog.go
Original file line number Diff line number Diff line change
@@ -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
}
125 changes: 125 additions & 0 deletions cmd/hado/charge/datadog_discover_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 10 additions & 0 deletions cmd/hado/charge/run.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package charge

import (
"context"
"flag"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"

Expand Down Expand Up @@ -31,6 +33,7 @@ func Run(args []string, stdout, stderr io.Writer) (int, error) {
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")
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 Down Expand Up @@ -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")
}
Expand Down
Loading
Loading