Skip to content
Draft
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
148 changes: 148 additions & 0 deletions grafana-alertcheck/cmd/grafana-alertcheck/check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package main

import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"os/signal"
"syscall"
"time"

"github.com/smartcontractkit/chainlink-testing-framework/grafana-alertcheck/internal/gate"
)

const checkUsage = "usage: grafana-alertcheck check [--in <file>] [--pidfile F] --from RFC3339 --to RFC3339 " +
"[--alerts ...] [--folder F] [--states ...] [--preexisting ...] [--min-observed N] [--allow-paused] " +
"[--nodata-is-unobservable] [--concurrency N] [--output json]"

// runCheck is the classify step's CLI surface: parse flags into a
// gate.Config, run gate.Check, and translate its (Result, error) into
// §20.2/§20.3's output and exit code. All of the correctness lives in
// gate.Check (P9) and decide (P8) — this file's only job is presentation and
// the H6/H7 exit-code mapping, which exitCode below keeps as one pure
// function so it can be tested without a network.
func runCheck(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("check", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() { fmt.Fprintln(stderr, checkUsage) }

common := registerCommon(fs)
in := fs.String("in", "", "path of a log recorded by watch; empty selects single-step mode (§9)")
pidfile := fs.String("pidfile", "", "pidfile of the recorder to stop before reading --in (default <in>.pid)")
from := fs.String("from", "", "the moment the deploy finished, RFC3339 (required in recorder mode, §7)")
to := fs.String("to", "", "the end of the window to classify, RFC3339 (required)")
states := fs.String("states", "", "comma-separated bad states to classify against (default: firing, §13)")
preexisting := fs.String("preexisting", "", "how to judge an instance already bad at `from` (default: fail-unless-recovered, §11.7)")
minObserved := fs.Int("min-observed", 0, "minimum rules that must be observed (default: every resolved rule, §12)")
allowPaused := fs.Bool("allow-paused", false, "do not count a rule paused before the window against --min-observed (§12.1)")
nodataIsUnobservable := fs.Bool("nodata-is-unobservable", false, "treat a sustained health=nodata as unobservable rather than a note (§10.2)")
output := fs.String("output", "", `"json" writes the machine-readable Result to stdout in addition to the table (§20.2); default is the table alone`)

if err := fs.Parse(args); err != nil {
return 2
}
if *output != "" && *output != "json" {
fmt.Fprintf(stderr, "--output: unknown value %q (only \"json\" is supported)\n", *output)
return 2
}

url, token, err := grafanaEnv()
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
alerts, err := readAlerts(stdin, *common.alerts)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
stateList, err := parseStates(*states)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
preexistingPolicy, err := parsePreexisting(*preexisting)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}

cfg := gate.Config{
URL: url,
Token: token,
Alerts: alerts,
Folder: *common.folder,
States: stateList,
Preexisting: preexistingPolicy,
MinObserved: *minObserved,
AllowPaused: *allowPaused,
NodataIsUnobservable: *nodataIsUnobservable,
Log: *in,
PidFile: *pidfile,
Concurrency: *common.concurrency,
Clock: gate.SystemClock{},
Notes: stderr,
}
if *to == "" {
fmt.Fprintln(stderr, "check: --to is required (§7)")
return 2
}
t, err := time.Parse(time.RFC3339, *to)
if err != nil {
fmt.Fprintf(stderr, "--to: %v\n", err)
return 2
}
cfg.To = t
if *from != "" {
f, err := time.Parse(time.RFC3339, *from)
if err != nil {
fmt.Fprintf(stderr, "--from: %v\n", err)
return 2
}
cfg.From = f
}

// SIGINT/SIGTERM cancel the run cleanly rather than leaving an operator's
// Ctrl-C to kill the process mid-collection: Check's collection loop and
// drain wait both already select on ctx.Done() (check.go), so this makes
// an interrupted run fail the way every other could-not-check path does
// — exit 2, never a silently truncated pass.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

result, checkErr := gate.Check(ctx, cfg)

if err := renderTable(stderr, result); err != nil {
fmt.Fprintln(stderr, err)
}
if checkErr != nil {
fmt.Fprintln(stderr, checkErr)
}
if *output == "json" {
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
if err := enc.Encode(result); err != nil {
fmt.Fprintf(stderr, "encode --output json: %v\n", err)
return 2
}
}
return exitCode(result, checkErr)
}

// exitCode is §20.3/H6/H7's whole mapping, kept as one pure function of
// exactly what Check returns so it is testable without a network: err != nil
// is exit 2 UNCONDITIONALLY — never 0 and never 1, even alongside real
// violations, because inability beats violation (H6) and an error is never a
// pass (H7). Violations without an error is exit 1. Neither is exit 0.
func exitCode(res gate.Result, err error) int {
switch {
case err != nil:
return 2
case len(res.Violations) > 0:
return 1
default:
return 0
}
}
147 changes: 147 additions & 0 deletions grafana-alertcheck/cmd/grafana-alertcheck/check_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package main

import (
"bytes"
"errors"
"os"
"strings"
"testing"

"github.com/smartcontractkit/chainlink-testing-framework/grafana-alertcheck/internal/gate"
)

// TestExitCode pins §20.3/H6/H7's mapping directly against exitCode, with no
// network involved: err != nil is exit 2 even alongside violations (H6 —
// inability beats violation), violations alone are exit 1, and neither is 0.
func TestExitCode(t *testing.T) {
tests := []struct {
name string
res gate.Result
err error
want int
}{
{"pass", gate.Result{}, nil, 0},
{"violation", gate.Result{Violations: []gate.Violation{{}}}, nil, 1},
{"error alone", gate.Result{}, errors.New("boom"), 2},
{"error beats violation", gate.Result{Violations: []gate.Violation{{}}}, errors.New("boom"), 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := exitCode(tt.res, tt.err); got != tt.want {
t.Fatalf("exitCode(...) = %d, want %d", got, tt.want)
}
})
}
}

func writeTempAlerts(t *testing.T) string {
t.Helper()
path := t.TempDir() + "/alerts.txt"
if err := os.WriteFile(path, []byte("Some Alert\n"), 0o644); err != nil {
t.Fatal(err)
}
return path
}

// TestRunCheck_FlagValidation is the flag-validation matrix: every one of
// these must fail before any network call, because Config.validate() (P9)
// runs first — an unreachable GRAFANA_URL succeeding or timing out is a
// different test than these, which check pure input validation.
func TestRunCheck_FlagValidation(t *testing.T) {
tests := []struct {
name string
env bool
args func(t *testing.T) []string
wantErr string
}{
{"missing env", false, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z", "--alerts", writeTempAlerts(t)}
}, "GRAFANA_URL"},
{"missing to", true, func(t *testing.T) []string {
return []string{"--alerts", writeTempAlerts(t)}
}, "--to"},
{"bad to", true, func(t *testing.T) []string {
return []string{"--to", "not-a-time", "--alerts", writeTempAlerts(t)}
}, "--to"},
{"bad from", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z", "--from", "not-a-time", "--alerts", writeTempAlerts(t)}
}, "--from"},
{"bad output", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z", "--output", "xml", "--alerts", writeTempAlerts(t)}
}, "--output"},
{"bad states", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z", "--states", "bogus", "--alerts", writeTempAlerts(t)}
}, "--states"},
{"states normal is rejected", true, func(t *testing.T) []string {
// normal is the good state, never a state to classify AS bad
// (R1): accepting it would make --states normal fail every
// healthy instance, the fail-open shape H7 exists to prevent.
return []string{"--to", "2026-01-01T00:00:00Z", "--states", "normal", "--alerts", writeTempAlerts(t)}
}, "--states"},
{"bad preexisting", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z", "--preexisting", "bogus", "--alerts", writeTempAlerts(t)}
}, "--preexisting"},
{"alerts with in", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z", "--in", "some.jsonl", "--alerts", writeTempAlerts(t)}
}, "refused"},
{"no alerts no in", true, func(t *testing.T) []string {
return []string{"--to", "2026-01-01T00:00:00Z"}
}, "no alert names"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env {
t.Setenv("GRAFANA_URL", "http://example.invalid")
t.Setenv("GRAFANA_TOKEN", "test-token")
} else {
t.Setenv("GRAFANA_URL", "")
t.Setenv("GRAFANA_TOKEN", "")
}
var stdout, stderr bytes.Buffer
args := append([]string{"check"}, tt.args(t)...)
code := run(args, &stdout, &stderr)
if code != 2 {
t.Fatalf("code = %d, want 2; stderr = %q", code, stderr.String())
}
if !strings.Contains(stderr.String(), tt.wantErr) {
t.Fatalf("stderr = %q, want it to contain %q", stderr.String(), tt.wantErr)
}
})
}
}

// TestRunCheck_ToInPastNoLog pins §4.2's refusal: a `to` already in the past
// with no recorded log cannot be classified from anything, because nothing
// ever observed the window.
func TestRunCheck_ToInPastNoLog(t *testing.T) {
t.Setenv("GRAFANA_URL", "http://example.invalid")
t.Setenv("GRAFANA_TOKEN", "test-token")

var stdout, stderr bytes.Buffer
code := run([]string{"check",
"--from", "1999-01-01T00:00:00Z", "--to", "2000-01-01T00:00:00Z",
"--alerts", writeTempAlerts(t),
}, &stdout, &stderr)
if code != 2 {
t.Fatalf("code = %d, want 2; stderr = %q", code, stderr.String())
}
if !strings.Contains(stderr.String(), "already passed") {
t.Fatalf("stderr = %q, want the §4.2 refusal", stderr.String())
}
}

// TestRunCheck_NoResultOnConfigError pins §20.2: --output json never writes
// to stdout when Check was never reached, because there is no Result to
// encode — only the table (on stderr) can report a configuration failure.
func TestRunCheck_NoResultOnConfigError(t *testing.T) {
t.Setenv("GRAFANA_URL", "")
t.Setenv("GRAFANA_TOKEN", "")
var stdout, stderr bytes.Buffer
code := run([]string{"check", "--to", "2026-01-01T00:00:00Z", "--output", "json"}, &stdout, &stderr)
if code != 2 {
t.Fatalf("code = %d, want 2", code)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
}
Loading
Loading