317 lines
13 KiB
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)
|
|
}
|
|
}
|