Skip to content
Merged
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
34 changes: 0 additions & 34 deletions .github/workflows/grafana-alertcheck-release.yml

This file was deleted.

33 changes: 0 additions & 33 deletions grafana-alertcheck/.goreleaser.yaml

This file was deleted.

46 changes: 43 additions & 3 deletions grafana-alertcheck/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,46 @@
# grafana-alertcheck

A CD quality gate for Grafana alerts: bookend a release with `watch` (record) and `check` (classify) to
answer whether any watched alert was in a bad state during the release window.
A CD quality gate for Grafana alerts. It bookends a release with two commands — `watch` (record) and
`check` (classify) — and answers whether any watched alert was in a bad state during the release window.

Under construction.
```
watch → your work → check
```

`watch` starts a background recorder that polls each named alert into a JSONL log. After the work emits a
`from`/`to` pair, `check` proves continuous coverage of that window, classifies each alert's state
timeline, and exits `0`, `1`, or `2`.

It **fails closed**: if it cannot get an answer, it stops the release — never a pass on an unproven window.

## Quickstart

```bash
export GRAFANA_URL=https://grafana.example.com
export GRAFANA_TOKEN=…

grafana-alertcheck watch --out /tmp/run.jsonl --alerts alerts.txt
./deploy.sh # emits deployed_at=<RFC3339>
./verify.sh # emits finished_at=<RFC3339>
grafana-alertcheck check --in /tmp/run.jsonl --from "$deployed_at" --to "$finished_at"
```

Requires Grafana >= 13.0.0 and < 14.0.0. Connection details come from the environment only — the token is
never a flag.

## Documentation

| Doc | Covers |
| --- | ------ |
| [`docs/index.md`](./docs/index.md) | Overview, quickstarts, exit codes, common surprises |
| [`docs/how-alerts-are-evaluated.md`](./docs/how-alerts-are-evaluated.md) | Verdict model, coverage proof, health/liveness |
| [`docs/advanced.md`](./docs/advanced.md) | Check budget, scheduling, why history isn't queried |
| [`docs/architecture.md`](./docs/architecture.md) | Design invariants, the pure-function seam, recorder lifecycle |
| [`docs/reference/cli.md`](./docs/reference/cli.md) | Full CLI reference — subcommands, flags, naming |
| [`docs/reference/log-format.md`](./docs/reference/log-format.md) | The JSONL log schema, for debugging artifacts |

## Build

```bash
go build ./... && go test ./...
```
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
Expand All @@ -17,30 +18,36 @@ 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 {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if fs.NArg() != 0 {
fmt.Fprintf(stderr, "check: unexpected arguments %v\n", fs.Args())
return 2
}
if *output != "" && *output != "json" {
Expand Down Expand Up @@ -83,10 +90,10 @@ func runCheck(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
PidFile: *pidfile,
Concurrency: *common.concurrency,
Clock: gate.SystemClock{},
Notes: stderr,
Notes: newNoteStyler(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 @@ -114,11 +121,10 @@ func runCheck(args []string, stdin io.Reader, stdout, stderr io.Writer) int {

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

if err := renderTable(stderr, result); err != nil {
fmt.Fprintln(stderr, err)
}
if checkErr != nil {
fmt.Fprintln(stderr, checkErr)
} else if err := renderTable(stderr, result); err != nil {
fmt.Fprintln(stderr, err)
}
if *output == "json" {
enc := json.NewEncoder(stdout)
Expand All @@ -131,11 +137,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
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import (
"bytes"
"errors"
"os"
"strings"
"testing"

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

// 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 All @@ -27,26 +27,22 @@ func TestExitCode(t *testing.T) {
}
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)
}
require.Equal(t, tt.want, exitCode(tt.res, tt.err))
})
}
}

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)
}
require.NoError(t, os.WriteFile(path, []byte("Some Alert\n"), 0o644))
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 +69,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 All @@ -100,19 +96,14 @@ func TestRunCheck_FlagValidation(t *testing.T) {
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)
}
require.Equal(t, 2, code)
require.Contains(t, 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.
// 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 @@ -122,26 +113,18 @@ func TestRunCheck_ToInPastNoLog(t *testing.T) {
"--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())
}
require.Equal(t, 2, code)
require.Contains(t, stderr.String(), "already passed")
}

// 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", "")
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())
}
require.Equal(t, 2, code)
require.Empty(t, stdout.String())
}
Loading
Loading