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

import (
"fmt"
"os"
)

// 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).
func grafanaEnv() (url, token string, err error) {
url = os.Getenv("GRAFANA_URL")
if url == "" {
return "", "", fmt.Errorf("GRAFANA_URL is not set")
}
token = os.Getenv("GRAFANA_TOKEN")
if token == "" {
return "", "", fmt.Errorf("GRAFANA_TOKEN is not set")
}
return url, token, nil
}
93 changes: 93 additions & 0 deletions grafana-alertcheck/cmd/grafana-alertcheck/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package main

import (
"context"
"fmt"
"io"
"sort"
"text/tabwriter"

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

// 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.
func runList(args []string, stdout, stderr io.Writer) int {
if len(args) != 0 {
fmt.Fprintf(stderr, "list takes no arguments, got %v\n", args)
return 2
}

url, token, err := grafanaEnv()
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}

// context.Background(), no outer deadline: httpSource bounds every single
// attempt with its http.Client's 30s Timeout (source.go) and gives up
// after maxSequentialFailures consecutive transport errors, so this call
// 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.
src := gate.NewHTTPSource(url, token, gate.SystemClock{})
version, err := src.Version(context.Background())
if err != nil {
fmt.Fprintf(stderr, "checking grafana version: %v\n", err)
return 2
}
if err := gate.CheckGrafanaVersion(version); err != nil {
fmt.Fprintln(stderr, err)
return 2
}

defs, err := src.Definitions(context.Background())
if err != nil {
fmt.Fprintf(stderr, "reading rule definitions: %v\n", err)
return 2
}

sort.Slice(defs, func(i, j int) bool {
if defs[i].Folder != defs[j].Folder {
return defs[i].Folder < defs[j].Folder
}
if defs[i].Group != defs[j].Group {
return defs[i].Group < defs[j].Group
}
return defs[i].Title < defs[j].Title
})

tw := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "KIND\tFOLDER\tGROUP\tTITLE\tUID")
for _, d := range defs {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", kindLabel(d.Kind), d.Folder, d.Group, d.Title, uidOrDash(d.UID))
}
if err := tw.Flush(); err != nil {
fmt.Fprintf(stderr, "writing output: %v\n", err)
return 2
}
return 0
}

func kindLabel(k gate.RuleKind) string {
switch k {
case gate.KindDatasourceManaged:
return "datasource-managed"
case gate.KindRecording:
return "recording"
default:
return "grafana-managed"
}
}

func uidOrDash(uid string) string {
if uid == "" {
return "-"
}
return uid
}
102 changes: 102 additions & 0 deletions grafana-alertcheck/cmd/grafana-alertcheck/list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package main

import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

const rulerBody = `{
"Example-Zone-A": [
{
"name": "Gateway",
"rules": [
{
"for": "5m",
"grafana_alert": {
"title": "Example No Gateways Available",
"uid": "rule0000006a",
"namespace_uid": "folder0000006",
"intervalSeconds": 60,
"no_data_state": "OK",
"exec_err_state": "OK",
"is_paused": false
}
}
]
}
]
}`

func healthBody(version string) string {
return fmt.Sprintf(`{"database":"ok","version":%q,"commit":"abc123"}`, version)
}

func grafanaTestServer(t *testing.T, version string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/health":
_, _ = w.Write([]byte(healthBody(version)))
case "/api/ruler/grafana/api/v1/rules":
_, _ = w.Write([]byte(rulerBody))
default:
t.Errorf("unexpected path %q", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv
}

func TestRunList_HappyPath(t *testing.T) {
srv := grafanaTestServer(t, "13.1.0")
t.Setenv("GRAFANA_URL", srv.URL)
t.Setenv("GRAFANA_TOKEN", "test-token")

var stdout, stderr bytes.Buffer
code := run([]string{"list"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr = %q", code, stderr.String())
}
out := stdout.String()
if !strings.Contains(out, "rule0000006a") {
t.Errorf("stdout = %q, want it to list rule0000006a", out)
}
if !strings.Contains(out, "Example No Gateways Available") {
t.Errorf("stdout = %q, want it to list the rule title", out)
}
if !strings.Contains(out, "grafana-managed") {
t.Errorf("stdout = %q, want it to name the rule kind", out)
}
}

func TestRunList_UnsupportedVersion(t *testing.T) {
srv := grafanaTestServer(t, "12.5.0")
t.Setenv("GRAFANA_URL", srv.URL)
t.Setenv("GRAFANA_TOKEN", "test-token")

var stdout, stderr bytes.Buffer
code := run([]string{"list"}, &stdout, &stderr)
if code != 2 {
t.Fatalf("code = %d, want 2", code)
}
if !strings.Contains(stderr.String(), "12.5.0") {
t.Fatalf("stderr = %q, want it to name the unsupported version", stderr.String())
}
}

func TestRunList_RejectsArgs(t *testing.T) {
t.Setenv("GRAFANA_URL", "http://example.invalid")
t.Setenv("GRAFANA_TOKEN", "test-token")

var stdout, stderr bytes.Buffer
code := run([]string{"list", "extra"}, &stdout, &stderr)
if code != 2 {
t.Fatalf("code = %d, want 2", code)
}
}
43 changes: 41 additions & 2 deletions grafana-alertcheck/cmd/grafana-alertcheck/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,46 @@
// Command grafana-alertcheck is the CLI entry point for the gate. P3 wires
// only the `list` subcommand — enough to validate auth, the ruler parse, and
// resolution against a real Grafana before any coverage logic exists (§9 rule
// 4, "reach runnable at PR 5"). P10 extends this file with `watch` and
// `check`.
package main

import "os"
import (
"fmt"
"io"
"os"
)

func main() {
os.Exit(2)
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}

const usage = "usage: grafana-alertcheck <list>"

// 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).
//
// 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
// stdout for help the caller asked for, reserving 2/stderr for help printed
// *because* something else went wrong (no subcommand, an unknown one).
func run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, usage)
return 2
}

switch args[0] {
case "list":
return runList(args[1:], stdout, stderr)
case "-h", "-help", "--help":
fmt.Fprintln(stdout, usage)
return 0
default:
fmt.Fprintf(stderr, "unknown subcommand %q\n", args[0])
return 2
}
}
60 changes: 60 additions & 0 deletions grafana-alertcheck/cmd/grafana-alertcheck/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package main

import (
"bytes"
"strings"
"testing"
)

func TestRun_NoArgs(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run(nil, &stdout, &stderr)
if code != 2 {
t.Fatalf("code = %d, want 2", code)
}
if !strings.Contains(stderr.String(), "usage") {
t.Fatalf("stderr = %q, want a usage message", stderr.String())
}
}

func TestRun_Help(t *testing.T) {
for _, flag := range []string{"-h", "-help", "--help"} {
t.Run(flag, func(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run([]string{flag}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0 (requested help is not a could-not-check condition)", code)
}
if !strings.Contains(stdout.String(), "usage") {
t.Fatalf("stdout = %q, want a usage message", stdout.String())
}
if stderr.String() != "" {
t.Fatalf("stderr = %q, want empty — help goes to stdout", stderr.String())
}
})
}
}

func TestRun_UnknownSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run([]string{"bogus"}, &stdout, &stderr)
if code != 2 {
t.Fatalf("code = %d, want 2", code)
}
if !strings.Contains(stderr.String(), `"bogus"`) {
t.Fatalf("stderr = %q, want it to name the unknown subcommand", stderr.String())
}
}

func TestRun_List_MissingEnv(t *testing.T) {
t.Setenv("GRAFANA_URL", "")
t.Setenv("GRAFANA_TOKEN", "")
var stdout, stderr bytes.Buffer
code := run([]string{"list"}, &stdout, &stderr)
if code != 2 {
t.Fatalf("code = %d, want 2", code)
}
if !strings.Contains(stderr.String(), "GRAFANA_URL") {
t.Fatalf("stderr = %q, want it to name the missing env var", stderr.String())
}
}
Loading
Loading