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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/deploys-app/deploys
go 1.27.0

require (
github.com/deploys-app/api v0.0.0-20260902050821-02959c8ba9ca
github.com/deploys-app/api v0.0.0-20260903231343-5b928d9c0434
github.com/moonrhythm/toon v0.0.0-20260702100246-6fcdad0a6a12
golang.org/x/mod v0.37.0
golang.org/x/oauth2 v0.14.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:W
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deploys-app/api v0.0.0-20260902050821-02959c8ba9ca h1:0hWen3W//N1+F/haVxmqyQrUyUZtBfw+u/8jVT/xHVA=
github.com/deploys-app/api v0.0.0-20260902050821-02959c8ba9ca/go.mod h1:QN5lioYbGyxSstxlyrvXrERJch7S2hnNCMNOVAu/6K0=
github.com/deploys-app/api v0.0.0-20260903231343-5b928d9c0434 h1:kLWE9MLFCICccgsY6NossFoDtdrE1qt2DJdXXc3mXvA=
github.com/deploys-app/api v0.0.0-20260903231343-5b928d9c0434/go.mod h1:QN5lioYbGyxSstxlyrvXrERJch7S2hnNCMNOVAu/6K0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
Expand Down
57 changes: 48 additions & 9 deletions internal/runner/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import (
"github.com/deploys-app/api"
)

const alertOpFlagHelp = "comparison operator: >=, <=, gte, or lte (default >=; quote '>=' / '<=' in shells)"
const (
alertOpFlagHelp = "comparison operator: '>=', '<=', gte, or lte (default '>='; quote '>=' / '<=' in shells)"
alertKindFlagHelp = "target kind: deployment or custom (empty = deployment)"
alertMetricFlagHelp = "metric to watch: cpu, memory, requests, or egress (kind=deployment); value or rate (kind=custom)"
alertSourceFlagHelp = "metric source name (kind=custom)"
alertSeriesFlagHelp = "series key (kind=custom)"
)

func (rn Runner) alert(args ...string) error {
if len(args) == 0 || IsHelpArg(args[0]) {
Expand Down Expand Up @@ -43,16 +49,20 @@ func (rn Runner) alert(args ...string) error {
var req api.AlertCreate
f.StringVar(&req.Project, "project", "", "project id")
f.StringVar(&req.Name, "name", "", "alert rule name")
f.StringVar(&req.Target.Location, "location", "", "target location")
f.StringVar(&req.Target.Deployment, "deployment", "", "target deployment name")
f.StringVar(&req.Condition.Metric, "metric", "", "metric to watch: cpu, memory, requests, or egress")
f.StringVar(&req.Target.Kind, "kind", "", alertKindFlagHelp)
f.StringVar(&req.Target.Location, "location", "", "target location (kind=deployment)")
f.StringVar(&req.Target.Deployment, "deployment", "", "target deployment name (kind=deployment)")
f.StringVar(&req.Target.Source, "source", "", alertSourceFlagHelp)
f.StringVar(&req.Target.Series, "series", "", alertSeriesFlagHelp)
f.StringVar(&req.Condition.Metric, "metric", "", alertMetricFlagHelp)
f.StringVar(&req.Condition.Op, "op", "", alertOpFlagHelp)
f.Float64Var(&req.Condition.Threshold, "threshold", 0, "threshold value (percent 0-100 for cpu/memory, req/min for requests, bytes/min for egress)")
f.Float64Var(&req.Condition.Threshold, "threshold", 0, "threshold value (percent 0-100 for cpu/memory, req/min for requests, bytes/min for egress, gauge for value, per-minute increase for rate)")
f.IntVar(&req.Condition.ForMinutes, "for", 0, "minutes the condition must hold continuously (1-60)")
f.IntVar(&req.RenotifyMinutes, "renotify", 0, "minutes between re-notifications while still firing (0 = disabled)")
f.BoolVar(&req.Disabled, "disabled", false, "create the rule disabled")
f.Parse(args[1:])
req.Condition.Op = normalizeAlertOp(req.Condition.Op)
applyAlertTargetKind(&req.Target)
resp, err = s.Create(context.Background(), &req)

case "update":
Expand All @@ -62,8 +72,11 @@ func (rn Runner) alert(args ...string) error {
// before applying overrides.
var (
req api.AlertUpdate
kind string
location string
deployment string
source string
series string
metric string
op string
threshold float64
Expand All @@ -73,11 +86,14 @@ func (rn Runner) alert(args ...string) error {
)
f.StringVar(&req.Project, "project", "", "project id")
f.StringVar(&req.Name, "name", "", "alert rule name")
f.StringVar(&location, "location", "", "target location")
f.StringVar(&deployment, "deployment", "", "target deployment name")
f.StringVar(&metric, "metric", "", "metric to watch: cpu, memory, requests, or egress")
f.StringVar(&kind, "kind", "", alertKindFlagHelp)
f.StringVar(&location, "location", "", "target location (kind=deployment)")
f.StringVar(&deployment, "deployment", "", "target deployment name (kind=deployment)")
f.StringVar(&source, "source", "", alertSourceFlagHelp)
f.StringVar(&series, "series", "", alertSeriesFlagHelp)
f.StringVar(&metric, "metric", "", alertMetricFlagHelp)
f.StringVar(&op, "op", "", alertOpFlagHelp)
f.Float64Var(&threshold, "threshold", 0, "threshold value (percent 0-100 for cpu/memory, req/min for requests, bytes/min for egress)")
f.Float64Var(&threshold, "threshold", 0, "threshold value (percent 0-100 for cpu/memory, req/min for requests, bytes/min for egress, gauge for value, per-minute increase for rate)")
f.IntVar(&forMin, "for", 0, "minutes the condition must hold continuously (1-60)")
f.IntVar(&renotify, "renotify", 0, "minutes between re-notifications while still firing (0 = disabled)")
f.BoolVar(&disabled, "disabled", false, "disable the rule")
Expand All @@ -95,12 +111,21 @@ func (rn Runner) alert(args ...string) error {
req.RenotifyMinutes = cur.RenotifyMinutes
req.Disabled = cur.Disabled

if set["kind"] {
req.Target.Kind = kind
}
if set["location"] {
req.Target.Location = location
}
if set["deployment"] {
req.Target.Deployment = deployment
}
if set["source"] {
req.Target.Source = source
}
if set["series"] {
req.Target.Series = series
}
if set["metric"] {
req.Condition.Metric = metric
}
Expand All @@ -119,6 +144,7 @@ func (rn Runner) alert(args ...string) error {
if set["disabled"] {
req.Disabled = disabled
}
applyAlertTargetKind(&req.Target)
resp, err = s.Update(context.Background(), &req)

case "delete":
Expand All @@ -142,6 +168,19 @@ func (rn Runner) alert(args ...string) error {
return rn.print(resp)
}

// applyAlertTargetKind enforces the API shape after flags are applied: kind=custom
// requires Location/Deployment empty; kind=deployment (or empty) requires Source/
// Series empty. Empty Kind is left empty (the API treats it as deployment).
func applyAlertTargetKind(t *api.AlertTarget) {
if t.Kind == api.AlertTargetKindCustom {
t.Location = ""
t.Deployment = ""
return
}
t.Source = ""
t.Series = ""
}

// normalizeAlertOp maps CLI-friendly aliases to the API operators.
// Shells treat unquoted >= and <= as redirections, so gte/lte are accepted too.
func normalizeAlertOp(op string) string {
Expand Down
54 changes: 54 additions & 0 deletions internal/runner/alert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,57 @@ func TestAlertCreateHelpMentionsShellSafeOp(t *testing.T) {
}
}
}

func TestAlertCreateHelpMentionsKindAndSource(t *testing.T) {
c := lookupCommand("alert")
if c == nil {
t.Fatal("alert group missing")
}
sub := c.lookupSub("create")
if sub == nil {
t.Fatal("alert create missing")
}
for _, want := range []string{"kind", "source", "value", "rate"} {
if !strings.Contains(sub.args, want) {
t.Errorf("create args %q missing %q", sub.args, want)
}
}
}

func TestApplyAlertTargetKind(t *testing.T) {
custom := api.AlertTarget{
Kind: api.AlertTargetKindCustom,
Location: "gke.cluster-rcf2",
Deployment: "web",
Source: "app",
Series: `queue_depth{queue="email"}`,
}
applyAlertTargetKind(&custom)
if custom.Kind != api.AlertTargetKindCustom {
t.Errorf("custom kind = %q; want %q", custom.Kind, api.AlertTargetKindCustom)
}
if custom.Location != "" || custom.Deployment != "" {
t.Errorf("custom target kept deployment fields: %+v", custom)
}
if custom.Source != "app" || custom.Series != `queue_depth{queue="email"}` {
t.Errorf("custom target dropped source/series: %+v", custom)
}

dep := api.AlertTarget{
Kind: "",
Location: "gke.cluster-rcf2",
Deployment: "web",
Source: "app",
Series: `queue_depth{queue="email"}`,
}
applyAlertTargetKind(&dep)
if dep.Kind != "" {
t.Errorf("empty kind rewritten to %q; want empty", dep.Kind)
}
if dep.Location != "gke.cluster-rcf2" || dep.Deployment != "web" {
t.Errorf("deployment target dropped location/deployment: %+v", dep)
}
if dep.Source != "" || dep.Series != "" {
t.Errorf("deployment target kept custom fields: %+v", dep)
}
}
16 changes: 14 additions & 2 deletions internal/runner/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,11 +380,23 @@ var commands = []command{
{name: "pull", args: "-name [-ack -limit -follow -poll -interval]", short: "fetch a pull channel's change events (ack to advance; -follow streams over SSE)"},
},
},
{
name: "metricsource",
short: "Prometheus scrape sources for custom metrics and alerts",
subs: []subcommand{
{name: "set", args: "-name -location -deployment -port [-path /metrics -disabled]", short: "create or replace a scrape source"},
{name: "get", args: "-name", short: "show a scrape source"},
{name: "list", short: "list scrape sources"},
{name: "delete", args: "-name", short: "delete a scrape source"},
{name: "series", args: "-name", short: "list discovered series for a scrape source"},
{name: "query", args: "-name [-series s] [-timerange 1h|6h|12h|1d|7d|30d]", short: "query series for a scrape source"},
},
},
{
name: "alert",
short: "metric alert rules on a deployment's CPU, memory, requests, or egress",
short: "metric alert rules on a deployment or a custom metric source",
subs: []subcommand{
{name: "create", args: "-name -location -deployment -metric cpu|memory|requests|egress -threshold n -for n [-op gte|lte|'>='|'<=' -renotify n -disabled]", short: "create a metric alert rule"},
{name: "create", args: "-name [-kind deployment|custom] [-location -deployment | -source -series] -metric cpu|memory|requests|egress|value|rate -threshold n -for n [-op gte|lte|'>='|'<=' -renotify n -disabled]", short: "create a metric alert rule"},
{name: "get", args: "-name", short: "show an alert rule"},
{name: "list", short: "list alert rules"},
{name: "update", args: "-name [flags]", short: "update an alert rule (omitted flags are preserved)"},
Expand Down
3 changes: 3 additions & 0 deletions internal/runner/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ func TestRunHelpNeedsNoAPI(t *testing.T) {
{[]string{"alert"}, "Subcommands:"},
{[]string{"alert", "help"}, "metric alert rules"},
{[]string{"alert", "-h"}, "metric alert rules"},
{[]string{"metricsource"}, "Subcommands:"},
{[]string{"metricsource", "help"}, "Prometheus scrape sources"},
{[]string{"metricsource", "-h"}, "Prometheus scrape sources"},
}
for _, tc := range cases {
if err := tmp.Truncate(0); err != nil {
Expand Down
84 changes: 84 additions & 0 deletions internal/runner/metricsource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package runner

import (
"context"

"github.com/deploys-app/api"
)

func (rn Runner) metricSource(args ...string) error {
if len(args) == 0 || IsHelpArg(args[0]) {
return rn.groupUsage("metricsource")
}

s := rn.API.MetricSource()

var (
resp any
err error
)

f := rn.subFlagSet("metricsource", args[0])
switch args[0] {
default:
return rn.unknownSub("metricsource", args[0])

case "set":
var req api.MetricSourceSet
f.StringVar(&req.Project, "project", "", "project id")
f.StringVar(&req.Name, "name", "", "metric source name")
f.StringVar(&req.Location, "location", "", "location")
f.StringVar(&req.Deployment, "deployment", "", "deployment to scrape")
f.IntVar(&req.Port, "port", 0, "scrape port")
f.StringVar(&req.Path, "path", "/metrics", "scrape path (must start with /)")
f.BoolVar(&req.Disabled, "disabled", false, "disable scraping")
f.Parse(args[1:])
resp, err = s.Set(context.Background(), &req)

case "get":
var req api.MetricSourceGet
f.StringVar(&req.Project, "project", "", "project id")
f.StringVar(&req.Name, "name", "", "metric source name")
f.Parse(args[1:])
resp, err = s.Get(context.Background(), &req)

case "list":
var req api.MetricSourceList
f.StringVar(&req.Project, "project", "", "project id")
f.Parse(args[1:])
resp, err = s.List(context.Background(), &req)

case "delete":
var req api.MetricSourceDelete
f.StringVar(&req.Project, "project", "", "project id")
f.StringVar(&req.Name, "name", "", "metric source name")
f.Parse(args[1:])
resp, err = s.Delete(context.Background(), &req)

case "series":
var req api.MetricSourceSeries
f.StringVar(&req.Project, "project", "", "project id")
f.StringVar(&req.Name, "name", "", "metric source name")
f.Parse(args[1:])
resp, err = s.Series(context.Background(), &req)

case "query":
var (
req api.MetricSourceQuery
series multiFlag
timeRange string
)
f.StringVar(&req.Project, "project", "", "project id")
f.StringVar(&req.Name, "name", "", "metric source name")
f.Var(&series, "series", "series key (repeatable; omit to let the server pick)")
f.StringVar(&timeRange, "timerange", "1h", "time range (1h, 6h, 12h, 1d, 7d, 30d)")
f.Parse(args[1:])
req.Series = series
req.TimeRange = timeRange
resp, err = s.Query(context.Background(), &req)
}
if err != nil {
return err
}
return rn.print(resp)
}
34 changes: 34 additions & 0 deletions internal/runner/metricsource_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package runner

import (
"strings"
"testing"
)

func TestMetricSourceHelp(t *testing.T) {
c := lookupCommand("metricsource")
if c == nil {
t.Fatal("metricsource group missing")
}
for _, name := range []string{"set", "get", "list", "delete", "series", "query"} {
if c.lookupSub(name) == nil {
t.Errorf("metricsource missing subcommand %q", name)
}
}
query := c.lookupSub("query")
if query == nil {
t.Fatal("metricsource query missing")
}
if !strings.Contains(query.args, "timerange") {
t.Errorf("query args %q missing timerange", query.args)
}
set := c.lookupSub("set")
if set == nil {
t.Fatal("metricsource set missing")
}
for _, want := range []string{"location", "deployment", "port", "path"} {
if !strings.Contains(set.args, want) {
t.Errorf("set args %q missing %q", set.args, want)
}
}
}
2 changes: 2 additions & 0 deletions internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ func (rn Runner) Run(args ...string) error {
return rn.scheduler(args[1:]...)
case "notification":
return rn.notification(args[1:]...)
case "metricsource":
return rn.metricSource(args[1:]...)
case "alert":
return rn.alert(args[1:]...)
case "check-update":
Expand Down