diff --git a/.gitignore b/.gitignore index 59873fa..7ff0525 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ epmon.db* config.yaml config.json data/ +/dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b1c87f4 --- /dev/null +++ b/Makefile @@ -0,0 +1,135 @@ +# epmon — single static binary, zero cgo. +# +# make build local binary with version stamping +# make test full suite (make test-race for -race, as in CI) +# make check fmt + vet + tests (what CI's go job runs) +# make cross §G1 matrix into dist/ +# make docker-build image with release metadata (never reports `dev`) +# make compose-up detached stack (VERSION/COMMIT/DATE flow through) +# +# Overridables: make build VERSION=0.2.0 / make compose-up VERSION=... / ARGS="..." + +BINARY ?= epmon +DIST ?= dist +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +DATE ?= $(shell date -u +%FT%TZ 2>/dev/null || echo unknown) +LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) +GO ?= go +DOCKER ?= docker +# `docker compose` (plugin) or falling back to the standalone binary. +COMPOSE ?= $(shell if $(DOCKER) compose version >/dev/null 2>&1; then echo "$(DOCKER) compose"; else echo docker-compose; fi) + +PLATFORMS := linux/amd64 linux/arm64 darwin/arm64 windows/amd64 + +.PHONY: help build test test-race vet fmt lint check check-docs cross clean \ + run init validate web docker-build docker-version docker-run \ + compose-up compose-down compose-logs compose-config + +help: + @echo "Targets:" + @echo " build CGO_ENABLED=0 binary ./$(BINARY) with ldflags stamping" + @echo " test go test ./..." + @echo " test-race go test -race ./... (CI parity)" + @echo " vet go vet ./..." + @echo " fmt fail on gofmt drift" + @echo " lint golangci-lint if installed, else warn-and-skip" + @echo " check fmt + vet + test-race" + @echo " check-docs every README yaml block + examples must validate" + @echo " cross static binaries for $(PLATFORMS) into $(DIST)/" + @echo " clean remove ./$(BINARY) and $(DIST)/" + @echo ' run go run ./cmd/epmon run $$ARGS' + @echo ' init go run ./cmd/epmon init $$ARGS' + @echo ' validate go run ./cmd/epmon validate $$ARGS' + @echo ' web rebuild + vendor the status SPA (STATUS_DIR=../status)' + @echo ' docker-build image epmon:$$VERSION with release metadata' + @echo " docker-version print the image's reported version" + @echo " docker-run run image foreground (override with ARGS)" + @echo " compose-up detached stack (passes VERSION/COMMIT/DATE)" + @echo " compose-down stop the stack" + @echo " compose-logs tail the stack logs" + @echo " compose-config validate compose file parsing" + +build: + CGO_ENABLED=0 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/epmon + +test: + $(GO) test ./... + +test-race: + $(GO) test -race ./... + +vet: + $(GO) vet ./... + +fmt: + @test -z "$$($(GO)fmt -l .)" || { $(GO)fmt -l .; echo "gofmt drift — run: gofmt -w ."; exit 1; } + +lint: + @if command -v golangci-lint >/dev/null 2>&1; then \ + golangci-lint run ./...; \ + else \ + echo "golangci-lint not installed — skipping (vet still runs in check)"; \ + fi + +check: fmt vet test-race + +# Mirrors CI: every ```yaml block in README must load through validate, +# plus the shipped examples. (Script file, not a heredoc: macOS make is +# 3.81 and runs each recipe line in its own shell.) +check-docs: + EPMON_API_KEY=ci-dummy TOKEN=ci-dummy DEV_TOKEN=ci-dummy python3 scripts/check-docs.py + +cross: + @mkdir -p $(DIST) + @for p in $(PLATFORMS); do \ + os=$${p%/*}; arch=$${p#*/}; out=$(DIST)/epmon-$$os-$$arch; \ + test "$$os" = windows && out=$$out.exe; \ + echo "building $$out"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch $(GO) build -trimpath \ + -ldflags "$(LDFLAGS)" -o $$out ./cmd/epmon || exit 1; \ + done + +clean: + rm -f $(BINARY) + rm -rf $(DIST) + +run: + $(GO) run ./cmd/epmon run $(ARGS) + +init: + $(GO) run ./cmd/epmon init $(ARGS) + +validate: + $(GO) run ./cmd/epmon validate $(ARGS) + +# Rebuild the status SPA from a status/ checkout (default: ../status) +# and vendor it into internal/api/webui. Origin is stamped in source.txt. +web: + STATUS_DIR="$${STATUS_DIR:-../status}" sh scripts/refresh-webui.sh + +docker-build: + $(DOCKER) build -t epmon:$(VERSION) \ + --build-arg VERSION=$(VERSION) \ + --build-arg COMMIT=$(COMMIT) \ + --build-arg DATE=$(DATE) . + +docker-version: + $(DOCKER) run --rm --entrypoint /epmon epmon:$(VERSION) version + +docker-run: + $(DOCKER) run --rm -p 8080:8080 \ + -v "$(CURDIR)/data:/data:rw" \ + epmon:$(VERSION) $(ARGS) + +compose-up: + VERSION=$(VERSION) COMMIT=$(COMMIT) DATE=$(DATE) $(COMPOSE) up --build -d + +compose-down: + $(COMPOSE) down + +compose-logs: + $(COMPOSE) logs -f + +compose-config: + $(COMPOSE) config diff --git a/cmd/epmon/init.go b/cmd/epmon/init.go new file mode 100644 index 0000000..896f308 --- /dev/null +++ b/cmd/epmon/init.go @@ -0,0 +1,666 @@ +package main + +// Command epmon init generates a config file through the same schema the +// loader validates (spec §17.2): interactive prompt loop by default, flags +// with --non-interactive for scripts. Generation builds a YAML document, +// validates it through config.Parse (the loader path), and writes it +// atomically. Invalid documents are never written; existing files are +// never overwritten without --force; secrets are written as ${VAR} +// references, never literally. +// +// Usage: +// epmon init [--output epmon.yaml] [--force] +// epmon init --non-interactive [--output epmon.yaml] [--force] +// [--server-listen :8080] [--db-dsn epmon.db] [--retention-days 90] +// [--default-interval 60s] [--default-timeout 10s] [--failure-threshold 1] +// --service id=url[;key=value...] ... + +import ( + "bufio" + "bytes" + "flag" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/epmon-dev/epmon/internal/config" + "gopkg.in/yaml.v3" +) + +// initService holds one service as collected; empty interval/timeout mean +// "inherit the probe defaults", empty name means "same as id". +type initService struct { + id, name, url string + interval, timeout string + expect, body string + headers [][2]string +} + +// initDoc is the collected answers before rendering. +type initDoc struct { + output string + listen, dsn string + retention, threshold int + defInterval, defTimeout string + services []initService +} + +// serviceFlag accumulates repeatable --service values. +type serviceFlag []string + +func (s *serviceFlag) String() string { return strings.Join(*s, ", ") } +func (s *serviceFlag) Set(v string) error { *s = append(*s, v); return nil } + +// initCommand runs epmon init. Exit codes: 0 wrote a valid file, 1 the +// document was invalid/refused/aborted, 64 usage. +func initCommand(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("init", flag.ContinueOnError) + fs.SetOutput(stderr) + output := fs.String("output", "epmon.yaml", "destination file") + force := fs.Bool("force", false, "overwrite an existing file") + nonInteractive := fs.Bool("non-interactive", false, "drive from flags, no prompts") + listen := fs.String("server-listen", ":8080", "server.listen") + dsn := fs.String("db-dsn", "epmon.db", "sqlite database file") + retention := fs.Int("retention-days", 90, "database.retention_days") + defInterval := fs.String("default-interval", "60s", "probes.default_interval") + defTimeout := fs.String("default-timeout", "10s", "probes.default_timeout") + threshold := fs.Int("failure-threshold", 1, "probes.failure_threshold") + var services serviceFlag + fs.Var(&services, "service", "id=url[;key=value...]; repeatable (keys: name,interval,timeout,expect,body_contains)") + if err := fs.Parse(args); err != nil { + return exitUsage + } + if fs.NArg() > 0 { + fmt.Fprintf(stderr, "epmon: init takes no positional args\n") + return exitUsage + } + + set := map[string]bool{} + fs.Visit(func(f *flag.Flag) { set[f.Name] = true }) + + var doc *initDoc + var err error + if *nonInteractive { + doc, err = initFromFlags(*output, *listen, *dsn, *retention, *defInterval, *defTimeout, *threshold, services) + } else { + // Interactive honors --output/--force only; content flags imply + // the user meant --non-interactive. + for name := range set { + if name != "output" && name != "force" { + fmt.Fprintf(stderr, "epmon: init flag --%s needs --non-interactive (or run without flags for prompts)\n", name) + return exitUsage + } + } + doc, err = initInteractive(stdin, stdout, *output) + } + if err != nil { + fmt.Fprintf(stderr, "epmon: init: %v\n", err) + return exitConfig + } + if err := writeInitDoc(doc, *force, stdout); err != nil { + fmt.Fprintf(stderr, "epmon: init: %v\n", err) + return exitConfig + } + return exitOK +} + +// ---- non-interactive ---- + +// initFromFlags builds the document purely from flag values. Zero +// --service flags is fatal: a scripted run with no services is a +// scripting bug, and the loader rejects zero-service files anyway. +func initFromFlags(output, listen, dsn string, retention int, defInterval, defTimeout string, threshold int, services serviceFlag) (*initDoc, error) { + if len(services) == 0 { + return nil, fmt.Errorf("--non-interactive needs at least one --service (config files with zero services are invalid)") + } + doc := &initDoc{ + output: output, listen: listen, dsn: dsn, + retention: retention, defInterval: defInterval, + defTimeout: defTimeout, threshold: threshold, + } + for _, raw := range services { + svc, err := parseServiceFlag(raw) + if err != nil { + return nil, err + } + doc.services = append(doc.services, svc) + } + if err := checkInitScalars(doc); err != nil { + return nil, err + } + return doc, nil +} + +// parseServiceFlag parses id=url[;key=value...]. Keys: name, interval, +// timeout, expect, body_contains. Headers are interactive-only. +func parseServiceFlag(raw string) (initService, error) { + var svc initService + head, rest, _ := strings.Cut(raw, ";") + id, target, ok := strings.Cut(head, "=") + svc.id = strings.TrimSpace(id) + svc.url = strings.TrimSpace(target) + if !ok || svc.id == "" || svc.url == "" { + return svc, fmt.Errorf("--service %q must look like id=https://host/path[;key=value...]", raw) + } + for _, kv := range strings.Split(rest, ";") { + if kv == "" { + continue + } + k, v, ok := strings.Cut(kv, "=") + if !ok { + return svc, fmt.Errorf("--service %q: %q needs key=value", raw, kv) + } + k, v = strings.TrimSpace(k), strings.TrimSpace(v) + switch k { + case "name": + svc.name = v + case "interval": + svc.interval = v + case "timeout": + svc.timeout = v + case "expect": + svc.expect = v + case "body_contains": + svc.body = v + default: + return svc, fmt.Errorf("--service %q: unknown key %q (want name|interval|timeout|expect|body_contains)", raw, k) + } + } + return svc, nil +} + +// checkInitScalars fails fast on malformed numbers/durations/URLs so a +// script gets a clear error; ranges remain the loader's job at the gate. +func checkInitScalars(doc *initDoc) error { + if doc.retention < 1 { + return fmt.Errorf("retention-days must be >= 1") + } + if doc.threshold < 1 { + return fmt.Errorf("failure-threshold must be >= 1") + } + for _, d := range []string{doc.defInterval, doc.defTimeout} { + if _, err := time.ParseDuration(d); err != nil { + return fmt.Errorf("bad duration %q: %v", d, err) + } + } + for i, s := range doc.services { + if err := checkInitService(s); err != nil { + return fmt.Errorf("service %d: %v", i, err) + } + } + return nil +} + +func checkInitService(s initService) error { + if _, err := time.ParseDuration(orDefault(s.interval, "60s")); err != nil { + return fmt.Errorf("bad interval %q: %v", s.interval, err) + } + if _, err := time.ParseDuration(orDefault(s.timeout, "10s")); err != nil { + return fmt.Errorf("bad timeout %q: %v", s.timeout, err) + } + if _, err := parseExpect(orDefault(s.expect, "200")); err != nil { + return fmt.Errorf("bad expect %q: %v", s.expect, err) + } + return checkURL(s.url) +} + +func checkURL(raw string) error { + u, err := url.ParseRequestURI(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("url must be absolute http(s), got %q", raw) + } + return nil +} + +func orDefault(v, def string) string { + if strings.TrimSpace(v) == "" { + return def + } + return v +} + +// ---- interactive ---- + +// prompter reads answer lines; EOF aborts the run with errAbort. +type prompter struct { + sc *bufio.Scanner + out io.Writer +} + +var errAbort = fmt.Errorf("aborted (no file written)") + +func (p *prompter) ask(prompt, def string) (string, error) { + if def != "" { + fmt.Fprintf(p.out, "%s [%s]: ", prompt, def) + } else { + fmt.Fprintf(p.out, "%s: ", prompt) + } + if !p.sc.Scan() { + if err := p.sc.Err(); err != nil { + return "", err + } + return "", errAbort + } + ans := strings.TrimSpace(p.sc.Text()) + if ans == "" { + return def, nil + } + return ans, nil +} + +func (p *prompter) askNonEmpty(prompt string) (string, error) { + for { + ans, err := p.ask(prompt, "") + if err != nil { + return "", err + } + if ans != "" { + return ans, nil + } + } +} + +// initInteractive walks the prompts. A first empty service id re-prompts +// (zero-service files are invalid); EOF anywhere aborts with no output. +func initInteractive(stdin io.Reader, stdout io.Writer, output string) (*initDoc, error) { + p := &prompter{sc: bufio.NewScanner(stdin), out: stdout} + p.sc.Buffer(make([]byte, 64*1024), 1024*1024) + fmt.Fprintln(stdout, "epmon init — answer prompts, empty takes [default]. Ctrl-C aborts.") + + doc := &initDoc{output: output} + var err error + if doc.listen, err = p.ask("Listen address", ":8080"); err != nil { + return nil, err + } + if doc.dsn, err = p.ask("SQLite database file", "epmon.db"); err != nil { + return nil, err + } + retention, err := askInt(p, "Retention days", 90, 1, 0) + if err != nil { + return nil, err + } + doc.retention = retention + if doc.defInterval, err = askDuration(p, "Default probe interval", "60s"); err != nil { + return nil, err + } + if doc.defTimeout, err = askDuration(p, "Default probe timeout", "10s"); err != nil { + return nil, err + } + threshold, err := askInt(p, "Failure threshold", 1, 1, 0) + if err != nil { + return nil, err + } + doc.threshold = threshold + + for { + id, err := p.ask("Service id (empty when done)", "") + if err != nil { + return nil, err + } + if id == "" { + if len(doc.services) == 0 { + fmt.Fprintln(stdout, "At least one service is required — a config with zero services is invalid.") + continue + } + break + } + svc, err := askService(p, id) + if err != nil { + return nil, err + } + doc.services = append(doc.services, svc) + } + return doc, nil +} + +func askInt(p *prompter, prompt string, def, min, max int) (int, error) { + for { + ans, err := p.ask(prompt, strconv.Itoa(def)) + if err != nil { + return 0, err + } + n, err := strconv.Atoi(ans) + if err != nil || n < min || (max > 0 && n > max) { + if max > 0 { + fmt.Fprintf(p.out, "Enter a number %d..%d.\n", min, max) + } else { + fmt.Fprintf(p.out, "Enter a number >= %d.\n", min) + } + continue + } + return n, nil + } +} + +func askDuration(p *prompter, prompt, def string) (string, error) { + for { + ans, err := p.ask(prompt, def) + if err != nil { + return "", err + } + if _, err := time.ParseDuration(ans); err != nil { + fmt.Fprintf(p.out, "Bad duration %q (try 30s, 5m).\n", ans) + continue + } + return ans, nil + } +} + +// askService collects one service; id was already given. URL and expect +// re-prompt on errors; ranges are enforced by the validate gate. +func askService(p *prompter, id string) (initService, error) { + svc := initService{id: id} + var err error + if svc.name, err = p.ask(" Name", id); err != nil { + return svc, err + } + for { + if svc.url, err = p.askNonEmpty(" URL"); err != nil { + return svc, err + } + if err := checkURL(svc.url); err != nil { + fmt.Fprintf(p.out, " %v.\n", err) + continue + } + break + } + if svc.interval, err = askOptionalDuration(p, " Interval (empty = default)"); err != nil { + return svc, err + } + if svc.timeout, err = askOptionalDuration(p, " Timeout (empty = default)"); err != nil { + return svc, err + } + for { + ans, err := p.ask(" Expected status (200, 2xx, 200,301)", "200") + if err != nil { + return svc, err + } + if _, err := parseExpect(ans); err != nil { + fmt.Fprintf(p.out, " %v.\n", err) + continue + } + svc.expect = ans + break + } + if svc.body, err = p.ask(" Body must contain (empty = skip)", ""); err != nil { + return svc, err + } + for { + h, err := p.ask(` Header "Name: value" ($NAME keeps a secret out of the file, empty when done)`, "") + if err != nil { + return svc, err + } + if h == "" { + break + } + name, value, ok := strings.Cut(h, ":") + name, value = strings.TrimSpace(name), strings.TrimSpace(value) + if !ok || name == "" || value == "" { + fmt.Fprintln(p.out, ` Give "Name: value" or leave empty.`) + continue + } + svc.headers = append(svc.headers, [2]string{name, secretRef(value)}) + } + return svc, nil +} + +// askOptionalDuration is askDuration with blank allowed (inherit). +func askOptionalDuration(p *prompter, prompt string) (string, error) { + for { + fmt.Fprintf(p.out, "%s: ", prompt) + if !p.sc.Scan() { + if err := p.sc.Err(); err != nil { + return "", err + } + return "", errAbort + } + ans := strings.TrimSpace(p.sc.Text()) + if ans == "" { + return "", nil + } + if _, err := time.ParseDuration(ans); err != nil { + fmt.Fprintf(p.out, "Bad duration %q (try 30s, 5m).\n", ans) + continue + } + return ans, nil + } +} + +// secretRef rewrites bare $NAME references to ${NAME} anywhere in the +// value — the substitution grammar (§4.2) only expands braced forms, so +// `Bearer $TOKEN` would otherwise reach the runtime literally. $$ +// (literal $) and ${...} forms pass through untouched. +var bareRefRe = regexp.MustCompile(`\$(\$|[A-Za-z_][A-Za-z0-9_]*)`) + +func secretRef(v string) string { + return bareRefRe.ReplaceAllStringFunc(v, func(m string) string { + if m == "$$" { + return m + } + return "${" + m[1:] + "}" + }) +} + +// parseExpect validates expect_status input through the loader's own +// §4.3.1 parser and returns the YAML node to emit: a bare scalar for a +// single int/class, a flow sequence otherwise. +func parseExpect(input string) (*yaml.Node, error) { + toks := strings.Fields(strings.ReplaceAll(input, ",", " ")) + if len(toks) == 0 { + return nil, fmt.Errorf("expect_status needs at least one status (200, 2xx, 200,301)") + } + items := make([]*yaml.Node, 0, len(toks)) + for _, t := range toks { + if n, err := strconv.Atoi(t); err == nil { + items = append(items, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(n)}) + } else { + items = append(items, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: t}) + } + } + seq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Content: items} + var spec config.StatusSpec + if err := spec.UnmarshalYAML(seq); err != nil { + return nil, err + } + if len(items) == 1 { + return items[0], nil + } + seq.Style = yaml.FlowStyle + return seq, nil +} + +// ---- render + gate + write ---- + +// renderInitDoc builds the YAML document: only keys init covers, with +// section comments. Everything else stays loader-defaulted. +func renderInitDoc(doc *initDoc) *yaml.Node { + str := func(v string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: v} + } + num := func(n int) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(n)} + } + pair := func(k string, v *yaml.Node) []*yaml.Node { + return []*yaml.Node{str(k), v} + } + mapping := func(pairs ...[]*yaml.Node) *yaml.Node { + m := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + for _, p := range pairs { + m.Content = append(m.Content, p...) + } + return m + } + + root := mapping() + content := &root.Content + + server := mapping( + pair("listen", str(doc.listen)), + ) + server.HeadComment = "epmon API listen address." + db := mapping( + pair("driver", str("sqlite")), + pair("dsn", str(doc.dsn)), + pair("retention_days", num(doc.retention)), + ) + db.HeadComment = "Storage: sqlite file plus history retention." + probes := mapping( + pair("default_interval", str(doc.defInterval)), + pair("default_timeout", str(doc.defTimeout)), + pair("failure_threshold", num(doc.threshold)), + ) + probes.HeadComment = "Fleet probe defaults; services inherit unless overridden." + + svcs := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + for _, s := range doc.services { + expect, err := parseExpect(orDefault(s.expect, "200")) + if err != nil { + expect = str("200") // validated earlier; cannot happen + } + fields := []*yaml.Node{str("id"), str(s.id)} + if s.name != "" && s.name != s.id { + fields = append(fields, str("name"), str(s.name)) + } + fields = append(fields, str("url"), str(s.url)) + if strings.TrimSpace(s.interval) != "" { + fields = append(fields, str("interval"), str(s.interval)) + } + if strings.TrimSpace(s.timeout) != "" { + fields = append(fields, str("timeout"), str(s.timeout)) + } + fields = append(fields, str("expect_status"), expect) + if s.body != "" { + fields = append(fields, str("body_contains"), str(s.body)) + } + if len(s.headers) > 0 { + hm := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + for _, h := range s.headers { + hm.Content = append(hm.Content, str(h[0]), str(h[1])) + } + fields = append(fields, str("headers"), hm) + } + svc := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: fields} + svcs.Content = append(svcs.Content, svc) + } + + for _, kv := range [][]*yaml.Node{ + pair("server", server), + pair("database", db), + pair("probes", probes), + {str("services"), svcs}, + } { + *content = append(*content, kv...) + } + return &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}} +} + +// validateInitDoc marshals the rendered document and runs it through +// config.Parse — the exact loader path `run` uses. ${VAR} references the +// operator typed are stubbed during the check (and only the check) so +// substitution doesn't fail on values that will exist at runtime. +func validateInitDoc(doc *initDoc, node *yaml.Node) error { + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(node); err != nil { + return err + } + if err := enc.Close(); err != nil { + return err + } + restore := stubMissingEnv(stubEnvNames(buf.Bytes())) + defer restore() + if _, err := config.Parse(doc.output, buf.Bytes()); err != nil { + return err + } + return nil +} + +var envRefRe = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-[^}]*)?\}`) + +func stubEnvNames(raw []byte) []string { + var missing []string + for _, m := range envRefRe.FindAllSubmatch(raw, -1) { + name := string(m[1]) + if _, ok := os.LookupEnv(name); !ok { + missing = append(missing, name) + } + } + return missing +} + +func stubMissingEnv(names []string) func() { + prev := make(map[string]*string, len(names)) + for _, n := range names { + if v, ok := os.LookupEnv(n); ok { + p := v + prev[n] = &p + } else { + prev[n] = nil + } + _ = os.Setenv(n, "epmon-init-placeholder") + } + return func() { + for n, p := range prev { + if p == nil { + _ = os.Unsetenv(n) + } else { + _ = os.Setenv(n, *p) + } + } + } +} + +// writeInitDoc validates the rendered document, then writes it atomically +// (temp + rename in the destination directory). Existing files need +// --force; nothing is written on any failure. +func writeInitDoc(doc *initDoc, force bool, stdout io.Writer) error { + if _, err := os.Stat(doc.output); err == nil && !force { + return fmt.Errorf("%s exists (use --force to overwrite)", doc.output) + } + node := renderInitDoc(doc) + if err := validateInitDoc(doc, node); err != nil { + return fmt.Errorf("generated config is invalid: %v", err) + } + var buf bytes.Buffer + buf.WriteString("# Generated by `epmon init` — edit freely, then validate:\n") + buf.WriteString("# epmon validate --config " + doc.output + "\n") + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(node); err != nil { + return err + } + if err := enc.Close(); err != nil { + return err + } + dir := filepath.Dir(doc.output) + tmp, err := os.CreateTemp(dir, ".epmon-init-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(buf.Bytes()); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(0o644); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, doc.output); err != nil { + return err + } + fmt.Fprintf(stdout, "wrote %s (%d service(s)); verify with: epmon validate --config %s\n", + doc.output, len(doc.services), doc.output) + return nil +} diff --git a/cmd/epmon/init_test.go b/cmd/epmon/init_test.go new file mode 100644 index 0000000..31b5c89 --- /dev/null +++ b/cmd/epmon/init_test.go @@ -0,0 +1,265 @@ +package main + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/epmon-dev/epmon/internal/config" + "gopkg.in/yaml.v3" +) + +func TestSecretRef(t *testing.T) { + for in, want := range map[string]string{ + "$TOKEN": "${TOKEN}", + "Bearer $TOKEN": "Bearer ${TOKEN}", + "${TOKEN}": "${TOKEN}", + "${T:-fallback}": "${T:-fallback}", + "$$": "$$", + "costs $$5": "costs $$5", + "plain": "plain", + "Bearer ${T} $U": "Bearer ${T} ${U}", + "trailing$": "trailing$", + "$9bad $GOOD": "$9bad ${GOOD}", + } { + if got := secretRef(in); got != want { + t.Errorf("secretRef(%q) = %q, want %q", in, got, want) + } + } +} + +func TestParseExpect(t *testing.T) { + single, err := parseExpect("200") + if err != nil { + t.Fatalf("parseExpect(200): %v", err) + } + if single.Tag != "!!int" || single.Value != "200" { + t.Errorf("single int emits %s %q, want scalar 200", single.Tag, single.Value) + } + class, err := parseExpect("2xx") + if err != nil { + t.Fatalf("parseExpect(2xx): %v", err) + } + if class.Tag != "!!str" || class.Value != "2xx" { + t.Errorf("single class emits %s %q, want scalar 2xx", class.Tag, class.Value) + } + multi, err := parseExpect("200, 301") + if err != nil { + t.Fatalf("parseExpect(200, 301): %v", err) + } + if multi.Kind != yaml.SequenceNode { + t.Errorf("multi emits kind %v, want sequence", multi.Kind) + } + for _, bad := range []string{"", "bogus", "99", "200, bogus", `"200"`} { + if _, err := parseExpect(bad); err == nil { + t.Errorf("parseExpect(%q) = nil error, want failure", bad) + } + } +} + +func TestInitFromFlagsRejects(t *testing.T) { + if _, err := initFromFlags("o.yaml", ":8080", "e.db", 90, "60s", "10s", 1, nil); err == nil { + t.Error("zero services accepted, want failure") + } + for _, raw := range []string{"nourl", "=https://x.example.com", "id=", "api=https://a.example.com;bogus=1"} { + if _, err := initFromFlags("o.yaml", ":8080", "e.db", 90, "60s", "10s", 1, serviceFlag{raw}); err == nil { + t.Errorf("service %q accepted, want failure", raw) + } + } +} + +// TestInitNonInteractiveGolden drives flags through rendering, asserts the +// exact file, and proves the gate by loading it with the real loader. +func TestInitNonInteractiveGolden(t *testing.T) { + out := filepath.Join(t.TempDir(), "epmon.yaml") + doc, err := initFromFlags(out, ":8080", "epmon.db", 90, "60s", "10s", 1, serviceFlag{ + "api=https://api.example.com/healthz;interval=30s;expect=200,301", + "web=https://example.com", + }) + if err != nil { + t.Fatalf("initFromFlags: %v", err) + } + if err := writeInitDoc(doc, false, io.Discard); err != nil { + t.Fatalf("writeInitDoc: %v", err) + } + raw, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read output: %v", err) + } + want := "# Generated by `epmon init` — edit freely, then validate:\n" + + "# epmon validate --config " + out + "\n" + + "server:\n" + + " # epmon API listen address.\n" + + " listen: :8080\n" + + "database:\n" + + " # Storage: sqlite file plus history retention.\n" + + " driver: sqlite\n" + + " dsn: epmon.db\n" + + " retention_days: 90\n" + + "probes:\n" + + " # Fleet probe defaults; services inherit unless overridden.\n" + + " default_interval: 60s\n" + + " default_timeout: 10s\n" + + " failure_threshold: 1\n" + + "services:\n" + + " - id: api\n" + + " url: https://api.example.com/healthz\n" + + " interval: 30s\n" + + " expect_status: [200, 301]\n" + + " - id: web\n" + + " url: https://example.com\n" + + " expect_status: 200\n" + if string(raw) != want { + t.Errorf("golden mismatch:\n got:\n%s\nwant:\n%s", raw, want) + } + if _, err := config.Load(out); err != nil { + t.Errorf("generated file fails the real loader: %v", err) + } +} + +func TestInitRefusesOverwrite(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "epmon.yaml") + if err := os.WriteFile(out, []byte("existing: true\n"), 0o644); err != nil { + t.Fatal(err) + } + doc, err := initFromFlags(out, ":8080", "e.db", 90, "60s", "10s", 1, + serviceFlag{"a=https://a.example.com"}) + if err != nil { + t.Fatal(err) + } + if err := writeInitDoc(doc, false, io.Discard); err == nil { + t.Fatal("overwrite without --force accepted") + } + raw, _ := os.ReadFile(out) + if string(raw) != "existing: true\n" { + t.Error("existing file touched despite refusal") + } + if err := writeInitDoc(doc, true, io.Discard); err != nil { + t.Fatalf("--force write: %v", err) + } + if _, err := config.Load(out); err != nil { + t.Errorf("forced file fails the loader: %v", err) + } +} + +// TestInitGateLeavesNothingBehind feeds an un-runnable document (bad URL +// passes the fast checks but the loader rejects it) and asserts neither +// the destination nor temp files appear. +func TestInitGateLeavesNothingBehind(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "epmon.yaml") + doc := &initDoc{ + output: out, listen: ":8080", dsn: "e.db", retention: 90, + defInterval: "60s", defTimeout: "10s", threshold: 1, + services: []initService{{id: "a", url: "https://a.example.com", expect: "200"}}, + } + doc.services[0].interval = "1s" // parses, but loader demands >= 5s + if err := writeInitDoc(doc, false, io.Discard); err == nil { + t.Fatal("invalid document written") + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("leftovers in %s: %v", dir, names) + } +} + +const interactiveInput = ` +db.sqlite + + + + +api +API +https://api.example.com/healthz +30s + +2xx + +Authorization: Bearer $TOKEN + +web + +https://example.com + + + + + + +` + +func TestInitInteractiveGolden(t *testing.T) { + out := filepath.Join(t.TempDir(), "epmon.yaml") + doc, err := initInteractive(strings.NewReader(interactiveInput), io.Discard, out) + if err != nil { + t.Fatalf("initInteractive: %v", err) + } + if len(doc.services) != 2 || doc.services[0].id != "api" || doc.dsn != "db.sqlite" { + t.Fatalf("unexpected doc: %+v", doc) + } + if got := doc.services[0].headers[0]; got != [2]string{"Authorization", "Bearer ${TOKEN}"} { + t.Errorf("header = %v, want secret reference", got) + } + if err := writeInitDoc(doc, false, io.Discard); err != nil { + t.Fatalf("writeInitDoc: %v", err) + } + // The stubbed gate must hold with the real variable present too. + t.Setenv("TOKEN", "secret") + if _, err := config.Load(out); err != nil { + t.Errorf("generated file fails the real loader: %v", err) + } +} + +func TestInitInteractiveAbort(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "epmon.yaml") + // EOF mid-service: no file may appear. + if _, err := initInteractive(strings.NewReader("\n\ndb.sqlite\n\n\n\n\napi\n"), io.Discard, out); err == nil { + t.Fatal("EOF accepted, want abort") + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Error("file written despite abort") + } + // Empty id up front re-prompts instead of producing zero services. + doc, err := initInteractive(strings.NewReader("\ndb.sqlite\n\n\n\n\n\nweb\n\nhttps://example.com\n\n\n\n\n\n\n"), io.Discard, out) + if err != nil { + t.Fatalf("empty-first-id run: %v", err) + } + if len(doc.services) != 1 { + t.Errorf("got %d services, want 1", len(doc.services)) + } +} + +func TestInitCommandUsage(t *testing.T) { + if code := initCommand([]string{"positional"}, strings.NewReader(""), io.Discard, io.Discard); code != exitUsage { + t.Errorf("positional args = %d, want %d", code, exitUsage) + } + if code := initCommand([]string{"--service", "a=https://a.example.com"}, strings.NewReader(""), io.Discard, io.Discard); code != exitUsage { + t.Errorf("--service without --non-interactive = %d, want %d", code, exitUsage) + } +} + +func TestInitCommandNonInteractive(t *testing.T) { + out := filepath.Join(t.TempDir(), "epmon.yaml") + code := initCommand([]string{ + "--non-interactive", "--output", out, + "--service", "a=https://a.example.com", + }, strings.NewReader(""), io.Discard, io.Discard) + if code != exitOK { + t.Fatalf("initCommand = %d, want %d", code, exitOK) + } + if _, err := config.Load(out); err != nil { + t.Errorf("generated file fails the real loader: %v", err) + } +} diff --git a/cmd/epmon/main.go b/cmd/epmon/main.go index 743537b..3e65022 100644 --- a/cmd/epmon/main.go +++ b/cmd/epmon/main.go @@ -1,7 +1,7 @@ // Command epmon monitors HTTP endpoints from a YAML/JSON catalogue, // stores every probe in SQLite, and serves the results as JSON. // -// Usage: epmon [run|validate|healthcheck|version] [-config config.yaml] +// Usage: epmon [run|validate|healthcheck|version|init] [-config config.yaml] package main import ( @@ -51,8 +51,10 @@ func execute(ctx context.Context, args []string, stdout, stderr io.Writer) int { return healthcheckEndpoint(args, stdout, stderr) case "version": return printVersion(stdout, stderr) + case "init": + return initCommand(args[1:], stdinReader(), stdout, stderr) default: - fmt.Fprintf(stderr, "epmon: unknown command %q (want run|validate|healthcheck|version)\n", args[0]) + fmt.Fprintf(stderr, "epmon: unknown command %q (want run|validate|healthcheck|version|init)\n", args[0]) return exitUsage } } @@ -257,6 +259,10 @@ var ( date = "unknown" ) +// stdinReader is a seam so init's prompt loop stays testable; production +// passes the real stdin through. +func stdinReader() io.Reader { return os.Stdin } + func main() { os.Exit(execute(context.Background(), os.Args[1:], os.Stdout, os.Stderr)) } diff --git a/internal/api/api.go b/internal/api/api.go index 835a6cf..b3c0d44 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -31,6 +31,7 @@ import ( "strings" "time" + "github.com/epmon-dev/epmon/internal/api/webui" "github.com/epmon-dev/epmon/internal/config" "github.com/epmon-dev/epmon/internal/store" "gopkg.in/yaml.v3" @@ -84,7 +85,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET "+prefix+"/openapi.yaml", s.serveSpecYAML) mux.HandleFunc("GET "+prefix+"/openapi.json", s.serveSpecJSON) mux.HandleFunc("GET /docs", s.serveDocs) - mux.HandleFunc("/", notFound) + mux.HandleFunc("/", s.root) limiter := newRateLimiter(s.cfg.Server.RateLimitRPM, s.cfg.Server.RateLimitBurst) var h http.Handler = mux @@ -124,6 +125,23 @@ func notFound(w http.ResponseWriter, _ *http.Request) { writeErr(w, http.StatusNotFound, "not_found", "unknown endpoint") } +// root serves the embedded status UI (spec §9) when enabled; otherwise +// the historical JSON 404. API routes always win — mux longest-match +// routes them before this catch-all. Unknown /api/* paths stay JSON: +// API clients must never receive the SPA shell. +func (s *Server) root(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + if p == "/api" || strings.HasPrefix(p, "/api/") { + notFound(w, r) + return + } + if s.cfg.StatusPageEnabled() { + webui.Handler().ServeHTTP(w, r) + return + } + notFound(w, r) +} + func (s *Server) serveSpecYAML(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/yaml") w.WriteHeader(http.StatusOK) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 0002627..a0bf6e3 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -107,7 +107,9 @@ func TestRoutingErrorsAreJSON(t *testing.T) { srv, _ := testServer(t) h := srv.Handler() - for _, path := range []string{"/nope", "/api/v1/nope", "/api/v2/status", "/api/status"} { + // Non-API unknowns ("/nope") serve the SPA shell when the status + // page is enabled; /api/* unknowns stay JSON. Both are pinned here. + for _, path := range []string{"/api/v1/nope", "/api/v2/status", "/api/status", "/api"} { code, body, _ := do(t, h, "GET", path, "") if code != 404 { t.Errorf("GET %s = %d, want 404", path, code) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 29cc150..48da3eb 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -77,7 +77,7 @@ func TestAPIContractFromSpec(t *testing.T) { {"GET", "/api/v1/openapi.yaml", "/api/v1/openapi.yaml", ""}, {"GET", "/api/v1/openapi.json", "/api/v1/openapi.json", ""}, {"GET", "/docs", "/docs", ""}, - {"GET", "/nope", "/nope", ""}, + {"GET", "/api/v1/nope", "/nope", ""}, } covered := map[string]bool{} diff --git a/internal/api/root_test.go b/internal/api/root_test.go new file mode 100644 index 0000000..ec5b6aa --- /dev/null +++ b/internal/api/root_test.go @@ -0,0 +1,57 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/epmon-dev/epmon/internal/config" +) + +// TestRootGating pins the spec §9 contract: the SPA shell when enabled, +// the historical JSON 404 when disabled, and JSON 404 for /api/* +// unknowns in both modes (API clients must never get HTML). +func TestRootGating(t *testing.T) { + srv, _ := testServer(t) // StatusPage.Enabled nil → default true + enabled := srv.Handler() + + falsy := false + offCfg := &config.Config{ + Server: config.Server{ + MaxBodyBytes: 1 << 20, + StatusPage: config.StatusPageConfig{Enabled: &falsy}, + }, + Services: []config.Service{ + {ID: "web", Name: "Web", URL: "https://example.com"}, + }, + } + off := New(offCfg, openTestStore(t), time.Now).Handler() + + serve := func(h http.Handler, path string) (int, string, http.Header) { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Code, rec.Body.String(), rec.Header() + } + + if code, _, header := serve(enabled, "/"); code != 302 || header.Get("Location") != "/local" { + t.Errorf("enabled / = %d, want redirect to /local", code) + } + if code, body, _ := serve(enabled, "/local"); code != 200 || !strings.Contains(body, `