diff --git a/alert.go b/alert.go index 47569c9..d090197 100644 --- a/alert.go +++ b/alert.go @@ -15,22 +15,24 @@ import ( // Alert manages a project's metric alert rules: "when deployment CPU >= 90% for // 10 minutes, notify." A rule is a single condition on one metric of one -// deployment; the target carries the location (like Notification carries its -// delivery config), so a rule is addressed by (project, name) like an env group -// or a scheduler job — location-less at the resource level, location-bound only -// inside Target. +// target — either a platform deployment metric (kind=deployment, the default +// when Kind is empty) or a custom metric-source series (kind=custom). The +// resource is addressed by (project, name) like an env group or a scheduler +// job — location-less at the resource level. For kind=deployment, location +// lives on Target; for kind=custom, location lives on the metric source. // // Rules are evaluated by an apiserver cron tick (outside this package) against -// the existing per-minute deployment_usages table; there is no separate metrics -// backend for v1. Evaluation is stateless per tick over a rolling window of the -// last Condition.ForMinutes buckets, and produces one of three states: "ok" -// (condition not met), "firing" (condition held for the full window), or -// "nodata" (too few buckets present — deployment paused/deleted, or no limit -// set for a percent metric). State transitions (ok/nodata -> firing, firing -> -// ok) enqueue "alert.trigger"/"alert.resolve" notification events (see -// Notification); a still-firing rule re-notifies every RenotifyMinutes when -// set. Notification delivery reuses the notification-channels feature -// entirely — a rule carries no delivery config of its own. +// the existing per-minute deployment_usages table (kind=deployment) or +// custom_usages (kind=custom). Evaluation is stateless per tick over a rolling +// window of the last Condition.ForMinutes buckets, and produces one of three +// states: "ok" (condition not met), "firing" (condition held for the full +// window), or "nodata" (too few buckets present — deployment paused/deleted, +// source gone, or no limit set for a percent metric). State transitions +// (ok/nodata -> firing, firing -> ok) enqueue "alert.trigger"/"alert.resolve" +// notification events (see Notification); a still-firing rule re-notifies +// every RenotifyMinutes when set. Notification delivery reuses the +// notification-channels feature entirely — a rule carries no delivery config +// of its own. // // Rule config changes (Create/Update/Delete) go through the normal audit/change // path like every other resource, so a channel subscribed to "alert.*" also @@ -38,10 +40,10 @@ import ( // transitions themselves are evaluator telemetry, not user actions, and are not // audited (mirrors deployment.health). // -// Existence of Target.Deployment is checked at Create/Update time but a rule is -// not FK-bound to it: deleting and recreating the deployment keeps the rule, -// which simply reports "nodata" while the deployment is gone (matches how -// routes behave). +// Existence of Target.Deployment (kind=deployment) or Target.Source +// (kind=custom) is checked at Create/Update time but a rule is not FK-bound +// to it: deleting the target keeps the rule, which simply reports "nodata" +// while the target is gone (matches how routes behave). type Alert interface { // Create requires the `alert.create` permission. Create(ctx context.Context, m *AlertCreate) (*Empty, error) @@ -59,22 +61,31 @@ type Alert interface { Events(ctx context.Context, m *AlertEvents) (*AlertEventsResult, error) } -// AlertTarget identifies what a rule watches. Location is required in v1 -// (kind=deployment is implicit; Phase 2 adds a Kind field for custom-metric -// targets, which is why Condition/Target are kept flat and additive rather than -// nested further). +// AlertTarget identifies what a rule watches. Empty Kind is treated as +// deployment (clients need not send "deployment"). kind=custom targets a +// metricSource series; location lives on the source, so Location and +// Deployment must be empty. type AlertTarget struct { - Location string `json:"location" yaml:"location"` - Deployment string `json:"deployment" yaml:"deployment"` + Kind string `json:"kind" yaml:"kind"` // "" or "deployment" or "custom" + Location string `json:"location" yaml:"location"` // kind=deployment + Deployment string `json:"deployment" yaml:"deployment"` // kind=deployment + Source string `json:"source" yaml:"source"` // kind=custom: metricSource name + Series string `json:"series" yaml:"series"` // kind=custom: exact series key } +const ( + AlertTargetKindDeployment = "deployment" // default when Kind is empty + AlertTargetKindCustom = "custom" +) + // AlertCondition is the single metric condition a rule evaluates. Op defaults // to ">=" when left empty. Threshold's unit depends on Metric (see -// AlertMetrics): percent 0-100 for cpu/memory (usage as a share of the -// deployment's limit, allowed above 100% since limits can be briefly -// overcommitted), req/min for requests, or bytes/min for egress. ForMinutes is -// how long the condition must hold continuously, evaluated as a rolling -// window (1..60 minutes). +// AlertMetrics / AlertCustomMetrics): percent 0-100 for cpu/memory (usage as +// a share of the deployment's limit, allowed above 100% since limits can be +// briefly overcommitted), req/min for requests, bytes/min for egress, the +// gauge value for kind=custom Metric=value, or per-minute increase for +// kind=custom Metric=rate. ForMinutes is how long the condition must hold +// continuously, evaluated as a rolling window (1..60 minutes). type AlertCondition struct { Metric string `json:"metric" yaml:"metric"` Op string `json:"op" yaml:"op"` // ">=" or "<="; default ">=" on empty @@ -82,8 +93,8 @@ type AlertCondition struct { ForMinutes int `json:"forMinutes" yaml:"forMinutes"` } -// Metric vocabulary (v1). See the SPEC for the backing deployment_usages series -// and bucket aggregation each metric uses. +// Platform metric vocabulary (kind=deployment). See the SPEC for the backing +// deployment_usages series and bucket aggregation each metric uses. const ( AlertMetricCPU = "cpu" // % of limit, avg across pods AlertMetricMemory = "memory" // % of limit, avg across pods @@ -91,6 +102,12 @@ const ( AlertMetricEgress = "egress" // bytes/min, summed across pods ) +// Custom metric vocabulary (kind=custom). +const ( + AlertMetricValue = "value" // gauge, kind=custom + AlertMetricRate = "rate" // counter per-minute, kind=custom +) + var alertMetrics = []string{ AlertMetricCPU, AlertMetricMemory, @@ -98,13 +115,25 @@ var alertMetrics = []string{ AlertMetricEgress, } -// AlertMetrics returns the v1 metric vocabulary a Condition.Metric may target — -// the discovery list for a rule-creation UI (mirrors NotificationEvents). The -// returned slice is a copy. +var alertCustomMetrics = []string{ + AlertMetricValue, + AlertMetricRate, +} + +// AlertMetrics returns the platform (kind=deployment) metric vocabulary a +// Condition.Metric may target — the discovery list for a rule-creation UI +// (mirrors NotificationEvents). The returned slice is a copy. func AlertMetrics() []string { return slices.Clone(alertMetrics) } +// AlertCustomMetrics returns the kind=custom metric vocabulary a +// Condition.Metric may target (value = gauge, rate = counter per-minute). +// The returned slice is a copy. +func AlertCustomMetrics() []string { + return slices.Clone(alertCustomMetrics) +} + func alertMetricIsPercent(metric string) bool { return metric == AlertMetricCPU || metric == AlertMetricMemory } @@ -138,18 +167,43 @@ func validAlertName(v *validator.Validator, name string) { v.Mustf(cnt >= MinNameLength && cnt <= MaxNameLength, "name must have length between %d-%d characters", MinNameLength, MaxNameLength) } +func alertTargetKind(kind string) string { + return cmp.Or(kind, AlertTargetKindDeployment) +} + // validAlertTarget checks Target's shape only; whether Location/Deployment -// actually resolve to an existing deployment is a server-side lookup (see the -// Alert doc comment), not client-side validation. +// (or Source) actually resolve is a server-side lookup (see the Alert doc +// comment), not client-side validation. Empty Kind is treated as deployment +// and is not rewritten on the struct. func validAlertTarget(v *validator.Validator, t AlertTarget) { - v.Must(t.Location != "", "target.location required") - v.Must(ReValidName.MatchString(t.Deployment), "target.deployment invalid: "+ReValidNameDesc) - cnt := utf8.RuneCountInString(t.Deployment) - v.Mustf(cnt >= MinNameLength && cnt <= DeploymentMaxNameLength, "target.deployment must have length between %d-%d characters", MinNameLength, DeploymentMaxNameLength) + switch alertTargetKind(t.Kind) { + case AlertTargetKindDeployment: + v.Must(t.Location != "", "target.location required") + v.Must(ReValidName.MatchString(t.Deployment), "target.deployment invalid: "+ReValidNameDesc) + cnt := utf8.RuneCountInString(t.Deployment) + v.Mustf(cnt >= MinNameLength && cnt <= DeploymentMaxNameLength, "target.deployment must have length between %d-%d characters", MinNameLength, DeploymentMaxNameLength) + v.Must(t.Source == "", "target.source is only valid for kind=custom") + v.Must(t.Series == "", "target.series is only valid for kind=custom") + case AlertTargetKindCustom: + v.Must(t.Location == "", "target.location is only valid for kind=deployment") + v.Must(t.Deployment == "", "target.deployment is only valid for kind=deployment") + v.Must(ReValidName.MatchString(t.Source), "target.source invalid: "+ReValidNameDesc) + cnt := utf8.RuneCountInString(t.Source) + v.Mustf(cnt >= MinNameLength && cnt <= MaxNameLength, "target.source must have length between %d-%d characters", MinNameLength, MaxNameLength) + v.Must(t.Series != "", "target.series required") + v.Mustf(utf8.RuneCountInString(t.Series) <= MetricSourceMaxSeriesKey, "target.series must not exceed %d characters", MetricSourceMaxSeriesKey) + default: + v.Must(false, "target.kind invalid (want deployment or custom)") + } } -func validAlertCondition(v *validator.Validator, c AlertCondition) { - v.Must(slices.Contains(alertMetrics, c.Metric), "condition.metric invalid (want cpu, memory, requests, or egress)") +func validAlertCondition(v *validator.Validator, kind string, c AlertCondition) { + switch alertTargetKind(kind) { + case AlertTargetKindCustom: + v.Must(slices.Contains(alertCustomMetrics, c.Metric), "condition.metric invalid (want value or rate)") + default: + v.Must(slices.Contains(alertMetrics, c.Metric), "condition.metric invalid (want cpu, memory, requests, or egress)") + } v.Must(c.Op == AlertOpGTE || c.Op == AlertOpLTE, "condition.op invalid (want >= or <=)") v.Must(c.Threshold > 0, "condition.threshold must be greater than 0") v.Must(!math.IsInf(c.Threshold, 0), "condition.threshold must be finite") @@ -179,8 +233,11 @@ type AlertCreate struct { func (m *AlertCreate) Valid() error { m.Name = strings.TrimSpace(m.Name) + m.Target.Kind = strings.TrimSpace(m.Target.Kind) m.Target.Location = strings.TrimSpace(m.Target.Location) m.Target.Deployment = strings.TrimSpace(m.Target.Deployment) + m.Target.Source = strings.TrimSpace(m.Target.Source) + m.Target.Series = strings.TrimSpace(m.Target.Series) m.Condition.Metric = strings.TrimSpace(m.Condition.Metric) m.Condition.Op = cmp.Or(strings.TrimSpace(m.Condition.Op), AlertOpGTE) @@ -188,7 +245,7 @@ func (m *AlertCreate) Valid() error { v.Must(m.Project != "", "project required") validAlertName(v, m.Name) validAlertTarget(v, m.Target) - validAlertCondition(v, m.Condition) + validAlertCondition(v, m.Target.Kind, m.Condition) validAlertRenotifyMinutes(v, m.RenotifyMinutes) return WrapValidate(v) @@ -209,8 +266,11 @@ type AlertUpdate struct { func (m *AlertUpdate) Valid() error { m.Name = strings.TrimSpace(m.Name) + m.Target.Kind = strings.TrimSpace(m.Target.Kind) m.Target.Location = strings.TrimSpace(m.Target.Location) m.Target.Deployment = strings.TrimSpace(m.Target.Deployment) + m.Target.Source = strings.TrimSpace(m.Target.Source) + m.Target.Series = strings.TrimSpace(m.Target.Series) m.Condition.Metric = strings.TrimSpace(m.Condition.Metric) m.Condition.Op = cmp.Or(strings.TrimSpace(m.Condition.Op), AlertOpGTE) @@ -218,7 +278,7 @@ func (m *AlertUpdate) Valid() error { v.Must(m.Project != "", "project required") validAlertName(v, m.Name) validAlertTarget(v, m.Target) - validAlertCondition(v, m.Condition) + validAlertCondition(v, m.Target.Kind, m.Condition) validAlertRenotifyMinutes(v, m.RenotifyMinutes) return WrapValidate(v) @@ -305,6 +365,9 @@ type AlertItem struct { } func alertTargetString(t AlertTarget) string { + if alertTargetKind(t.Kind) == AlertTargetKindCustom { + return "custom/" + t.Source + "/" + t.Series + } return t.Location + "/" + t.Deployment } diff --git a/alert_test.go b/alert_test.go index e8f42ac..4d79314 100644 --- a/alert_test.go +++ b/alert_test.go @@ -25,6 +25,23 @@ func TestAlertMetrics(t *testing.T) { } } +func TestAlertCustomMetrics(t *testing.T) { + metrics := AlertCustomMetrics() + if len(metrics) == 0 { + t.Fatal("the custom metric vocabulary must not be empty") + } + metrics[0] = "mutated" + if AlertCustomMetrics()[0] == "mutated" { + t.Fatal("AlertCustomMetrics must return a copy") + } + + for _, want := range []string{AlertMetricValue, AlertMetricRate} { + if !slices.Contains(AlertCustomMetrics(), want) { + t.Fatalf("custom vocabulary is missing %q", want) + } + } +} + func validAlertCreate() *AlertCreate { return &AlertCreate{ Project: "p", @@ -40,9 +57,19 @@ func validAlertCreate() *AlertCreate { } func TestAlertCreateValid(t *testing.T) { - if err := validAlertCreate().Valid(); err != nil { + ok := validAlertCreate() + if err := ok.Valid(); err != nil { t.Fatalf("a valid create was rejected: %v", err) } + if ok.Target.Kind != "" { + t.Fatalf("Valid must not rewrite empty Kind, got %q", ok.Target.Kind) + } + + explicit := validAlertCreate() + explicit.Target.Kind = AlertTargetKindDeployment + if err := explicit.Valid(); err != nil { + t.Fatalf("kind=deployment was rejected: %v", err) + } // op defaults to >= when left empty. m := validAlertCreate() @@ -105,6 +132,8 @@ func TestAlertCreateValid(t *testing.T) { m.Condition.Metric = AlertMetricRequests m.Condition.Threshold = math.Inf(1) }, "must be finite"}, + {"deployment with source", func(m *AlertCreate) { m.Target.Source = "web" }, "target.source"}, + {"deployment metric value", func(m *AlertCreate) { m.Condition.Metric = AlertMetricValue }, "condition.metric invalid"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -132,6 +161,78 @@ func TestAlertCreateNonPercentMetricAllowsLargeThreshold(t *testing.T) { } } +func validAlertCreateCustom() *AlertCreate { + return &AlertCreate{ + Project: "p", + Name: "queue-hot", + Target: AlertTarget{ + Kind: AlertTargetKindCustom, + Source: "web", + Series: `queue_depth{queue="email"}`, + }, + Condition: AlertCondition{ + Metric: AlertMetricValue, + Op: AlertOpGTE, + Threshold: 100, + ForMinutes: 10, + }, + } +} + +func TestAlertCreateCustomValid(t *testing.T) { + if err := validAlertCreateCustom().Valid(); err != nil { + t.Fatalf("a valid custom create was rejected: %v", err) + } + + rate := validAlertCreateCustom() + rate.Condition.Metric = AlertMetricRate + if err := rate.Valid(); err != nil { + t.Fatalf("custom rate was rejected: %v", err) + } + + large := validAlertCreateCustom() + large.Condition.Threshold = AlertPercentThresholdMax + 1 + if err := large.Valid(); err != nil { + t.Fatalf("a large custom value threshold was rejected: %v", err) + } + + cases := []struct { + name string + mutate func(*AlertCreate) + want string + }{ + {"custom with deployment", func(m *AlertCreate) { m.Target.Deployment = "web" }, "target.deployment"}, + {"custom with location", func(m *AlertCreate) { m.Target.Location = "gke.cluster-rcf2" }, "target.location"}, + {"custom metric cpu", func(m *AlertCreate) { m.Condition.Metric = AlertMetricCPU }, "condition.metric invalid"}, + {"custom missing series", func(m *AlertCreate) { m.Target.Series = "" }, "target.series required"}, + {"custom missing source", func(m *AlertCreate) { m.Target.Source = "" }, "target.source invalid"}, + {"unknown kind", func(m *AlertCreate) { m.Target.Kind = "disk" }, "target.kind invalid"}, + {"infinite threshold", func(m *AlertCreate) { m.Condition.Threshold = math.Inf(1) }, "must be finite"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := validAlertCreateCustom() + tc.mutate(m) + err := m.Valid() + if err == nil { + t.Fatalf("expected a validation error for %s, got nil", tc.name) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected error to contain %q, got: %v", tc.want, err) + } + }) + } +} + +func TestAlertTargetString(t *testing.T) { + if got := alertTargetString(AlertTarget{Location: "loc", Deployment: "web"}); got != "loc/web" { + t.Fatalf("deployment target = %q, want loc/web", got) + } + if got := alertTargetString(AlertTarget{Kind: AlertTargetKindCustom, Source: "web", Series: "queue_depth"}); got != "custom/web/queue_depth" { + t.Fatalf("custom target = %q, want custom/web/queue_depth", got) + } +} + func TestAlertUpdateValid(t *testing.T) { m := &AlertUpdate{ Project: "p", @@ -154,6 +255,24 @@ func TestAlertUpdateValid(t *testing.T) { if err := bad.Valid(); err == nil { t.Fatal("expected an incomplete update to be rejected") } + + custom := &AlertUpdate{ + Project: "p", + Name: "queue-hot", + Target: AlertTarget{ + Kind: AlertTargetKindCustom, + Source: "web", + Series: "queue_depth", + }, + Condition: AlertCondition{ + Metric: AlertMetricValue, + Threshold: 10, + ForMinutes: 5, + }, + } + if err := custom.Valid(); err != nil { + t.Fatalf("a valid custom update was rejected: %v", err) + } } func TestAlertGetDeleteValid(t *testing.T) { diff --git a/api.go b/api.go index 9845366..bde706e 100644 --- a/api.go +++ b/api.go @@ -30,4 +30,5 @@ type Interface interface { Scheduler() Scheduler Notification() Notification Alert() Alert + MetricSource() MetricSource } diff --git a/client/client.go b/client/client.go index 01cf5f0..edcf6e2 100644 --- a/client/client.go +++ b/client/client.go @@ -183,6 +183,10 @@ func (c *Client) Alert() api.Alert { return alertClient{c} } +func (c *Client) MetricSource() api.MetricSource { + return metricSourceClient{c} +} + func (c *Client) invoke(ctx context.Context, api string, r any, res any) error { if err := validRequest(r); err != nil { return err diff --git a/client/collector.go b/client/collector.go index eccaa1d..4126fd6 100644 --- a/client/collector.go +++ b/client/collector.go @@ -81,3 +81,21 @@ func (c collectorClient) SetCacheResultUsage(ctx context.Context, m *api.Collect } return &res, nil } + +func (c collectorClient) ListMetricSources(ctx context.Context, m *api.CollectorListMetricSources) (*api.CollectorListMetricSourcesResult, error) { + var res api.CollectorListMetricSourcesResult + err := c.inv.invoke(ctx, "collector.listMetricSources", m, &res) + if err != nil { + return nil, err + } + return &res, nil +} + +func (c collectorClient) SetCustomUsage(ctx context.Context, m *api.CollectorSetCustomUsage) (*api.Empty, error) { + var res api.Empty + err := c.inv.invoke(ctx, "collector.setCustomUsage", m, &res) + if err != nil { + return nil, err + } + return &res, nil +} diff --git a/client/metricsource.go b/client/metricsource.go new file mode 100644 index 0000000..4860648 --- /dev/null +++ b/client/metricsource.go @@ -0,0 +1,59 @@ +package client + +import ( + "context" + + "github.com/deploys-app/api" +) + +type metricSourceClient struct { + inv invoker +} + +func (c metricSourceClient) Set(ctx context.Context, m *api.MetricSourceSet) (*api.Empty, error) { + var res api.Empty + if err := c.inv.invoke(ctx, "metricSource.set", m, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c metricSourceClient) Get(ctx context.Context, m *api.MetricSourceGet) (*api.MetricSourceItem, error) { + var res api.MetricSourceItem + if err := c.inv.invoke(ctx, "metricSource.get", m, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c metricSourceClient) List(ctx context.Context, m *api.MetricSourceList) (*api.MetricSourceListResult, error) { + var res api.MetricSourceListResult + if err := c.inv.invoke(ctx, "metricSource.list", m, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c metricSourceClient) Delete(ctx context.Context, m *api.MetricSourceDelete) (*api.Empty, error) { + var res api.Empty + if err := c.inv.invoke(ctx, "metricSource.delete", m, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c metricSourceClient) Series(ctx context.Context, m *api.MetricSourceSeries) (*api.MetricSourceSeriesResult, error) { + var res api.MetricSourceSeriesResult + if err := c.inv.invoke(ctx, "metricSource.series", m, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c metricSourceClient) Query(ctx context.Context, m *api.MetricSourceQuery) (*api.MetricSourceQueryResult, error) { + var res api.MetricSourceQueryResult + if err := c.inv.invoke(ctx, "metricSource.query", m, &res); err != nil { + return nil, err + } + return &res, nil +} diff --git a/collector.go b/collector.go index ce4a9e4..e991381 100644 --- a/collector.go +++ b/collector.go @@ -2,6 +2,8 @@ package api import ( "context" + + "github.com/moonrhythm/validator" ) type Collector interface { @@ -21,6 +23,10 @@ type Collector interface { SetCacheOverrideUsage(ctx context.Context, m *CollectorSetCacheOverrideUsage) (*Empty, error) // SetCacheResultUsage requires the location's collector token (internal endpoint authenticated by the per-location collector_token, not a user permission). SetCacheResultUsage(ctx context.Context, m *CollectorSetCacheResultUsage) (*Empty, error) + // ListMetricSources requires the location's collector token (internal endpoint authenticated by the per-location collector_token, not a user permission). + ListMetricSources(ctx context.Context, m *CollectorListMetricSources) (*CollectorListMetricSourcesResult, error) + // SetCustomUsage requires the location's collector token (internal endpoint authenticated by the per-location collector_token, not a user permission). + SetCustomUsage(ctx context.Context, m *CollectorSetCustomUsage) (*Empty, error) } type CollectorLocation struct { @@ -172,3 +178,61 @@ type CollectorCacheResultUsageItem struct { Bytes float64 `json:"bytes" yaml:"bytes"` // bytes served in the window At int64 `json:"at" yaml:"at"` // unix second, minute-aligned bucket } + +// CollectorListMetricSources lists enabled custom-metric scrape sources for a +// location. The request has no URL field: scrape URLs are filled by apiserver +// from (deployment, port, path); callers cannot supply a scrape URL. +type CollectorListMetricSources struct { + Location string `json:"location" yaml:"location"` +} + +func (m *CollectorListMetricSources) Valid() error { + v := validator.New() + v.Must(m.Location != "", "location required") + return WrapValidate(v) +} + +type CollectorListMetricSourcesResult struct { + Items []*CollectorMetricSource `json:"items" yaml:"items"` +} + +// CollectorMetricSource is one scrape target. URL is filled by apiserver from +// (deployment, port, path); callers cannot supply a scrape URL. The resolved +// form is http://.:port/path. +type CollectorMetricSource struct { + ProjectID int64 `json:"projectId,string" yaml:"projectId"` + SourceID int64 `json:"sourceId,string" yaml:"sourceId"` + Name string `json:"name" yaml:"name"` + URL string `json:"url" yaml:"url"` +} + +type CollectorSetCustomUsage struct { + Location string `json:"location" yaml:"location"` + List []*CollectorCustomUsageItem `json:"list" yaml:"list"` + // SourceID identifies the source when List is empty (a scrape error or a + // successful scrape with zero samples). When List is non-empty, each item + // carries its own SourceID. + SourceID int64 `json:"sourceId,string" yaml:"sourceId"` + // Truncated is set by the collector when this scrape hit MetricSourceMaxSeries + // and extra series were dropped. Apiserver stores it on the source so the + // console can show a banner; the list itself is already capped. + Truncated bool `json:"truncated" yaml:"truncated"` + // LastError is a scrape failure message. Empty on a successful scrape + // (including zero samples). Apiserver stores it on the source. + LastError string `json:"lastError" yaml:"lastError"` +} + +func (m *CollectorSetCustomUsage) Valid() error { + v := validator.New() + v.Must(m.Location != "", "location required") + return WrapValidate(v) +} + +type CollectorCustomUsageItem struct { + ProjectID int64 `json:"projectId,string" yaml:"projectId"` + SourceID int64 `json:"sourceId,string" yaml:"sourceId"` + Series string `json:"series" yaml:"series"` // name{sortedLabels} + Type string `json:"type" yaml:"type"` // gauge|counter|untyped; empty → untyped + Value float64 `json:"value" yaml:"value"` + At int64 `json:"at" yaml:"at"` +} diff --git a/collector_test.go b/collector_test.go new file mode 100644 index 0000000..1fbb59c --- /dev/null +++ b/collector_test.go @@ -0,0 +1,67 @@ +package api + +import ( + "reflect" + "strings" + "testing" +) + +func TestCollectorListMetricSourcesHasNoURLField(t *testing.T) { + tpe := reflect.TypeFor[CollectorListMetricSources]() + for i := range tpe.NumField() { + f := tpe.Field(i) + name := strings.ToLower(f.Name) + jsonTag := strings.ToLower(f.Tag.Get("json")) + yamlTag := strings.ToLower(f.Tag.Get("yaml")) + if strings.Contains(name, "url") || strings.HasPrefix(jsonTag, "url") || strings.HasPrefix(yamlTag, "url") { + t.Fatalf("CollectorListMetricSources must not have a URL field (SSRF bound); found %s %s", f.Name, f.Tag) + } + } +} + +func TestCollectorListMetricSourcesValid(t *testing.T) { + m := &CollectorListMetricSources{} + err := m.Valid() + if err == nil || !strings.Contains(err.Error(), "location required") { + t.Fatalf("expected location required, got: %v", err) + } + if strings.Contains(strings.ToLower(err.Error()), "url") { + t.Fatalf("Valid must not mention url, got: %v", err) + } + + m.Location = "gke.cluster-rcf2" + if err := m.Valid(); err != nil { + t.Fatalf("valid request rejected: %v", err) + } +} + +func TestCollectorSetCustomUsageValid(t *testing.T) { + empty := &CollectorSetCustomUsage{Location: "gke.cluster-rcf2"} + if err := empty.Valid(); err != nil { + t.Fatalf("empty list was rejected: %v", err) + } + + withItems := &CollectorSetCustomUsage{ + Location: "gke.cluster-rcf2", + List: []*CollectorCustomUsageItem{ + {ProjectID: 1, SourceID: 2, Series: "jobs_total", Type: MetricSourceSeriesTypeCounter, Value: 3, At: 4}, + }, + } + if err := withItems.Valid(); err != nil { + t.Fatalf("valid setCustomUsage was rejected: %v", err) + } + + missing := &CollectorSetCustomUsage{} + if err := missing.Valid(); err == nil || !strings.Contains(err.Error(), "location required") { + t.Fatalf("expected location required, got: %v", err) + } + + flagged := &CollectorSetCustomUsage{ + Location: "gke.cluster-rcf2", + Truncated: true, + LastError: "timeout", + } + if err := flagged.Valid(); err != nil { + t.Fatalf("truncated/error report was rejected: %v", err) + } +} diff --git a/constraint.go b/constraint.go index 9383722..60e0baf 100644 --- a/constraint.go +++ b/constraint.go @@ -234,3 +234,27 @@ const ( AlertEventsDefaultLimit = 50 AlertEventsMaxLimit = 100 ) + +// MetricSource (Prometheus scrape sources on a project's own deployments) +const ( + // MetricSourceMaxPerProject caps how many scrape sources a project may + // define; server-enforced at Set of a new name. + MetricSourceMaxPerProject = 4 + + // MetricSourceMaxSeries caps how many series are stored per source; + // ingest drops new series beyond this and marks the source truncated. + MetricSourceMaxSeries = 100 + + // MetricSourceMaxPath caps MetricSourceSet.Path. + MetricSourceMaxPath = 256 + + // MetricSourceMaxSeriesKey caps a series identity string (name{sortedLabels}). + MetricSourceMaxSeriesKey = 512 + + // MetricSourceScrapeTimeout is the collector scrape timeout; documented + // here so apiserver and collector share the bound. + MetricSourceScrapeTimeout = 5 * time.Second + + // MetricSourceMaxBodyBytes caps the scrape response body (1 MiB). + MetricSourceMaxBodyBytes = 1 << 20 +) diff --git a/errors.go b/errors.go index 80b0c4a..d5743e1 100644 --- a/errors.go +++ b/errors.go @@ -101,6 +101,8 @@ var ( ErrAlertNotFound = newError("api: alert not found") ErrAlertAlreadyExists = newError("api: alert already exists") ErrMaximumAlertRulesReached = newError("api: maximum alert rules reached") + ErrMetricSourceNotFound = newError("api: metric source not found") + ErrMaximumMetricSourcesReached = newError("api: maximum metric sources reached") ) var AllErrors []error diff --git a/metricsource.go b/metricsource.go new file mode 100644 index 0000000..07461f4 --- /dev/null +++ b/metricsource.go @@ -0,0 +1,293 @@ +package api + +import ( + "cmp" + "context" + "net/url" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/moonrhythm/validator" +) + +// MetricSource manages a project's Prometheus scrape sources: the platform +// scrapes a path on the project's own deployment (port+path, never a free-form +// URL) once a minute from the in-cluster collector, stores a capped set of +// series, and serves them for charts and custom alert rules. +// +// A source is project-scoped and addressed by (project, name); Location lives +// in the config (like Alert). Set is a full upsert (cache.set style): the first +// Set of a name creates the source, subsequent Sets of the same name replace +// the config. The per-project cap (MetricSourceMaxPerProject) is server-enforced +// when creating a new name — Valid() does not count existing sources. +// +// The scrape target is (Deployment, Port, Path) resolved by apiserver to +// http://.:port/path. There is no URL field on this +// resource; Path must be a path (leading slash, no host, no ://). That is the +// v1 SSRF bound. +type MetricSource interface { + // Set upserts a scrape source. Requires the `metricSource.set` permission. + Set(ctx context.Context, m *MetricSourceSet) (*Empty, error) + // Get requires the `metricSource.get` permission. + Get(ctx context.Context, m *MetricSourceGet) (*MetricSourceItem, error) + // List requires the `metricSource.list` permission. + List(ctx context.Context, m *MetricSourceList) (*MetricSourceListResult, error) + // Delete requires the `metricSource.delete` permission. + Delete(ctx context.Context, m *MetricSourceDelete) (*Empty, error) + // Series lists discovered series for a source (name{sortedLabels}, type, + // last seen). Requires the `metricSource.get` permission. + Series(ctx context.Context, m *MetricSourceSeries) (*MetricSourceSeriesResult, error) + // Query returns chart data in the DeploymentMetricsLine shape. Requires + // the `metricSource.get` permission. + Query(ctx context.Context, m *MetricSourceQuery) (*MetricSourceQueryResult, error) +} + +// Discovered series types stored for a scrape source. Histograms/summaries are +// not kept in v1 (only gauge, counter, and untyped). +const ( + MetricSourceSeriesTypeGauge = "gauge" + MetricSourceSeriesTypeCounter = "counter" + MetricSourceSeriesTypeUntyped = "untyped" +) + +// MetricSourceSeriesType returns a stored series type, or untyped for empty/unknown. +func MetricSourceSeriesType(s string) string { + switch s { + case MetricSourceSeriesTypeGauge, MetricSourceSeriesTypeCounter, MetricSourceSeriesTypeUntyped: + return s + default: + return MetricSourceSeriesTypeUntyped + } +} + +// Query time-range vocabulary: the waf/cache short windows (not deployment +// 1hagg). Required on MetricSourceQuery. +const ( + MetricSourceQueryTimeRange1h = "1h" + MetricSourceQueryTimeRange6h = "6h" + MetricSourceQueryTimeRange12h = "12h" + MetricSourceQueryTimeRange1d = "1d" + MetricSourceQueryTimeRange7d = "7d" + MetricSourceQueryTimeRange30d = "30d" +) + +var validMetricSourceQueryTimeRange = map[string]bool{ + MetricSourceQueryTimeRange1h: true, + MetricSourceQueryTimeRange6h: true, + MetricSourceQueryTimeRange12h: true, + MetricSourceQueryTimeRange1d: true, + MetricSourceQueryTimeRange7d: true, + MetricSourceQueryTimeRange30d: true, +} + +func validMetricSourceName(v *validator.Validator, name string) { + v.Must(ReValidName.MatchString(name), "name invalid: "+ReValidNameDesc) + cnt := utf8.RuneCountInString(name) + v.Mustf(cnt >= MinNameLength && cnt <= MaxNameLength, "name must have length between %d-%d characters", MinNameLength, MaxNameLength) +} + +func validMetricSourceDeployment(v *validator.Validator, name string) { + v.Must(ReValidName.MatchString(name), "deployment invalid: "+ReValidNameDesc) + cnt := utf8.RuneCountInString(name) + v.Mustf(cnt >= MinNameLength && cnt <= DeploymentMaxNameLength, "deployment must have length between %d-%d characters", MinNameLength, DeploymentMaxNameLength) +} + +// validMetricSourcePath is the SSRF bound: Path is a path, never a URL. +func validMetricSourcePath(v *validator.Validator, path string) { + v.Must(!strings.Contains(path, "://"), "path must not contain a URL") + v.Must(strings.HasPrefix(path, "/"), "path must start with /") + cnt := utf8.RuneCountInString(path) + v.Mustf(cnt <= MetricSourceMaxPath, "path must not exceed %d characters", MetricSourceMaxPath) + u, err := url.Parse(path) + if err != nil { + v.Must(false, "path invalid") + return + } + v.Must(u.Scheme == "" && u.Host == "", "path must not contain a host") +} + +// MetricSourceSet upserts a scrape source. Path defaults to "/metrics" when +// empty. There is no URL field — the platform resolves (deployment, port, path) +// to the in-cluster scrape URL. +type MetricSourceSet struct { + Project string `json:"project" yaml:"project"` + Name string `json:"name" yaml:"name"` + Location string `json:"location" yaml:"location"` + Deployment string `json:"deployment" yaml:"deployment"` + Port int `json:"port" yaml:"port"` + Path string `json:"path" yaml:"path"` + Disabled bool `json:"disabled" yaml:"disabled"` +} + +func (m *MetricSourceSet) Valid() error { + m.Name = strings.TrimSpace(m.Name) + m.Location = strings.TrimSpace(m.Location) + m.Deployment = strings.TrimSpace(m.Deployment) + m.Path = cmp.Or(strings.TrimSpace(m.Path), "/metrics") + + v := validator.New() + v.Must(m.Project != "", "project required") + validMetricSourceName(v, m.Name) + v.Must(m.Location != "", "location required") + validMetricSourceDeployment(v, m.Deployment) + v.Must(m.Port >= 1 && m.Port <= 65535, "port must be between 1 and 65535") + validMetricSourcePath(v, m.Path) + + return WrapValidate(v) +} + +type MetricSourceGet struct { + Project string `json:"project" yaml:"project"` + Name string `json:"name" yaml:"name"` +} + +func (m *MetricSourceGet) Valid() error { + m.Name = strings.TrimSpace(m.Name) + v := validator.New() + v.Must(m.Project != "", "project required") + validMetricSourceName(v, m.Name) + return WrapValidate(v) +} + +type MetricSourceDelete struct { + Project string `json:"project" yaml:"project"` + Name string `json:"name" yaml:"name"` +} + +func (m *MetricSourceDelete) Valid() error { + m.Name = strings.TrimSpace(m.Name) + v := validator.New() + v.Must(m.Project != "", "project required") + validMetricSourceName(v, m.Name) + return WrapValidate(v) +} + +type MetricSourceList struct { + Project string `json:"project" yaml:"project"` +} + +func (m *MetricSourceList) Valid() error { + v := validator.New() + v.Must(m.Project != "", "project required") + return WrapValidate(v) +} + +type MetricSourceSeries struct { + Project string `json:"project" yaml:"project"` + Name string `json:"name" yaml:"name"` +} + +func (m *MetricSourceSeries) Valid() error { + m.Name = strings.TrimSpace(m.Name) + v := validator.New() + v.Must(m.Project != "", "project required") + validMetricSourceName(v, m.Name) + return WrapValidate(v) +} + +// MetricSourceQuery returns chart data for a source. Series empty means the +// server picks the top N by last-seen. TimeRange is required (1h/6h/12h/1d/7d/30d). +type MetricSourceQuery struct { + Project string `json:"project" yaml:"project"` + Name string `json:"name" yaml:"name"` + Series []string `json:"series" yaml:"series"` + TimeRange string `json:"timeRange" yaml:"timeRange"` +} + +func (m *MetricSourceQuery) Valid() error { + m.Name = strings.TrimSpace(m.Name) + v := validator.New() + v.Must(m.Project != "", "project required") + validMetricSourceName(v, m.Name) + v.Must(validMetricSourceQueryTimeRange[m.TimeRange], "timeRange invalid") + v.Mustf(len(m.Series) <= MetricSourceMaxSeries, "series must not exceed %d entries", MetricSourceMaxSeries) + for i, s := range m.Series { + m.Series[i] = strings.TrimSpace(s) + v.Must(m.Series[i] != "", "series must not be empty") + v.Mustf(utf8.RuneCountInString(m.Series[i]) <= MetricSourceMaxSeriesKey, "series must not exceed %d characters", MetricSourceMaxSeriesKey) + } + return WrapValidate(v) +} + +type MetricSourceItem struct { + Project string `json:"project" yaml:"project"` + Name string `json:"name" yaml:"name"` + Location string `json:"location" yaml:"location"` + Deployment string `json:"deployment" yaml:"deployment"` + Port int `json:"port" yaml:"port"` + Path string `json:"path" yaml:"path"` + Disabled bool `json:"disabled" yaml:"disabled"` + Truncated bool `json:"truncated" yaml:"truncated"` + LastScrapedAt *time.Time `json:"lastScrapedAt" yaml:"lastScrapedAt"` + LastError string `json:"lastError" yaml:"lastError"` + CreatedAt time.Time `json:"createdAt" yaml:"createdAt"` + CreatedBy string `json:"createdBy" yaml:"createdBy"` + UpdatedAt time.Time `json:"updatedAt" yaml:"updatedAt"` + UpdatedBy string `json:"updatedBy" yaml:"updatedBy"` +} + +func metricSourceStatus(x *MetricSourceItem) string { + if x.Disabled { + return "disabled" + } + if x.LastError != "" { + return "error" + } + if x.Truncated { + return "truncated" + } + return "ok" +} + +func metricSourceRow(x *MetricSourceItem) []string { + return []string{ + x.Name, + x.Location, + x.Deployment, + strconv.Itoa(x.Port), + x.Path, + metricSourceStatus(x), + } +} + +func (m *MetricSourceItem) Table() [][]string { + return [][]string{ + {"NAME", "LOCATION", "DEPLOYMENT", "PORT", "PATH", "STATUS"}, + metricSourceRow(m), + } +} + +type MetricSourceListResult struct { + Project string `json:"project" yaml:"project"` + Items []*MetricSourceItem `json:"items" yaml:"items"` +} + +func (m *MetricSourceListResult) Table() [][]string { + table := [][]string{ + {"NAME", "LOCATION", "DEPLOYMENT", "PORT", "PATH", "STATUS"}, + } + for _, x := range m.Items { + table = append(table, metricSourceRow(x)) + } + return table +} + +type MetricSourceSeriesItem struct { + Series string `json:"series" yaml:"series"` // name{sortedLabels} + Type string `json:"type" yaml:"type"` // gauge|counter|untyped + LastSeenAt time.Time `json:"lastSeenAt" yaml:"lastSeenAt"` +} + +type MetricSourceSeriesResult struct { + Project string `json:"project" yaml:"project"` + Name string `json:"name" yaml:"name"` + Items []*MetricSourceSeriesItem `json:"items" yaml:"items"` +} + +// MetricSourceQueryResult reuses DeploymentMetricsLine so Chart.svelte consumes +// it unmodified. +type MetricSourceQueryResult struct { + Items []*DeploymentMetricsLine `json:"items" yaml:"items"` +} diff --git a/metricsource_test.go b/metricsource_test.go new file mode 100644 index 0000000..eb9fb0e --- /dev/null +++ b/metricsource_test.go @@ -0,0 +1,270 @@ +package api + +import ( + "reflect" + "slices" + "strings" + "testing" +) + +func TestMetricSourceSeriesType(t *testing.T) { + if got := MetricSourceSeriesType(MetricSourceSeriesTypeCounter); got != MetricSourceSeriesTypeCounter { + t.Fatalf("counter = %q", got) + } + if got := MetricSourceSeriesType(""); got != MetricSourceSeriesTypeUntyped { + t.Fatalf("empty = %q, want untyped", got) + } + if got := MetricSourceSeriesType("histogram"); got != MetricSourceSeriesTypeUntyped { + t.Fatalf("unknown = %q, want untyped", got) + } +} + +func TestCollectorCustomUsageItemHasType(t *testing.T) { + f, ok := reflect.TypeFor[CollectorCustomUsageItem]().FieldByName("Type") + if !ok { + t.Fatal("CollectorCustomUsageItem.Type missing — ingest cannot persist gauge/counter") + } + if f.Type.Kind() != reflect.String { + t.Fatalf("Type field is %s, want string", f.Type) + } +} + +func validMetricSourceSet() *MetricSourceSet { + return &MetricSourceSet{ + Project: "p", + Name: "web", + Location: "gke.cluster-rcf2", + Deployment: "web", + Port: 9090, + Path: "/metrics", + } +} + +func TestMetricSourceSetValid(t *testing.T) { + if err := validMetricSourceSet().Valid(); err != nil { + t.Fatalf("a valid set was rejected: %v", err) + } + + def := validMetricSourceSet() + def.Path = "" + if err := def.Valid(); err != nil { + t.Fatalf("empty path was rejected: %v", err) + } + if def.Path != "/metrics" { + t.Fatalf("empty path must default to /metrics, got %q", def.Path) + } + + cases := []struct { + name string + mutate func(*MetricSourceSet) + want string + }{ + {"missing project", func(m *MetricSourceSet) { m.Project = "" }, "project required"}, + {"bad name", func(m *MetricSourceSet) { m.Name = "Bad Name" }, "name invalid"}, + {"missing location", func(m *MetricSourceSet) { m.Location = "" }, "location required"}, + {"bad deployment", func(m *MetricSourceSet) { m.Deployment = "Bad Name" }, "deployment invalid"}, + {"missing deployment", func(m *MetricSourceSet) { m.Deployment = "" }, "deployment invalid"}, + {"port zero", func(m *MetricSourceSet) { m.Port = 0 }, "port must be between 1 and 65535"}, + {"port too large", func(m *MetricSourceSet) { m.Port = 65536 }, "port must be between 1 and 65535"}, + {"url path", func(m *MetricSourceSet) { m.Path = "http://evil.example/metrics" }, "path"}, + {"path without leading slash", func(m *MetricSourceSet) { m.Path = "metrics" }, "path must start with /"}, + {"path with scheme", func(m *MetricSourceSet) { m.Path = "/metrics://foo" }, "path must not contain a URL"}, + {"protocol-relative path", func(m *MetricSourceSet) { m.Path = "//evil.example/metrics" }, "path must not contain a host"}, + {"path too long", func(m *MetricSourceSet) { m.Path = "/" + strings.Repeat("a", MetricSourceMaxPath) }, "path must not exceed"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := validMetricSourceSet() + tc.mutate(m) + err := m.Valid() + if err == nil { + t.Fatalf("expected a validation error for %s, got nil", tc.name) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected error to contain %q, got: %v", tc.want, err) + } + }) + } +} + +func TestMetricSourceSetHasNoURLField(t *testing.T) { + assertNoURLField(t, reflect.TypeFor[MetricSourceSet]()) + assertNoURLField(t, reflect.TypeFor[MetricSourceItem]()) + assertNoURLField(t, reflect.TypeFor[MetricSourceGet]()) + assertNoURLField(t, reflect.TypeFor[MetricSourceQuery]()) +} + +func assertNoURLField(t *testing.T, tpe reflect.Type) { + t.Helper() + for i := range tpe.NumField() { + f := tpe.Field(i) + name := strings.ToLower(f.Name) + jsonTag := strings.ToLower(f.Tag.Get("json")) + yamlTag := strings.ToLower(f.Tag.Get("yaml")) + if strings.Contains(name, "url") || strings.HasPrefix(jsonTag, "url") || strings.HasPrefix(yamlTag, "url") { + t.Fatalf("%s must not have a URL field (SSRF bound); found %s %s", tpe.Name(), f.Name, f.Tag) + } + } +} + +func TestMetricSourceGetDeleteValid(t *testing.T) { + get := &MetricSourceGet{Project: "p", Name: "web"} + if err := get.Valid(); err != nil { + t.Fatalf("valid MetricSourceGet rejected: %v", err) + } + get.Project = "" + if err := get.Valid(); err == nil || !strings.Contains(err.Error(), "project required") { + t.Fatalf("expected project required, got: %v", err) + } + + del := &MetricSourceDelete{Project: "p", Name: "web"} + if err := del.Valid(); err != nil { + t.Fatalf("valid MetricSourceDelete rejected: %v", err) + } + del.Name = "Bad Name" + if err := del.Valid(); err == nil || !strings.Contains(err.Error(), "name invalid") { + t.Fatalf("expected name invalid, got: %v", err) + } +} + +func TestMetricSourceListValid(t *testing.T) { + list := &MetricSourceList{Project: "p"} + if err := list.Valid(); err != nil { + t.Fatalf("valid MetricSourceList rejected: %v", err) + } + list.Project = "" + if err := list.Valid(); err == nil || !strings.Contains(err.Error(), "project required") { + t.Fatalf("expected project required, got: %v", err) + } +} + +func TestMetricSourceSeriesValid(t *testing.T) { + m := &MetricSourceSeries{Project: "p", Name: "web"} + if err := m.Valid(); err != nil { + t.Fatalf("valid MetricSourceSeries rejected: %v", err) + } + m.Name = "" + if err := m.Valid(); err == nil || !strings.Contains(err.Error(), "name invalid") { + t.Fatalf("expected name invalid, got: %v", err) + } +} + +func TestMetricSourceQueryValid(t *testing.T) { + m := &MetricSourceQuery{Project: "p", Name: "web", TimeRange: MetricSourceQueryTimeRange1h} + if err := m.Valid(); err != nil { + t.Fatalf("valid query with empty series was rejected: %v", err) + } + + m = &MetricSourceQuery{ + Project: "p", + Name: "web", + Series: []string{`queue_depth{queue="email"}`}, + TimeRange: MetricSourceQueryTimeRange7d, + } + if err := m.Valid(); err != nil { + t.Fatalf("valid query was rejected: %v", err) + } + + cases := []struct { + name string + mutate func(*MetricSourceQuery) + want string + }{ + {"missing project", func(q *MetricSourceQuery) { q.Project = "" }, "project required"}, + {"missing timeRange", func(q *MetricSourceQuery) { q.TimeRange = "" }, "timeRange invalid"}, + {"deployment agg range", func(q *MetricSourceQuery) { q.TimeRange = "1hagg" }, "timeRange invalid"}, + {"disk 2d range", func(q *MetricSourceQuery) { q.TimeRange = "2d" }, "timeRange invalid"}, + {"empty series entry", func(q *MetricSourceQuery) { q.Series = []string{""} }, "series must not be empty"}, + {"too many series", func(q *MetricSourceQuery) { + q.Series = make([]string, MetricSourceMaxSeries+1) + for i := range q.Series { + q.Series[i] = "s" + } + }, "series must not exceed"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + q := &MetricSourceQuery{Project: "p", Name: "web", TimeRange: MetricSourceQueryTimeRange1h} + tc.mutate(q) + err := q.Valid() + if err == nil { + t.Fatalf("expected a validation error for %s, got nil", tc.name) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected error to contain %q, got: %v", tc.want, err) + } + }) + } +} + +func TestMetricSourceQueryResultReusesDeploymentMetricsLine(t *testing.T) { + f, ok := reflect.TypeFor[MetricSourceQueryResult]().FieldByName("Items") + if !ok { + t.Fatal("Items field missing") + } + want := reflect.TypeFor[[]*DeploymentMetricsLine]() + if f.Type != want { + t.Fatalf("Items type = %s, want %s", f.Type, want) + } +} + +func TestMetricSourcePermissionsInCatalog(t *testing.T) { + perms := Permissions() + for _, want := range []string{"metricSource.*", "metricSource.set", "metricSource.get", "metricSource.list", "metricSource.delete"} { + if !slices.Contains(perms, want) { + t.Fatalf("permission catalog is missing %q", want) + } + } +} + +func TestMetricSourcePublicBindable(t *testing.T) { + for _, p := range []string{"metricSource.get", "metricSource.list"} { + if !IsPublicBindablePermission(p) { + t.Fatalf("%s should be public-bindable", p) + } + } + for _, p := range []string{"metricSource.set", "metricSource.delete", "metricSource.*", "*"} { + if IsPublicBindablePermission(p) { + t.Fatalf("%s must not be public-bindable", p) + } + } +} + +func TestMetricSourceDelegatable(t *testing.T) { + for _, p := range []string{"metricSource.set", "metricSource.get", "metricSource.list", "metricSource.delete"} { + if !IsDelegatablePermission(p) { + t.Fatalf("%s should be delegatable", p) + } + } + if IsDelegatablePermission("metricSource.*") { + t.Fatal("metricSource.* must not be delegatable (wildcard)") + } +} + +func TestMetricSourceEventsInCatalog(t *testing.T) { + events := NotificationEvents() + for _, want := range []string{"metricSource.set", "metricSource.delete"} { + if !slices.Contains(events, want) { + t.Fatalf("notification event catalog is missing %q", want) + } + } +} + +func TestMetricSourceStatus(t *testing.T) { + m := &MetricSourceItem{} + if got := metricSourceStatus(m); got != "ok" { + t.Fatalf("status = %q, want ok", got) + } + m.Truncated = true + if got := metricSourceStatus(m); got != "truncated" { + t.Fatalf("truncated status = %q, want truncated", got) + } + m.LastError = "timeout" + if got := metricSourceStatus(m); got != "error" { + t.Fatalf("error status = %q, want error", got) + } + m.Disabled = true + if got := metricSourceStatus(m); got != "disabled" { + t.Fatalf("disabled status = %q, want disabled", got) + } +} diff --git a/notification.go b/notification.go index dc57579..ed0234b 100644 --- a/notification.go +++ b/notification.go @@ -224,6 +224,7 @@ var notificationEvents = []string{ "error.detected", "githubInstallation.create", "githubRepo.link", "githubRepo.update", "githubRepo.unlink", + "metricSource.set", "metricSource.delete", "notification.create", "notification.update", "notification.delete", "project.create", "project.update", "project.delete", "pullSecret.create", "pullSecret.delete", diff --git a/role.go b/role.go index 75e8c42..1845bc2 100644 --- a/role.go +++ b/role.go @@ -140,6 +140,11 @@ var permissions = []string{ "alert.get", "alert.list", "alert.delete", + "metricSource.*", + "metricSource.set", + "metricSource.get", + "metricSource.list", + "metricSource.delete", } func Permissions() []string {