Compare commits
2 Commits
14c3ae7bf4
...
632a1e2824
| Author | SHA1 | Date | |
|---|---|---|---|
| 632a1e2824 | |||
| 2e247b6782 |
11
README.md
11
README.md
@ -115,9 +115,14 @@ Deployment gate script:
|
|||||||
|
|
||||||
Gate order:
|
Gate order:
|
||||||
1. docs contract checks
|
1. docs contract checks
|
||||||
2. naming + LOC hygiene checks
|
2. split test-module contract (`cmd/` + `internal/` cannot grow new in-tree `_test.go` files)
|
||||||
3. pedantic lint
|
3. naming + LOC hygiene checks
|
||||||
4. per-file coverage gate (95% minimum)
|
4. pedantic lint
|
||||||
|
5. per-file coverage gate (95% minimum)
|
||||||
|
|
||||||
|
Current migration rule:
|
||||||
|
- keep new tests in the top-level `testing/` module
|
||||||
|
- legacy in-tree `_test.go` files are temporarily grandfathered through `testing/hygiene/in_tree_test_allowlist.txt` until they are migrated safely
|
||||||
|
|
||||||
Installer behavior:
|
Installer behavior:
|
||||||
- `scripts/install.sh` runs the quality gate by default
|
- `scripts/install.sh` runs the quality gate by default
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
package execx
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestRunnerRunFailureWithoutOutput runs one orchestration or CLI step.
|
|
||||||
// Signature: TestRunnerRunFailureWithoutOutput(t *testing.T).
|
|
||||||
// Why: covers error branch where command fails without producing output.
|
|
||||||
func TestRunnerRunFailureWithoutOutput(t *testing.T) {
|
|
||||||
r := &Runner{}
|
|
||||||
out, err := r.Run(context.Background(), "sh", "-c", "exit 3")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected failure")
|
|
||||||
}
|
|
||||||
if out != "" {
|
|
||||||
t.Fatalf("expected empty output, got %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunnerLogfNoLogger runs one orchestration or CLI step.
|
|
||||||
// Signature: TestRunnerLogfNoLogger(t *testing.T).
|
|
||||||
// Why: covers no-op logging path.
|
|
||||||
func TestRunnerLogfNoLogger(t *testing.T) {
|
|
||||||
r := &Runner{}
|
|
||||||
r.logf("hello %s", "world")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunnerCommandMissing runs one orchestration or CLI step.
|
|
||||||
// Signature: TestRunnerCommandMissing(t *testing.T).
|
|
||||||
// Why: covers false branch of command existence checks.
|
|
||||||
func TestRunnerCommandMissing(t *testing.T) {
|
|
||||||
r := &Runner{}
|
|
||||||
if r.CommandExists("definitely-not-a-real-command-ananke") {
|
|
||||||
t.Fatalf("expected missing command to be false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunnerInjectsKubeconfigEnv runs one orchestration or CLI step.
|
|
||||||
// Signature: TestRunnerInjectsKubeconfigEnv(t *testing.T).
|
|
||||||
// Why: covers kubeconfig environment injection branch in command runner.
|
|
||||||
func TestRunnerInjectsKubeconfigEnv(t *testing.T) {
|
|
||||||
r := &Runner{Kubeconfig: "/tmp/test-kubeconfig"}
|
|
||||||
out, err := r.Run(context.Background(), "sh", "-c", "printf %s \"$KUBECONFIG\"")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("runner command failed: %v", err)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(out) != "/tmp/test-kubeconfig" {
|
|
||||||
t.Fatalf("expected kubeconfig env to propagate, got %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
package execx
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestRunnerDryRun runs one orchestration or CLI step.
|
|
||||||
// Signature: TestRunnerDryRun(t *testing.T).
|
|
||||||
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
|
|
||||||
func TestRunnerDryRun(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
r := &Runner{
|
|
||||||
DryRun: true,
|
|
||||||
Logger: log.New(&buf, "", 0),
|
|
||||||
}
|
|
||||||
out, err := r.Run(context.Background(), "echo", "hello")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("dry-run should not fail: %v", err)
|
|
||||||
}
|
|
||||||
if out != "" {
|
|
||||||
t.Fatalf("expected empty dry-run output, got %q", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(buf.String(), "DRY-RUN: echo hello") {
|
|
||||||
t.Fatalf("expected dry-run log entry, got %q", buf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunnerRunSuccess runs one orchestration or CLI step.
|
|
||||||
// Signature: TestRunnerRunSuccess(t *testing.T).
|
|
||||||
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
|
|
||||||
func TestRunnerRunSuccess(t *testing.T) {
|
|
||||||
r := &Runner{}
|
|
||||||
out, err := r.Run(context.Background(), "sh", "-c", "printf ok")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected command success: %v", err)
|
|
||||||
}
|
|
||||||
if out != "ok" {
|
|
||||||
t.Fatalf("expected output ok, got %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunnerRunFailureIncludesOutput runs one orchestration or CLI step.
|
|
||||||
// Signature: TestRunnerRunFailureIncludesOutput(t *testing.T).
|
|
||||||
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
|
|
||||||
func TestRunnerRunFailureIncludesOutput(t *testing.T) {
|
|
||||||
r := &Runner{}
|
|
||||||
out, err := r.Run(context.Background(), "sh", "-c", "echo boom >&2; exit 1")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected command failure")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(out) != "boom" {
|
|
||||||
t.Fatalf("expected stderr to be preserved, got %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunnerCommandExists runs one orchestration or CLI step.
|
|
||||||
// Signature: TestRunnerCommandExists(t *testing.T).
|
|
||||||
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
|
|
||||||
func TestRunnerCommandExists(t *testing.T) {
|
|
||||||
r := &Runner{}
|
|
||||||
if !r.CommandExists("sh") {
|
|
||||||
t.Fatalf("expected shell command to exist")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
9
internal/execx/testing_hooks.go
Normal file
9
internal/execx/testing_hooks.go
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package execx
|
||||||
|
|
||||||
|
// TestHookLogf runs one orchestration or CLI step.
|
||||||
|
// Signature: TestHookLogf(r *Runner, format string, args ...any).
|
||||||
|
// Why: top-level testing module still needs to hit the nil-logger branch
|
||||||
|
// directly while runner tests move out of the root module.
|
||||||
|
func TestHookLogf(r *Runner, format string, args ...any) {
|
||||||
|
r.logf(format, args...)
|
||||||
|
}
|
||||||
@ -1,105 +0,0 @@
|
|||||||
package metrics
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestExporterHealthzAndEscaping runs one orchestration or CLI step.
|
|
||||||
// Signature: TestExporterHealthzAndEscaping(t *testing.T).
|
|
||||||
// Why: covers health endpoint and label escaping branches in metrics renderer.
|
|
||||||
func TestExporterHealthzAndEscaping(t *testing.T) {
|
|
||||||
e := New()
|
|
||||||
e.UpdateSample(Sample{
|
|
||||||
Name: `Sta"tera`,
|
|
||||||
Target: `statera\host`,
|
|
||||||
Status: `O"B`,
|
|
||||||
LastError: "x",
|
|
||||||
})
|
|
||||||
|
|
||||||
h := e.Handler("/custom")
|
|
||||||
healthReq := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
|
||||||
healthRR := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(healthRR, healthReq)
|
|
||||||
if healthRR.Code != http.StatusOK || strings.TrimSpace(healthRR.Body.String()) != "ok" {
|
|
||||||
t.Fatalf("unexpected health response: code=%d body=%q", healthRR.Code, healthRR.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
metricsReq := httptest.NewRequest(http.MethodGet, "/custom", nil)
|
|
||||||
metricsRR := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(metricsRR, metricsReq)
|
|
||||||
body := metricsRR.Body.String()
|
|
||||||
if !strings.Contains(body, `source="Sta\\\"tera"`) {
|
|
||||||
t.Fatalf("expected escaped source label, got:\n%s", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, `target="statera\\\\host"`) {
|
|
||||||
t.Fatalf("expected escaped target label, got:\n%s", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "ananke_ups_error") {
|
|
||||||
t.Fatalf("expected error metric line in output")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBoolNumAndSafeHelpers runs one orchestration or CLI step.
|
|
||||||
// Signature: TestBoolNumAndSafeHelpers(t *testing.T).
|
|
||||||
// Why: directly covers remaining helper branches.
|
|
||||||
func TestBoolNumAndSafeHelpers(t *testing.T) {
|
|
||||||
if boolNum(true) != 1 || boolNum(false) != 0 {
|
|
||||||
t.Fatalf("unexpected boolNum values")
|
|
||||||
}
|
|
||||||
if got := safe(`a"b\c`); got != `a\"b\\c` {
|
|
||||||
t.Fatalf("unexpected escaped string: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestExporterAppendsQualityGateMetrics runs one orchestration or CLI step.
|
|
||||||
// Signature: TestExporterAppendsQualityGateMetrics(t *testing.T).
|
|
||||||
// Why: verifies quality-gate metrics are surfaced on /metrics for Grafana suite
|
|
||||||
// pass-rate tracking.
|
|
||||||
func TestExporterAppendsQualityGateMetrics(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
metricsPath := filepath.Join(tmp, "quality-gate.prom")
|
|
||||||
content := strings.Join([]string{
|
|
||||||
`# HELP ananke_quality_gate_runs_total Total quality gate runs by status.`,
|
|
||||||
`# TYPE ananke_quality_gate_runs_total counter`,
|
|
||||||
`ananke_quality_gate_runs_total{suite="ananke",status="ok"} 10`,
|
|
||||||
`ananke_quality_gate_runs_total{suite="ananke",status="failed"} 2`,
|
|
||||||
"",
|
|
||||||
}, "\n")
|
|
||||||
if err := os.WriteFile(metricsPath, []byte(content), 0o600); err != nil {
|
|
||||||
t.Fatalf("write quality metrics file: %v", err)
|
|
||||||
}
|
|
||||||
t.Setenv("ANANKE_QUALITY_METRICS_FILE", metricsPath)
|
|
||||||
|
|
||||||
e := New()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
e.Handler("/metrics").ServeHTTP(rr, req)
|
|
||||||
body := rr.Body.String()
|
|
||||||
if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="ok"} 10`) {
|
|
||||||
t.Fatalf("expected quality gate metrics appended to exporter output, got:\n%s", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestExporterDefaultsQualityGateMetrics runs one orchestration or CLI step.
|
|
||||||
// Signature: TestExporterDefaultsQualityGateMetrics(t *testing.T).
|
|
||||||
// Why: ensures Grafana panels see zero-value quality-gate series instead of
|
|
||||||
// "no data" before host-side quality-gate files exist.
|
|
||||||
func TestExporterDefaultsQualityGateMetrics(t *testing.T) {
|
|
||||||
t.Setenv("ANANKE_QUALITY_METRICS_FILE", filepath.Join(t.TempDir(), "missing.prom"))
|
|
||||||
e := New()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
e.Handler("/metrics").ServeHTTP(rr, req)
|
|
||||||
body := rr.Body.String()
|
|
||||||
if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="ok"} 0`) {
|
|
||||||
t.Fatalf("expected default ok=0 quality gate metric, got:\n%s", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="failed"} 0`) {
|
|
||||||
t.Fatalf("expected default failed=0 quality gate metric, got:\n%s", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
package metrics
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestExporterEmitsCoreMetrics runs one orchestration or CLI step.
|
|
||||||
// Signature: TestExporterEmitsCoreMetrics(t *testing.T).
|
|
||||||
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
|
|
||||||
func TestExporterEmitsCoreMetrics(t *testing.T) {
|
|
||||||
e := New()
|
|
||||||
e.UpdateBudget(321)
|
|
||||||
e.UpdateSample(Sample{
|
|
||||||
Name: "Pyrphoros",
|
|
||||||
Target: "pyrphoros@localhost",
|
|
||||||
OnBattery: true,
|
|
||||||
LowBattery: false,
|
|
||||||
RuntimeSecond: 412,
|
|
||||||
BatteryCharge: 81.5,
|
|
||||||
LoadPercent: 27.4,
|
|
||||||
PowerNominalW: 510,
|
|
||||||
ThresholdSec: 354,
|
|
||||||
Trigger: true,
|
|
||||||
BreachCount: 2,
|
|
||||||
Status: "OB",
|
|
||||||
UpdatedAt: time.Unix(1710000000, 0).UTC(),
|
|
||||||
})
|
|
||||||
e.MarkShutdown("ups-threshold")
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
e.Handler("/metrics").ServeHTTP(rr, req)
|
|
||||||
body := rr.Body.String()
|
|
||||||
|
|
||||||
mustContain := []string{
|
|
||||||
"ananke_shutdown_budget_seconds 321",
|
|
||||||
"ananke_shutdown_triggers_total 1",
|
|
||||||
"ananke_ups_on_battery{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
|
|
||||||
"ananke_ups_runtime_seconds{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
|
|
||||||
"ananke_ups_battery_charge_percent{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
|
|
||||||
"ananke_ups_load_percent{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
|
|
||||||
"ananke_ups_power_nominal_watts{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
|
|
||||||
"ananke_ups_threshold_seconds{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
|
|
||||||
}
|
|
||||||
for _, m := range mustContain {
|
|
||||||
if !strings.Contains(body, m) {
|
|
||||||
t.Fatalf("missing metric fragment %q in output:\n%s", m, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
17
internal/metrics/testing_hooks.go
Normal file
17
internal/metrics/testing_hooks.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
// TestHookBoolNum runs one orchestration or CLI step.
|
||||||
|
// Signature: TestHookBoolNum(v bool) int.
|
||||||
|
// Why: top-level testing module coverage still needs explicit access to the
|
||||||
|
// boolean-to-metric helper without keeping package-local root tests around.
|
||||||
|
func TestHookBoolNum(v bool) int {
|
||||||
|
return boolNum(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHookSafeLabelValue runs one orchestration or CLI step.
|
||||||
|
// Signature: TestHookSafeLabelValue(in string) string.
|
||||||
|
// Why: top-level testing module coverage still needs to verify label escaping
|
||||||
|
// behavior directly while split-module migration removes in-tree tests.
|
||||||
|
func TestHookSafeLabelValue(in string) string {
|
||||||
|
return safe(in)
|
||||||
|
}
|
||||||
@ -145,6 +145,9 @@ go test ./hygiene -run TestHygieneContracts/naming_contract -count=1
|
|||||||
|
|
||||||
echo "[quality] hygiene: LOC limits"
|
echo "[quality] hygiene: LOC limits"
|
||||||
go test ./hygiene -run TestHygieneContracts/loc_limit -count=1
|
go test ./hygiene -run TestHygieneContracts/loc_limit -count=1
|
||||||
|
|
||||||
|
echo "[quality] hygiene: split test-module contract"
|
||||||
|
go test ./hygiene -run TestHygieneContracts/split_module_contract -count=1
|
||||||
cd "${REPO_DIR}"
|
cd "${REPO_DIR}"
|
||||||
|
|
||||||
echo "[quality] lint"
|
echo "[quality] lint"
|
||||||
|
|||||||
@ -117,6 +117,8 @@ func TestPerFileCoverageReport(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
filepath.Join(root, "testing"),
|
filepath.Join(root, "testing"),
|
||||||
testingCover,
|
testingCover,
|
||||||
|
"./execx",
|
||||||
|
"./metrics",
|
||||||
"./hygiene",
|
"./hygiene",
|
||||||
"./orchestrator",
|
"./orchestrator",
|
||||||
"./state",
|
"./state",
|
||||||
|
|||||||
66
testing/execx/runner_command_contract_test.go
Normal file
66
testing/execx/runner_command_contract_test.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
package execxquality
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"scm.bstein.dev/bstein/ananke/internal/execx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRunnerRunSuccess runs one orchestration or CLI step.
|
||||||
|
// Signature: TestRunnerRunSuccess(t *testing.T).
|
||||||
|
// Why: keeps command execution success behavior stable after moving runner
|
||||||
|
// checks into the top-level testing module.
|
||||||
|
func TestRunnerRunSuccess(t *testing.T) {
|
||||||
|
runner := &execx.Runner{}
|
||||||
|
out, err := runner.Run(context.Background(), "sh", "-c", "printf ok")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected command success: %v", err)
|
||||||
|
}
|
||||||
|
if out != "ok" {
|
||||||
|
t.Fatalf("expected output ok, got %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunnerRunFailureIncludesOutput runs one orchestration or CLI step.
|
||||||
|
// Signature: TestRunnerRunFailureIncludesOutput(t *testing.T).
|
||||||
|
// Why: preserves failure-output behavior checks after split-module migration.
|
||||||
|
func TestRunnerRunFailureIncludesOutput(t *testing.T) {
|
||||||
|
runner := &execx.Runner{}
|
||||||
|
out, err := runner.Run(context.Background(), "sh", "-c", "echo boom >&2; exit 1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected command failure")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out) != "boom" {
|
||||||
|
t.Fatalf("expected stderr to be preserved, got %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunnerRunFailureWithoutOutput runs one orchestration or CLI step.
|
||||||
|
// Signature: TestRunnerRunFailureWithoutOutput(t *testing.T).
|
||||||
|
// Why: keeps the empty-output failure branch covered after root-module tests are removed.
|
||||||
|
func TestRunnerRunFailureWithoutOutput(t *testing.T) {
|
||||||
|
runner := &execx.Runner{}
|
||||||
|
out, err := runner.Run(context.Background(), "sh", "-c", "exit 3")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected failure")
|
||||||
|
}
|
||||||
|
if out != "" {
|
||||||
|
t.Fatalf("expected empty output, got %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunnerCommandDiscovery runs one orchestration or CLI step.
|
||||||
|
// Signature: TestRunnerCommandDiscovery(t *testing.T).
|
||||||
|
// Why: preserves both positive and negative command existence checks from the
|
||||||
|
// top-level testing module.
|
||||||
|
func TestRunnerCommandDiscovery(t *testing.T) {
|
||||||
|
runner := &execx.Runner{}
|
||||||
|
if !runner.CommandExists("sh") {
|
||||||
|
t.Fatalf("expected shell command to exist")
|
||||||
|
}
|
||||||
|
if runner.CommandExists("definitely-not-a-real-command-ananke") {
|
||||||
|
t.Fatalf("expected missing command to be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
54
testing/execx/runner_environment_contract_test.go
Normal file
54
testing/execx/runner_environment_contract_test.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package execxquality
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"scm.bstein.dev/bstein/ananke/internal/execx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRunnerDryRun runs one orchestration or CLI step.
|
||||||
|
// Signature: TestRunnerDryRun(t *testing.T).
|
||||||
|
// Why: keeps dry-run logging behavior explicit after moving runner tests into
|
||||||
|
// the top-level testing module.
|
||||||
|
func TestRunnerDryRun(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
runner := &execx.Runner{
|
||||||
|
DryRun: true,
|
||||||
|
Logger: log.New(&buf, "", 0),
|
||||||
|
}
|
||||||
|
out, err := runner.Run(context.Background(), "echo", "hello")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dry-run should not fail: %v", err)
|
||||||
|
}
|
||||||
|
if out != "" {
|
||||||
|
t.Fatalf("expected empty dry-run output, got %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "DRY-RUN: echo hello") {
|
||||||
|
t.Fatalf("expected dry-run log entry, got %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunnerInjectsKubeconfigEnv runs one orchestration or CLI step.
|
||||||
|
// Signature: TestRunnerInjectsKubeconfigEnv(t *testing.T).
|
||||||
|
// Why: keeps kubeconfig environment propagation covered from the split testing module.
|
||||||
|
func TestRunnerInjectsKubeconfigEnv(t *testing.T) {
|
||||||
|
runner := &execx.Runner{Kubeconfig: "/tmp/test-kubeconfig"}
|
||||||
|
out, err := runner.Run(context.Background(), "sh", "-c", "printf %s \"$KUBECONFIG\"")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runner command failed: %v", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out) != "/tmp/test-kubeconfig" {
|
||||||
|
t.Fatalf("expected kubeconfig env to propagate, got %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunnerNilLoggerHook runs one orchestration or CLI step.
|
||||||
|
// Signature: TestRunnerNilLoggerHook(t *testing.T).
|
||||||
|
// Why: keeps the nil-logger no-op branch covered without reintroducing same-package tests.
|
||||||
|
func TestRunnerNilLoggerHook(t *testing.T) {
|
||||||
|
execx.TestHookLogf(&execx.Runner{}, "hello %s", "world")
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package hygiene
|
package hygiene
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"go/ast"
|
"go/ast"
|
||||||
"go/parser"
|
"go/parser"
|
||||||
"go/token"
|
"go/token"
|
||||||
@ -71,6 +72,9 @@ func TestHygieneContracts(t *testing.T) {
|
|||||||
t.Run("loc_limit", func(t *testing.T) {
|
t.Run("loc_limit", func(t *testing.T) {
|
||||||
checkFileLOCLimits(t, files)
|
checkFileLOCLimits(t, files)
|
||||||
})
|
})
|
||||||
|
t.Run("split_module_contract", func(t *testing.T) {
|
||||||
|
checkSplitModuleContract(t, root, files)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkDocContracts runs one orchestration or CLI step.
|
// checkDocContracts runs one orchestration or CLI step.
|
||||||
@ -137,3 +141,108 @@ func checkFileLOCLimits(t *testing.T, files []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkSplitModuleContract runs one orchestration or CLI step.
|
||||||
|
// Signature: checkSplitModuleContract(t *testing.T, root string, files []string).
|
||||||
|
// Why: The long-term test layout requirement is top-level `testing/`; this
|
||||||
|
// guard freezes the remaining in-tree baseline so new root-module tests cannot
|
||||||
|
// slip in while the migration continues incrementally and safely.
|
||||||
|
func checkSplitModuleContract(t *testing.T, root string, files []string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
allowlistPath := filepath.Join(root, "testing", "hygiene", "in_tree_test_allowlist.txt")
|
||||||
|
allowed := loadInTreeTestAllowlist(t, allowlistPath)
|
||||||
|
actual := make([]string, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
if !strings.HasSuffix(file, "_test.go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
actual = append(actual, mustRepoRelativePath(t, root, file))
|
||||||
|
}
|
||||||
|
sort.Strings(actual)
|
||||||
|
|
||||||
|
allowedSet := make(map[string]struct{}, len(allowed))
|
||||||
|
for _, file := range allowed {
|
||||||
|
allowedSet[file] = struct{}{}
|
||||||
|
}
|
||||||
|
actualSet := make(map[string]struct{}, len(actual))
|
||||||
|
for _, file := range actual {
|
||||||
|
actualSet[file] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
unexpected := make([]string, 0)
|
||||||
|
for _, file := range actual {
|
||||||
|
if _, ok := allowedSet[file]; !ok {
|
||||||
|
unexpected = append(unexpected, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
missing := make([]string, 0)
|
||||||
|
for _, file := range allowed {
|
||||||
|
if _, ok := actualSet[file]; !ok {
|
||||||
|
missing = append(missing, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unexpected) == 0 && len(missing) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var report strings.Builder
|
||||||
|
report.WriteString("split-module contract mismatch\n")
|
||||||
|
if len(unexpected) > 0 {
|
||||||
|
report.WriteString("unexpected in-tree tests (move them under testing/ or explicitly rebaseline if this is an intentional migration checkpoint):\n")
|
||||||
|
for _, file := range unexpected {
|
||||||
|
report.WriteString("- " + file + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
report.WriteString("stale allowlist entries (remove them from testing/hygiene/in_tree_test_allowlist.txt after a migration):\n")
|
||||||
|
for _, file := range missing {
|
||||||
|
report.WriteString("- " + file + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal(report.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadInTreeTestAllowlist runs one orchestration or CLI step.
|
||||||
|
// Signature: loadInTreeTestAllowlist(t *testing.T, path string) []string.
|
||||||
|
// Why: The split-module baseline needs one canonical source of truth so the
|
||||||
|
// gate can distinguish legacy in-tree tests from accidental new ones.
|
||||||
|
func loadInTreeTestAllowlist(t *testing.T, path string) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open in-tree test allowlist %s: %v", path, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
entries := make([]string, 0, 64)
|
||||||
|
s := bufio.NewScanner(f)
|
||||||
|
for s.Scan() {
|
||||||
|
line := strings.TrimSpace(s.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, filepath.ToSlash(line))
|
||||||
|
}
|
||||||
|
if err := s.Err(); err != nil {
|
||||||
|
t.Fatalf("scan in-tree test allowlist %s: %v", path, err)
|
||||||
|
}
|
||||||
|
sort.Strings(entries)
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustRepoRelativePath runs one orchestration or CLI step.
|
||||||
|
// Signature: mustRepoRelativePath(t *testing.T, root string, path string) string.
|
||||||
|
// Why: Relative slash-normalized paths keep split-module hygiene output stable
|
||||||
|
// across machines and make allowlist diffs easy to review.
|
||||||
|
func mustRepoRelativePath(t *testing.T, root string, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("make %s relative to %s: %v", path, root, err)
|
||||||
|
}
|
||||||
|
return filepath.ToSlash(rel)
|
||||||
|
}
|
||||||
|
|||||||
34
testing/hygiene/in_tree_test_allowlist.txt
Normal file
34
testing/hygiene/in_tree_test_allowlist.txt
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Remaining root-module test baseline.
|
||||||
|
# New tests belong under the top-level testing/ module.
|
||||||
|
cmd/ananke/bootstrap_handoff_additional_test.go
|
||||||
|
cmd/ananke/bootstrap_handoff_test.go
|
||||||
|
cmd/ananke/builder_test.go
|
||||||
|
cmd/ananke/command_additional_test.go
|
||||||
|
cmd/ananke/command_handlers_injection_test.go
|
||||||
|
cmd/ananke/command_handlers_status_error_test.go
|
||||||
|
cmd/ananke/command_handlers_test.go
|
||||||
|
cmd/ananke/main_dispatch_additional_test.go
|
||||||
|
cmd/ananke/main_dispatch_test.go
|
||||||
|
cmd/ananke/power_safety_test.go
|
||||||
|
cmd/ananke/test_helpers_test.go
|
||||||
|
internal/cluster/orchestrator_inventory_test.go
|
||||||
|
internal/cluster/orchestrator_report_test.go
|
||||||
|
internal/cluster/orchestrator_test.go
|
||||||
|
internal/cluster/orchestrator_unit_additional_test.go
|
||||||
|
internal/cluster/orchestrator_vault_test.go
|
||||||
|
internal/config/config_test.go
|
||||||
|
internal/config/load_additional_test.go
|
||||||
|
internal/config/validate_matrix_test.go
|
||||||
|
internal/service/daemon_additional_test.go
|
||||||
|
internal/service/daemon_coverage_closeout_test.go
|
||||||
|
internal/service/daemon_quality_branches_test.go
|
||||||
|
internal/service/daemon_test.go
|
||||||
|
internal/sshutil/repair_test.go
|
||||||
|
internal/sshutil/sshutil_test.go
|
||||||
|
internal/state/heal_test.go
|
||||||
|
internal/state/intent_additional_test.go
|
||||||
|
internal/state/intent_test.go
|
||||||
|
internal/state/store_additional_test.go
|
||||||
|
internal/state/store_test.go
|
||||||
|
internal/ups/nut_additional_test.go
|
||||||
|
internal/ups/nut_test.go
|
||||||
106
testing/metrics/exporter_http_contract_test.go
Normal file
106
testing/metrics/exporter_http_contract_test.go
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
package metricsquality
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"scm.bstein.dev/bstein/ananke/internal/metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestExporterEmitsCoreMetrics runs one orchestration or CLI step.
|
||||||
|
// Signature: TestExporterEmitsCoreMetrics(t *testing.T).
|
||||||
|
// Why: keeps exporter metric output stable after moving checks into the
|
||||||
|
// top-level testing module.
|
||||||
|
func TestExporterEmitsCoreMetrics(t *testing.T) {
|
||||||
|
exporter := metrics.New()
|
||||||
|
exporter.UpdateBudget(321)
|
||||||
|
exporter.UpdateSample(metrics.Sample{
|
||||||
|
Name: "Pyrphoros",
|
||||||
|
Target: "pyrphoros@localhost",
|
||||||
|
OnBattery: true,
|
||||||
|
LowBattery: false,
|
||||||
|
RuntimeSecond: 412,
|
||||||
|
BatteryCharge: 81.5,
|
||||||
|
LoadPercent: 27.4,
|
||||||
|
PowerNominalW: 510,
|
||||||
|
ThresholdSec: 354,
|
||||||
|
Trigger: true,
|
||||||
|
BreachCount: 2,
|
||||||
|
Status: "OB",
|
||||||
|
UpdatedAt: time.Unix(1710000000, 0).UTC(),
|
||||||
|
})
|
||||||
|
exporter.MarkShutdown("ups-threshold")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
exporter.Handler("/metrics").ServeHTTP(rr, req)
|
||||||
|
body := rr.Body.String()
|
||||||
|
|
||||||
|
mustContain := []string{
|
||||||
|
"ananke_shutdown_budget_seconds 321",
|
||||||
|
"ananke_shutdown_triggers_total 1",
|
||||||
|
"ananke_ups_on_battery{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
|
||||||
|
"ananke_ups_runtime_seconds{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
|
||||||
|
"ananke_ups_battery_charge_percent{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
|
||||||
|
"ananke_ups_load_percent{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
|
||||||
|
"ananke_ups_power_nominal_watts{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
|
||||||
|
"ananke_ups_threshold_seconds{source=\"Pyrphoros\",target=\"pyrphoros@localhost\"",
|
||||||
|
}
|
||||||
|
for _, fragment := range mustContain {
|
||||||
|
if !strings.Contains(body, fragment) {
|
||||||
|
t.Fatalf("missing metric fragment %q in output:\n%s", fragment, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExporterHealthzAndEscaping runs one orchestration or CLI step.
|
||||||
|
// Signature: TestExporterHealthzAndEscaping(t *testing.T).
|
||||||
|
// Why: covers exporter health routing and label escaping from the top-level
|
||||||
|
// testing module so HTTP behavior stays explicit after migration.
|
||||||
|
func TestExporterHealthzAndEscaping(t *testing.T) {
|
||||||
|
exporter := metrics.New()
|
||||||
|
exporter.UpdateSample(metrics.Sample{
|
||||||
|
Name: `Sta"tera`,
|
||||||
|
Target: `statera\host`,
|
||||||
|
Status: `O"B`,
|
||||||
|
LastError: "x",
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := exporter.Handler("/custom")
|
||||||
|
healthReq := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
|
healthRR := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(healthRR, healthReq)
|
||||||
|
if healthRR.Code != http.StatusOK || strings.TrimSpace(healthRR.Body.String()) != "ok" {
|
||||||
|
t.Fatalf("unexpected health response: code=%d body=%q", healthRR.Code, healthRR.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
metricsReq := httptest.NewRequest(http.MethodGet, "/custom", nil)
|
||||||
|
metricsRR := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(metricsRR, metricsReq)
|
||||||
|
body := metricsRR.Body.String()
|
||||||
|
if !strings.Contains(body, `source="Sta\\\"tera"`) {
|
||||||
|
t.Fatalf("expected escaped source label, got:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `target="statera\\\\host"`) {
|
||||||
|
t.Fatalf("expected escaped target label, got:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "ananke_ups_error") {
|
||||||
|
t.Fatalf("expected error metric line in output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExporterHelperContracts runs one orchestration or CLI step.
|
||||||
|
// Signature: TestExporterHelperContracts(t *testing.T).
|
||||||
|
// Why: preserves direct helper coverage for metric formatting utilities after
|
||||||
|
// removing same-package root tests.
|
||||||
|
func TestExporterHelperContracts(t *testing.T) {
|
||||||
|
if metrics.TestHookBoolNum(true) != 1 || metrics.TestHookBoolNum(false) != 0 {
|
||||||
|
t.Fatalf("unexpected boolNum values")
|
||||||
|
}
|
||||||
|
if got := metrics.TestHookSafeLabelValue(`a"b\c`); got != `a\"b\\c` {
|
||||||
|
t.Fatalf("unexpected escaped string: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
testing/metrics/exporter_quality_gate_metrics_test.go
Normal file
60
testing/metrics/exporter_quality_gate_metrics_test.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package metricsquality
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"scm.bstein.dev/bstein/ananke/internal/metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestExporterAppendsQualityGateMetrics runs one orchestration or CLI step.
|
||||||
|
// Signature: TestExporterAppendsQualityGateMetrics(t *testing.T).
|
||||||
|
// Why: verifies exporter output still includes host quality-gate metrics after
|
||||||
|
// the split-module migration.
|
||||||
|
func TestExporterAppendsQualityGateMetrics(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
metricsPath := filepath.Join(tmp, "quality-gate.prom")
|
||||||
|
content := strings.Join([]string{
|
||||||
|
`# HELP ananke_quality_gate_runs_total Total quality gate runs by status.`,
|
||||||
|
`# TYPE ananke_quality_gate_runs_total counter`,
|
||||||
|
`ananke_quality_gate_runs_total{suite="ananke",status="ok"} 10`,
|
||||||
|
`ananke_quality_gate_runs_total{suite="ananke",status="failed"} 2`,
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
if err := os.WriteFile(metricsPath, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("write quality metrics file: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("ANANKE_QUALITY_METRICS_FILE", metricsPath)
|
||||||
|
|
||||||
|
exporter := metrics.New()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
exporter.Handler("/metrics").ServeHTTP(rr, req)
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="ok"} 10`) {
|
||||||
|
t.Fatalf("expected quality gate metrics appended to exporter output, got:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExporterDefaultsQualityGateMetrics runs one orchestration or CLI step.
|
||||||
|
// Signature: TestExporterDefaultsQualityGateMetrics(t *testing.T).
|
||||||
|
// Why: keeps zero-value quality-gate series visible before the host writes its
|
||||||
|
// first metrics file.
|
||||||
|
func TestExporterDefaultsQualityGateMetrics(t *testing.T) {
|
||||||
|
t.Setenv("ANANKE_QUALITY_METRICS_FILE", filepath.Join(t.TempDir(), "missing.prom"))
|
||||||
|
exporter := metrics.New()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
exporter.Handler("/metrics").ServeHTTP(rr, req)
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="ok"} 0`) {
|
||||||
|
t.Fatalf("expected default ok=0 quality gate metric, got:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `ananke_quality_gate_runs_total{suite="ananke",status="failed"} 0`) {
|
||||||
|
t.Fatalf("expected default failed=0 quality gate metric, got:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user