ananke/testing/service/gitops_snapshot_test.go

317 lines
13 KiB
Go

package servicequality
import (
"context"
"errors"
"io"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/metrics"
"scm.bstein.dev/bstein/ananke/internal/service"
)
// TestParseGitRepositories runs one orchestration or CLI step.
// Signature: TestParseGitRepositories(t *testing.T).
// Why: branch, revision, and Ready status are the labels the Grafana GitOps
// panels rely on for the current Flux source.
func TestParseGitRepositories(t *testing.T) {
raw := []byte(`{
"items": [{
"metadata": {"name": "flux-system", "namespace": "flux-system"},
"spec": {"url": "ssh://git@example/repo.git", "ref": {"branch": "main"}},
"status": {
"artifact": {"revision": "main@sha1:abc123"},
"conditions": [{"type": "Ready", "status": "True", "reason": "Succeeded"}]
}
}]
}`)
got, err := service.TestHookParseGitRepositories(raw)
if err != nil {
t.Fatalf("parseGitRepositories failed: %v", err)
}
if len(got) != 1 || got[0].Branch != "main" || got[0].Revision != "main@sha1:abc123" || !got[0].Ready {
t.Fatalf("unexpected GitRepository parse result: %+v", got)
}
}
// TestParseKustomizations runs one orchestration or CLI step.
// Signature: TestParseKustomizations(t *testing.T).
// Why: Kustomization source and suspend labels power the detailed GitOps table.
func TestParseKustomizations(t *testing.T) {
raw := []byte(`{
"items": [{
"metadata": {"name": "monitoring", "namespace": "flux-system"},
"spec": {
"path": "./services/monitoring",
"sourceRef": {"name": "flux-system"},
"suspend": true
},
"status": {
"lastAppliedRevision": "main@sha1:def456",
"conditions": [{"type": "Ready", "status": "False", "reason": "DependencyNotReady"}]
}
}]
}`)
got, err := service.TestHookParseKustomizations(raw)
if err != nil {
t.Fatalf("parseKustomizations failed: %v", err)
}
if len(got) != 1 || got[0].SourceNamespace != "flux-system" || got[0].Ready || !got[0].Suspended {
t.Fatalf("unexpected Kustomization parse result: %+v", got)
}
}
// TestParseHelmReleases runs one orchestration or CLI step.
// Signature: TestParseHelmReleases(t *testing.T).
// Why: HelmRelease chart fields have separate status fallbacks from plain Flux
// Kustomizations and need their own contract coverage.
func TestParseHelmReleases(t *testing.T) {
raw := []byte(`{
"items": [{
"metadata": {"name": "grafana", "namespace": "monitoring"},
"spec": {
"chart": {"spec": {"chart": "grafana", "version": "~8.5.0"}}
},
"status": {
"lastAttemptedRevision": "8.5.8",
"history": [{"chartName": "grafana", "chartVersion": "8.5.8", "appVersion": "11.4.0", "digest": "sha256:abc"}],
"conditions": [{"type": "Ready", "status": "True", "reason": "InstallSucceeded"}]
}
}]
}`)
got, err := service.TestHookParseHelmReleases(raw)
if err != nil {
t.Fatalf("parseHelmReleases failed: %v", err)
}
if len(got) != 1 || got[0].Chart != "grafana" || got[0].Version != "8.5.8" || got[0].Revision != "sha256:abc" || !got[0].Ready {
t.Fatalf("unexpected HelmRelease parse result: %+v", got)
}
}
// TestCollectGitOpsSnapshotPartialFailure runs one orchestration or CLI step.
// Signature: TestCollectGitOpsSnapshotPartialFailure(t *testing.T).
// Why: a recovering cluster may serve some Flux resource families before
// others, and Ananke should publish a partial snapshot instead of failing closed.
func TestCollectGitOpsSnapshotPartialFailure(t *testing.T) {
runner := func(_ context.Context, _ string, args ...string) ([]byte, error) {
joined := strings.Join(args, " ")
switch {
case strings.Contains(joined, "gitrepositories"):
return []byte(`{"items":[]}`), nil
case strings.Contains(joined, "kustomizations"):
return nil, errors.New("api unavailable")
case strings.Contains(joined, "helmreleases"):
return []byte(`{"items":[]}`), nil
default:
return nil, errors.New("unexpected command")
}
}
snapshot := service.TestHookCollectGitOpsSnapshotWithRunner(context.Background(), config.Config{Kubeconfig: "/tmp/kubeconfig"}, runner)
if snapshot.ScrapeSuccess {
t.Fatalf("expected partial failure")
}
if snapshot.Errors["kustomization"] == "" {
t.Fatalf("expected kustomization error, got %+v", snapshot.Errors)
}
if summary := service.TestHookGitOpsErrorSummary(snapshot.Errors); !strings.Contains(summary, "kustomization=api unavailable") {
t.Fatalf("unexpected error summary: %s", summary)
}
}
// TestCollectGitOpsSnapshotParseFailures runs one orchestration or CLI step.
// Signature: TestCollectGitOpsSnapshotParseFailures(t *testing.T).
// Why: malformed API payloads should be surfaced per resource family so the
// exporter can distinguish data errors from transport failures.
func TestCollectGitOpsSnapshotParseFailures(t *testing.T) {
runner := func(_ context.Context, _ string, _ ...string) ([]byte, error) {
return []byte(`not-json`), nil
}
snapshot := service.TestHookCollectGitOpsSnapshotWithRunner(context.Background(), config.Config{}, runner)
if snapshot.ScrapeSuccess {
t.Fatalf("expected parse failures")
}
for _, key := range []string{"gitrepository", "kustomization", "helmrelease"} {
if snapshot.Errors[key] == "" {
t.Fatalf("expected %s parse error, got %+v", key, snapshot.Errors)
}
}
}
// TestCollectGitOpsSnapshotSuccess runs one orchestration or CLI step.
// Signature: TestCollectGitOpsSnapshotSuccess(t *testing.T).
// Why: successful collection should preserve non-branch Git refs as the source
// label rather than hiding them as unknown.
func TestCollectGitOpsSnapshotSuccess(t *testing.T) {
runner := func(_ context.Context, _ string, args ...string) ([]byte, error) {
joined := strings.Join(args, " ")
switch {
case strings.Contains(joined, "gitrepositories"):
return []byte(`{"items":[{"metadata":{"name":"flux-system","namespace":"flux-system"},"spec":{"ref":{"tag":"v1.0.0"}}}]}`), nil
case strings.Contains(joined, "kustomizations"):
return []byte(`{"items":[]}`), nil
case strings.Contains(joined, "helmreleases"):
return []byte(`{"items":[]}`), nil
default:
return nil, errors.New("unexpected command")
}
}
snapshot := service.TestHookCollectGitOpsSnapshotWithRunner(context.Background(), config.Config{}, runner)
if !snapshot.ScrapeSuccess || len(snapshot.FluxSources) != 1 || snapshot.FluxSources[0].Branch != "v1.0.0" {
t.Fatalf("unexpected snapshot: %+v", snapshot)
}
}
// TestMaybeStartGitOpsSnapshot runs one orchestration or CLI step.
// Signature: TestMaybeStartGitOpsSnapshot(t *testing.T).
// Why: Ananke must collect GitOps status asynchronously so a slow Kubernetes API
// cannot delay UPS polling.
func TestMaybeStartGitOpsSnapshot(t *testing.T) {
runner := func(_ context.Context, _ string, _ ...string) ([]byte, error) {
return []byte(`{"items":[]}`), nil
}
exporter := metrics.New()
done := make(chan struct{}, 1)
lastRun := time.Time{}
cfg := config.Config{GitOps: config.GitOps{Enabled: true, PollSeconds: 1}}
withRunner := func() bool {
return service.TestHookCollectGitOpsSnapshotWithRunner(context.Background(), config.Config{}, runner).ScrapeSuccess
}
if !withRunner() {
t.Fatalf("expected injected runner sanity check to pass")
}
if running := service.TestHookMaybeStartGitOpsSnapshotWithRunner(context.Background(), cfg, exporter, log.New(io.Discard, "", 0), &lastRun, false, done, runner); !running {
t.Fatalf("expected scrape to start")
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("timed out waiting for scrape")
}
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rr := httptest.NewRecorder()
exporter.Handler("/metrics").ServeHTTP(rr, req)
if !strings.Contains(rr.Body.String(), "ananke_gitops_scrape_success") {
t.Fatalf("expected GitOps scrape metric, got:\n%s", rr.Body.String())
}
if running := service.TestHookMaybeStartGitOpsSnapshot(context.Background(), cfg, exporter, log.New(io.Discard, "", 0), &lastRun, true, done); !running {
t.Fatalf("running scrape should stay marked running")
}
cfg.GitOps.Enabled = false
if running := service.TestHookMaybeStartGitOpsSnapshot(context.Background(), cfg, exporter, nil, &lastRun, false, done); running {
t.Fatalf("disabled GitOps scrape should not start")
}
}
// TestRunGitOpsKubectlOutput runs one orchestration or CLI step.
// Signature: TestRunGitOpsKubectlOutput(t *testing.T).
// Why: command diagnostics should preserve stderr for operator-visible errors.
func TestRunGitOpsKubectlOutput(t *testing.T) {
out, err := service.TestHookRunGitOpsKubectlOutput(context.Background(), "sh", "-c", "printf ok")
if err != nil || string(out) != "ok" {
t.Fatalf("unexpected command success result: out=%q err=%v", out, err)
}
if _, err := service.TestHookRunGitOpsKubectlOutput(context.Background(), "sh", "-c", "echo bad >&2; exit 7"); err == nil || !strings.Contains(err.Error(), "bad") {
t.Fatalf("expected stderr to be preserved in error, got %v", err)
}
if _, err := service.TestHookRunGitOpsKubectlOutput(context.Background(), "sh", "-c", "exit 8"); err == nil {
t.Fatalf("expected empty-stderr command failure")
}
}
// TestGitOpsParseErrors runs one orchestration or CLI step.
// Signature: TestGitOpsParseErrors(t *testing.T).
// Why: invalid JSON should return parse errors for every Flux resource parser.
func TestGitOpsParseErrors(t *testing.T) {
if _, err := service.TestHookParseGitRepositories([]byte(`not-json`)); err == nil {
t.Fatalf("expected GitRepository parse error")
}
if _, err := service.TestHookParseKustomizations([]byte(`not-json`)); err == nil {
t.Fatalf("expected Kustomization parse error")
}
if _, err := service.TestHookParseHelmReleases([]byte(`not-json`)); err == nil {
t.Fatalf("expected HelmRelease parse error")
}
}
// TestMaybeStartGitOpsSnapshotSkipsFreshSampleAndNilExporter runs one
// orchestration or CLI step.
// Signature: TestMaybeStartGitOpsSnapshotSkipsFreshSampleAndNilExporter(t *testing.T).
// Why: disabled or already-fresh scrapes should be cheap no-ops in the daemon
// loop.
func TestMaybeStartGitOpsSnapshotSkipsFreshSampleAndNilExporter(t *testing.T) {
cfg := config.Config{GitOps: config.GitOps{Enabled: true, PollSeconds: 60}}
done := make(chan struct{}, 1)
lastRun := time.Now()
if running := service.TestHookMaybeStartGitOpsSnapshot(context.Background(), cfg, nil, nil, &lastRun, false, done); running {
t.Fatalf("nil exporter should not start scrape")
}
if running := service.TestHookMaybeStartGitOpsSnapshot(context.Background(), cfg, metrics.New(), nil, &lastRun, false, done); running {
t.Fatalf("fresh sample should not start another scrape")
}
runner := func(_ context.Context, _ string, _ ...string) ([]byte, error) {
return []byte(`{"items":[]}`), nil
}
if running := service.TestHookMaybeStartGitOpsSnapshotWithRunner(context.Background(), cfg, metrics.New(), nil, &lastRun, true, done, runner); !running {
t.Fatalf("already-running scrape should remain marked running")
}
cfg.GitOps.Enabled = false
lastRun = time.Time{}
if running := service.TestHookMaybeStartGitOpsSnapshotWithRunner(context.Background(), cfg, metrics.New(), nil, &lastRun, false, done, runner); running {
t.Fatalf("disabled scrape should not start through runner hook")
}
}
// TestMaybeStartGitOpsSnapshotLogsPartialFailureWithoutLogger runs one
// orchestration or CLI step.
// Signature: TestMaybeStartGitOpsSnapshotLogsPartialFailureWithoutLogger(t *testing.T).
// Why: scrape failure handling should not depend on a logger being present.
func TestMaybeStartGitOpsSnapshotLogsPartialFailureWithoutLogger(t *testing.T) {
done := make(chan struct{}, 1)
lastRun := time.Time{}
cfg := config.Config{GitOps: config.GitOps{Enabled: true, PollSeconds: 1}}
runner := func(_ context.Context, _ string, _ ...string) ([]byte, error) {
return nil, errors.New("api down")
}
if running := service.TestHookMaybeStartGitOpsSnapshotWithRunner(context.Background(), cfg, metrics.New(), nil, &lastRun, false, done, runner); !running {
t.Fatalf("expected scrape to start")
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("timed out waiting for failed scrape")
}
}
// TestGitOpsHelpers runs one orchestration or CLI step.
// Signature: TestGitOpsHelpers(t *testing.T).
// Why: tiny label helpers carry dashboard readability contracts and are worth
// locking down.
func TestGitOpsHelpers(t *testing.T) {
if got := service.TestHookGitOpsDefaultString(" ", "fallback"); got != "fallback" {
t.Fatalf("unexpected defaultString fallback: %q", got)
}
if got := service.TestHookGitOpsFirstNonEmpty(" ", "second"); got != "second" {
t.Fatalf("unexpected firstNonEmpty result: %q", got)
}
if got := service.TestHookGitOpsFirstNonEmpty(" ", ""); got != "" {
t.Fatalf("unexpected empty firstNonEmpty result: %q", got)
}
if got := service.TestHookGitOpsErrorSummary(nil); got != "none" {
t.Fatalf("unexpected empty summary: %q", got)
}
ready, reason := service.TestHookFluxReadyMissing()
if ready || reason != "ReadyMissing" {
t.Fatalf("unexpected empty Ready condition parse: ready=%t reason=%s", ready, reason)
}
}