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
35 changes: 17 additions & 18 deletions grafana-alertcheck/cmd/grafana-alertcheck/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,27 @@ const checkUsage = "usage: grafana-alertcheck check [--in <file>] [--pidfile F]
"[--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.
// runCheck is the classify step's CLI surface: parse flags into a gate.Config,
// run gate.Check, and translate its (Result, error) into output and an exit
// code. All of the correctness lives in the gate package — this file's only job
// is presentation and the 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)")
in := fs.String("in", "", "path of a log recorded by watch; empty selects single-step mode")
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)")
from := fs.String("from", "", "the moment the deploy finished, RFC3339 (required with --in)")
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`)
states := fs.String("states", "", "comma-separated bad states to classify against (default: firing)")
preexisting := fs.String("preexisting", "", "how to judge an instance already bad at `from` (default: fail-unless-recovered)")
minObserved := fs.Int("min-observed", 0, "minimum rules that must be observed (default: every resolved rule)")
allowPaused := fs.Bool("allow-paused", false, "do not count a rule paused before the window against --min-observed")
nodataIsUnobservable := fs.Bool("nodata-is-unobservable", false, "treat a sustained health=nodata as unobservable rather than a note")
output := fs.String("output", "", `"json" writes the machine-readable Result to stdout in addition to the table; default is the table alone`)

if err := fs.Parse(args); err != nil {
return 2
Expand Down Expand Up @@ -86,7 +85,7 @@ func runCheck(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
Notes: stderr,
}
if *to == "" {
fmt.Fprintln(stderr, "check: --to is required (§7)")
fmt.Fprintln(stderr, "check: --to is required")
return 2
}
t, err := time.Parse(time.RFC3339, *to)
Expand Down Expand Up @@ -131,11 +130,11 @@ func runCheck(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return exitCode(result, checkErr)
}

// exitCode is §20.3/H6/H7's whole mapping, kept as one pure function of
// exitCode is the whole exit-code 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.
// violations, because an inability to check beats a violation and an error is
// never a pass. Violations without an error is exit 1. Neither is exit 0.
func exitCode(res gate.Result, err error) int {
switch {
case err != nil:
Expand Down
33 changes: 16 additions & 17 deletions grafana-alertcheck/cmd/grafana-alertcheck/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import (
"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.
// The exit-code mapping, pinned directly against exitCode with no network
// involved: err != nil is exit 2 even alongside violations (an inability to
// check beats a violation), violations alone are exit 1, and neither is 0.
func TestExitCode(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -43,10 +43,10 @@ func writeTempAlerts(t *testing.T) string {
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.
// The flag-validation matrix: every one of these must fail before any network
// call, because gate.Config.validate() 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
Expand All @@ -73,9 +73,9 @@ func TestRunCheck_FlagValidation(t *testing.T) {
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.
// normal is the good state, never a state to classify AS bad:
// accepting it would make --states normal fail every healthy
// instance.
return []string{"--to", "2026-01-01T00:00:00Z", "--states", "normal", "--alerts", writeTempAlerts(t)}
}, "--states"},
{"bad preexisting", true, func(t *testing.T) []string {
Expand Down Expand Up @@ -110,9 +110,8 @@ func TestRunCheck_FlagValidation(t *testing.T) {
}
}

// 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.
// 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")
Expand All @@ -126,13 +125,13 @@ func TestRunCheck_ToInPastNoLog(t *testing.T) {
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())
t.Fatalf("stderr = %q, want the past-`to` 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.
// --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", "")
Expand Down
27 changes: 13 additions & 14 deletions grafana-alertcheck/cmd/grafana-alertcheck/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import (
)

// commonFlags is registerCommon's result: the exactly three flags watch and
// check share (§20). Connection details are never flags (§20.2) and states /
// poll-interval are deliberately NOT here — states is check-only because
// recording is unfiltered (P6), and poll-interval is watch-only because check
// reads the cadence from the log header (P5). Putting either here would
// silently reinstate a knob this plan removed.
// check share. Connection details are never flags, and states / poll-interval
// are deliberately NOT here — states is check-only because recording is
// unfiltered, and poll-interval is watch-only because check reads the cadence
// from the log header. Putting either here would give both commands an opinion
// about a value only one of them may set.
type commonFlags struct {
folder *string
concurrency *int
Expand All @@ -25,13 +25,13 @@ type commonFlags struct {

func registerCommon(fs *flag.FlagSet) *commonFlags {
return &commonFlags{
folder: fs.String("folder", "", "default folder to scope an unqualified alert name to (§17)"),
folder: fs.String("folder", "", "default folder to scope an unqualified alert name to"),
concurrency: fs.Int("concurrency", 1, "maximum concurrent requests to Grafana"),
alerts: fs.String("alerts", "", "path to a file of alert names, one per line, or - for stdin"),
}
}

// readAlerts reads §17's alert names, one per line, from a file or from
// readAlerts reads alert names, one per line, from a file or from
// stdin when path is "-". An empty path is not an error here — watch and
// check each decide for themselves whether an empty list is allowed
// (log mode never wants one; single-step / record mode always does).
Expand Down Expand Up @@ -62,16 +62,15 @@ func readAlerts(stdin io.Reader, path string) ([]string, error) {
}

// parseStates parses check's --states flag: a comma-separated list of the
// "bad" state vocabulary Config.States matches against (§13, classify.go's
// "bad" state vocabulary Config.States matches against (classify.go's
// badStateSet). An empty string is not resolved here — it means "use the
// library default of {firing}" — so this returns nil, nil for "" rather than
// an error.
//
// normal is deliberately NOT accepted: the v2 plan fixes this vocabulary to
// firing | pending | nodata | error (line 378) precisely because "normal" is
// the good state, never a bad one to classify against. Accepting it here
// would let --states normal turn every healthy instance into a violation and
// fail every healthy fleet — the exact fail-open shape H7 exists to prevent.
// normal is deliberately NOT accepted. The vocabulary is fixed to
// firing | pending | nodata | error precisely because "normal" is the good
// state, never a bad one to classify against: --states normal would turn every
// healthy instance into a violation and fail every healthy fleet.
func parseStates(s string) ([]gate.State, error) {
if strings.TrimSpace(s) == "" {
return nil, nil
Expand All @@ -95,7 +94,7 @@ func parseStates(s string) ([]gate.State, error) {
return out, nil
}

// parsePreexisting parses check's --preexisting flag (§11.7).
// parsePreexisting parses check's --preexisting flag.
func parsePreexisting(s string) (gate.PreexistingPolicy, error) {
switch gate.PreexistingPolicy(s) {
case "":
Expand Down
3 changes: 1 addition & 2 deletions grafana-alertcheck/cmd/grafana-alertcheck/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import (

// grafanaEnv reads the connection details from the environment only, never
// from a flag — a flag value lands in the process argv and in CI logs, and
// the token must never be logged or otherwise surface in an error string
// (§20.2).
// the token must never be logged or otherwise surface in an error string.
func grafanaEnv() (url, token string, err error) {
url = os.Getenv("GRAFANA_URL")
if url == "" {
Expand Down
11 changes: 5 additions & 6 deletions grafana-alertcheck/cmd/grafana-alertcheck/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,10 @@ import (
)

// runList reads every rule definition from the ruler endpoint and prints one
// line per rule: its kind, its Folder/Group/Title, and its uid. This is what
// makes the gate runnable end to end before any coverage logic exists (§9
// rule 4) — it validates auth, the ruler parse, and the shapes Resolve
// matches against, all against a real Grafana. It is also the "did you mean"
// surface §17.2's no-match error points operators at.
// line per rule: its kind, its Folder/Group/Title, and its uid. It validates
// auth, the ruler parse, and the shapes Resolve matches against, all against a
// real Grafana, and it is the surface Resolve's no-match error points operators
// at.
func runList(args []string, stdout, stderr io.Writer) int {
if len(args) != 0 {
fmt.Fprintf(stderr, "list takes no arguments, got %v\n", args)
Expand All @@ -34,7 +33,7 @@ func runList(args []string, stdout, stderr io.Writer) int {
// always terminates. It can still take minutes end-to-end under repeated
// transient failures (5 retries * up to 30s backoff each, per call) — an
// acceptable wait for an interactive `list`, not for `watch`/`check`,
// which get their own deadlines from `--until`/`to` in P10.
// which get their own deadlines from `--until`/`--to`.
src := gate.NewHTTPSource(url, token, gate.SystemClock{})
version, err := src.Version(context.Background())
if err != nil {
Expand Down
10 changes: 5 additions & 5 deletions grafana-alertcheck/cmd/grafana-alertcheck/main.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Command grafana-alertcheck is the CLI entry point for the gate: `list`
// (P3), `watch` (record, P10) and `check` (classify, P10).
// Command grafana-alertcheck is the CLI entry point for the gate: `list`,
// `watch` (record) and `check` (classify).
package main

import (
Expand All @@ -16,9 +16,9 @@ const usage = "usage: grafana-alertcheck <list|watch|check>"

// run is the whole of main's testable surface: parse the subcommand, dispatch,
// return the process exit code. Exit codes below 2 (pass/violations) belong to
// `check` alone (§20.3, P10); every failure reachable from here — a missing
// subcommand, a bad flag, a transport or auth failure — is a could-not-check
// condition and maps to 2, never to 0 or 1 (H7).
// `check` alone; every failure reachable from here — a missing subcommand, a
// bad flag, a transport or auth failure — is a could-not-check condition and
// maps to 2, never to 0 or 1.
//
// Requested help (-h/--help) is not a failure — it is the one exception to
// that rule. Convention (and every stdlib flag.FlagSet default) is exit 0 to
Expand Down
30 changes: 14 additions & 16 deletions grafana-alertcheck/cmd/grafana-alertcheck/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,25 @@ import (
"github.com/smartcontractkit/chainlink-testing-framework/grafana-alertcheck/internal/gate"
)

// renderTable is §20.2's required human table. It always writes to the
// writer it is given, which the caller (runCheck) always points at
// stderr — the human table is not the machine output §20.2 reserves stdout
// for.
// renderTable is the human table. It always writes to the writer it is given,
// which the caller (runCheck) always points at stderr — stdout is reserved for
// the machine-readable --output json.
//
// Three sections, in order:
//
// 1. one line per rule: outcome, BadFor, pollEvery, proved-or-not with the
// largest gap;
// 2. one line per Violation (R2): a rule's worst-of outcome does not carry
// the State/Health of the instance that actually caused it — Violation
// does — so this is also where those two columns appear, sorted after
// the rule table rather than folded into it, and it is the only place an
// operator running WITHOUT --output json sees the §12.1 --allow-paused
// hint that Violation.Note already carries (classify.go);
// 3. a footer with the per-rule thresholds and the run-wide numbers §20.2
// says are the answer to "why" on exit 2: each non-skipped rule's
// maxGap/healthGrace/evalStaleAfter, the global transitionGrace and
// 2. one line per Violation: a rule's worst-of outcome does not carry the
// State/Health of the instance that actually caused it — Violation does —
// so this is also where those two columns appear, sorted after the rule
// table rather than folded into it, and it is the only place an operator
// running WITHOUT --output json sees the --allow-paused hint that
// Violation.Note already carries (classify.go);
// 3. a footer with the numbers that answer "why" on exit 2: each non-skipped
// rule's maxGap/healthGrace/evalStaleAfter, the global transitionGrace and
// drainTimeout, and the largest measured clock skew alongside its own
// error bound (RTT/2) — SkewHardLimit is a separate, fixed input
// threshold and is reported next to it, never as if it were that bound.
// error bound (RTT/2) — SkewHardLimit is a separate, fixed input threshold
// and is reported next to it, never as if it were that bound.
func renderTable(w io.Writer, res gate.Result) error {
alertOf := make(map[string]string, len(res.Verdicts))
for _, v := range res.Verdicts {
Expand Down Expand Up @@ -77,7 +75,7 @@ func renderTable(w io.Writer, res gate.Result) error {
// provedLabel is the table's PROVED column: "yes" for a clean coverage
// proof, "no" with the reason and largest gap for an unobservable rule, and
// "-" for a rule decide never asked proveCoverage about at all (skipped —
// paused before the window opened, §12).
// paused before the window opened).
func provedLabel(cov gate.CoverageResult) string {
if cov.Reason == "" && !cov.Unobservable && !cov.Proved {
return "-"
Expand Down
Loading
Loading