diff --git a/.env.example b/.env.example index e7c2f36..58917f1 100644 --- a/.env.example +++ b/.env.example @@ -85,6 +85,7 @@ # Observability (optional) # AGENT_VAULT_LOG_LEVEL=info # info (default) | debug — debug emits one line per proxied request (no secret values) +# AGENT_VAULT_METRICS_ENABLED=false # when true, exposes GET /metrics in the Prometheus text exposition format. Unauthenticated — opt-in, expose only on a trusted network. # Request-log retention (optional) — controls the per-vault audit log of proxied requests. # Bodies and query strings are never stored; only method/host/path/status/latency metadata. diff --git a/README.md b/README.md index 54bd524..c1b442f 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Features: - **Purpose-Built Design**: Existing forward proxies like `mitmproxy` or `squid` require modification to perform credential brokering and integrate well with agents. Agent Vault is purpose-built to work with the ergonomics of all types of agent use-cases with a dedicated CLI, multi-tenancy, and agent-specific roadmap backed by [Infisical](https://github.com/Infisical/infisical). - **Egress Filtering**: Control which agents should have access to which services and API endpoints on them since authenticated requests flow through Agent Vault. - **Request Logging**: Inspect authenticated traffic to monitor and diagnose agent behavior. +- **Prometheus Metrics**: Opt-in `/metrics` endpoint exposing proxy request counts/latency and proposal backlog for alerting and dashboards. See [environment variables](docs/self-hosting/environment-variables.mdx#prometheus-metrics). By default, requests not matching any service forward as plain proxy traffic; flip a vault into strict deny mode (`unmatched_host_policy=deny`) to reject them with 403 instead. diff --git a/cmd/metrics_test.go b/cmd/metrics_test.go new file mode 100644 index 0000000..1aa3a35 --- /dev/null +++ b/cmd/metrics_test.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "log/slog" + "testing" + + "github.com/Infisical/agent-vault/internal/server" +) + +func TestAttachMetricsIfEnabled_Disabled(t *testing.T) { + t.Setenv("AGENT_VAULT_METRICS_ENABLED", "") + db := openTestDB(t) + srv := server.New("127.0.0.1:0", db, make([]byte, 32), nil, true, "http://127.0.0.1:14321", slog.New(slog.DiscardHandler)) + + if sink := attachMetricsIfEnabled(srv, db); sink != nil { + t.Fatal("expected a nil requestlog.Sink when AGENT_VAULT_METRICS_ENABLED is unset") + } +} + +func TestAttachMetricsIfEnabled_Enabled(t *testing.T) { + t.Setenv("AGENT_VAULT_METRICS_ENABLED", "true") + db := openTestDB(t) + srv := server.New("127.0.0.1:0", db, make([]byte, 32), nil, true, "http://127.0.0.1:14321", slog.New(slog.DiscardHandler)) + + if sink := attachMetricsIfEnabled(srv, db); sink == nil { + t.Fatal("expected a non-nil requestlog.Sink when AGENT_VAULT_METRICS_ENABLED=true") + } +} diff --git a/cmd/server.go b/cmd/server.go index faa6852..652cc56 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -21,6 +21,7 @@ import ( "github.com/Infisical/agent-vault/internal/ca" "github.com/Infisical/agent-vault/internal/crypto" "github.com/Infisical/agent-vault/internal/infisical" + "github.com/Infisical/agent-vault/internal/metrics" "github.com/Infisical/agent-vault/internal/mitm" "github.com/Infisical/agent-vault/internal/notify" "github.com/Infisical/agent-vault/internal/pidfile" @@ -180,7 +181,8 @@ var serverCmd = &cobra.Command{ srv := server.New(addr, db, masterKey.Key(), notifier, initialized, baseURL, logger) srv.SetSkills(skillCLI) srv.AttachTelemetry(tel) - shutdownLogs := attachLogSink(srv, db, logger) + metricsSink := attachMetricsIfEnabled(srv, db) + shutdownLogs := attachLogSink(srv, db, logger, metricsSink) defer shutdownLogs() if err := attachServerExtensions(srv, host, mitmPort, masterKey.Key(), db, logger, maxRespBytes, maxReqBytes); err != nil { return err @@ -276,11 +278,18 @@ func attachInfisicalIfConfigured(srv *server.Server, logger *slog.Logger) { // attachLogSink wires the request-log pipeline: a BatchSink with async // batching feeds persistent storage, and a retention goroutine trims old -// rows. Returns a shutdown function the caller runs after Start() -// returns to flush pending records and stop retention. -func attachLogSink(srv *server.Server, db store.Store, logger *slog.Logger) func() { +// rows. extraSink, when non-nil (Prometheus metrics), fans out alongside +// the persistence sink via requestlog.MultiSink. Returns a shutdown +// function the caller runs after Start() returns to flush pending records +// and stop retention. +func attachLogSink(srv *server.Server, db store.Store, logger *slog.Logger, extraSink requestlog.Sink) func() { sink := requestlog.NewBatchSink(db, logger, requestlog.BatchSinkConfig{}) - srv.AttachLogSink(sink) + + var combined requestlog.Sink = sink + if extraSink != nil { + combined = requestlog.MultiSink{sink, extraSink} + } + srv.AttachLogSink(combined) retentionCtx, cancelRetention := context.WithCancel(context.Background()) go requestlog.RunRetention(retentionCtx, db, logger) @@ -295,6 +304,19 @@ func attachLogSink(srv *server.Server, db store.Store, logger *slog.Logger) func } } +// attachMetricsIfEnabled wires a Prometheus /metrics endpoint when +// AGENT_VAULT_METRICS_ENABLED is set. Returns the requestlog.Sink that +// records proxy metrics on the hot path, or nil when metrics are disabled +// (the caller folds this into the log-sink pipeline via attachLogSink). +func attachMetricsIfEnabled(srv *server.Server, db store.Store) requestlog.Sink { + if !metrics.EnabledFromEnv() { + return nil + } + m := metrics.New(db) + srv.AttachMetrics(m) + return m.Sink() +} + // promptOwnerSetup interactively creates the owner account. // masterPassword is optional — if provided, the admin password is checked against it. func promptOwnerSetup(cmd *cobra.Command, db store.Store, masterPassword []byte) error { @@ -601,7 +623,8 @@ func runDetachedChild(host, addr string, mitmPort int, logger *slog.Logger, maxR srv := server.New(addr, db, key, notifier, initialized, baseURL, logger) srv.SetSkills(skillCLI) srv.AttachTelemetry(tel) - shutdownLogs := attachLogSink(srv, db, logger) + metricsSink := attachMetricsIfEnabled(srv, db) + shutdownLogs := attachLogSink(srv, db, logger, metricsSink) defer shutdownLogs() if err := attachServerExtensions(srv, host, mitmPort, key, db, logger, maxRespBytes, maxReqBytes); err != nil { return err diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 0d6d1f6..0139f46 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -40,6 +40,7 @@ description: "Complete reference for all Agent Vault CLI commands." | `AGENT_VAULT_MAX_REQUEST_BYTES` | Maximum request body bytes forwarded to upstreams (default `1073741824` = 1 GiB). The `--max-request-bytes` flag takes precedence. | | `AGENT_VAULT_ADDR` | Externally-reachable base URL (e.g. `https://agent-vault.example.com`). Used for links in emails, invites, and discovery, and the hostname is added as a SubjectAltName on every MITM leaf cert so clients that TLS-verify against the proxy's own hostname succeed without a shim. Falls back to `https://.fly.dev` on Fly.io, then `http://{host}:{port}`. The **Connect Your Agent** modal pre-fills the agent address from this value when set; when unset, the modal renders a literal `` placeholder. | | `FLY_APP_NAME` | Auto-detected on Fly.io. When `AGENT_VAULT_ADDR` is unset, derives the base URL as `https://.fly.dev`. | + | `AGENT_VAULT_METRICS_ENABLED` | When `true`, exposes `GET /metrics` in Prometheus text exposition format. Unauthenticated; disabled by default. | | `AGENT_VAULT_SMTP_HOST` | SMTP server host. If unset, email notifications are silently disabled. | | `AGENT_VAULT_SMTP_PORT` | SMTP server port (default `587`) | | `AGENT_VAULT_SMTP_USERNAME` | SMTP username | diff --git a/docs/self-hosting/environment-variables.mdx b/docs/self-hosting/environment-variables.mdx index b6479e3..62e8345 100644 --- a/docs/self-hosting/environment-variables.mdx +++ b/docs/self-hosting/environment-variables.mdx @@ -16,6 +16,7 @@ description: "Configuration for deploying an instance of Agent Vault." | `AGENT_VAULT_NETWORK_ALLOWLIST` | Optional | Comma-separated CIDRs or bare IPs the proxy may dial when `AGENT_VAULT_ALLOW_PRIVATE_RANGES=false` (e.g., `10.163.0.0/16,192.168.1.1`). | | `AGENT_VAULT_TRUSTED_PROXIES` | Optional | Comma-separated CIDRs of reverse proxies whose `X-Forwarded-For` headers are honored (e.g., `10.0.0.0/8,172.16.0.0/12`). | | `AGENT_VAULT_LOG_LEVEL` | Optional (defaults to `info`) | Log verbosity. One of: `info`, `debug`. `debug` adds one structured line per proxied request (no credential values). Overridden by `--log-level`. | +| `AGENT_VAULT_METRICS_ENABLED` | Optional (defaults to `false`) | Whether to expose `GET /metrics` in the [Prometheus](/self-hosting/environment-variables#prometheus-metrics) text exposition format. | | `AGENT_VAULT_RATELIMIT_PROFILE` | Optional (defaults to `default`) | Rate-limit preset. One of: `default`, `strict` (≈0.5× the defaults), `loose` (≈2×), `off`. | | `AGENT_VAULT_RATELIMIT_LOCK` | Optional (defaults to `false`) | Whether to make the rate-limit UI read-only and ignore UI overrides. | | `AGENT_VAULT_RATELIMIT__` | Optional | Per-tier override. `TIER` is one of: `AUTH`, `PROXY`, `AUTHED`, `GLOBAL`. `KNOB` is one of: `RATE` (tokens/sec), `BURST`, `WINDOW` (e.g., `5m`), `MAX`, `CONCURRENCY`. Always takes precedence over UI overrides. | @@ -29,6 +30,20 @@ description: "Configuration for deploying an instance of Agent Vault." | `DB_MAX_IDLE_CONNS` | Optional (defaults to `10`) | Maximum number of idle Postgres connections kept in the pool per instance. Only applies when `DATABASE_URL` is set. See [connection pooling](/self-hosting/postgres#operational-notes). | | `DB_CONN_MAX_LIFETIME` | Optional (defaults to `5m`) | Maximum lifetime of a Postgres connection before it is closed and replaced. Go duration string (e.g. `5m`, `1h`). Only applies when `DATABASE_URL` is set. See [connection pooling](/self-hosting/postgres#operational-notes). | +## Prometheus metrics + +Set `AGENT_VAULT_METRICS_ENABLED=true` to expose `GET /metrics` in the standard Prometheus text exposition format. The endpoint is unauthenticated (consistent with `/health` and `/v1/status`) and disabled by default — treat it the same as any other internal observability surface and expose it only on a trusted network or behind a reverse proxy that adds auth. + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `agent_vault_proxy_requests_total` | Counter | `service`, `status` | Proxied requests, by matched service name (or `unmatched`) and response status code. | +| `agent_vault_proxy_request_duration_seconds` | Histogram | `service` | Latency of proxied requests. | +| `agent_vault_proposals` | Gauge | `status` | Current number of proposals in each lifecycle status (`pending`, `applied`, `rejected`, `expired`), across all vaults. Queried live from the database on every scrape, so it always reflects the current backlog — e.g. `agent_vault_proposals{status="pending"}` for the pending backlog operators most want to alert on. | + + + Additional counters — netguard-blocked requests and rate-limit rejections — are natural follow-ups; if you'd find those useful, open an issue. + + ## Email SMTP configuration Configure SMTP to enable Agent Vault to send emails for verification codes, vault invites, and notifications. diff --git a/go.mod b/go.mod index 20a2751..f182f53 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/jedib0t/go-pretty/v6 v6.8.2 github.com/muesli/reflow v0.3.0 github.com/posthog/posthog-go v1.16.1 + github.com/prometheus/client_golang v1.24.0 github.com/spf13/cobra v1.10.2 golang.org/x/crypto v0.54.0 golang.org/x/sync v0.22.0 @@ -43,6 +44,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 // indirect github.com/aws/smithy-go v1.20.2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect @@ -81,8 +83,12 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/oracle/oci-go-sdk/v65 v65.95.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.0 // indirect + github.com/prometheus/procfs v0.21.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/zerolog v1.26.1 // indirect @@ -97,7 +103,7 @@ require ( go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.14.0 // indirect diff --git a/go.sum b/go.sum index cbbbe76..79e5943 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -142,10 +144,14 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -169,6 +175,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/oracle/oci-go-sdk/v65 v65.95.2 h1:0HJ0AgpLydp/DtvYrF2d4str2BjXOVAeNbuW7E07g94= @@ -180,6 +188,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posthog/posthog-go v1.16.1 h1:uEbaaYT361a3ImI0D1DYUyNLWN7Y9V9gLqCbQ/z5SxQ= github.com/posthog/posthog-go v1.16.1/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= +github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI= +github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= +github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -230,6 +246,10 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -258,8 +278,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..41c2fb1 --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,97 @@ +// Package metrics exposes a Prometheus /metrics endpoint for Agent Vault. +// Collection is opt-in via AGENT_VAULT_METRICS_ENABLED — when disabled, no +// collectors are registered and the proxy hot path does no extra work. +package metrics + +import ( + "context" + "net/http" + "os" + "strconv" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + + "github.com/Infisical/agent-vault/internal/requestlog" +) + +// EnabledFromEnv reports whether AGENT_VAULT_METRICS_ENABLED is set to a +// truthy value (e.g. "true", "1"). Defaults to disabled. +func EnabledFromEnv() bool { + b, err := strconv.ParseBool(os.Getenv("AGENT_VAULT_METRICS_ENABLED")) + return err == nil && b +} + +// ProposalCounter is the subset of store.Store that the proposals gauge +// needs. Declared here instead of importing internal/store to keep this +// package's dependency surface minimal; store.Store satisfies it structurally. +type ProposalCounter interface { + CountProposalsByStatus(ctx context.Context) (map[string]int, error) +} + +// Metrics owns a dedicated Prometheus registry (never the global default +// registry) so that constructing multiple instances — e.g. once per test — +// never collides on duplicate registration. +type Metrics struct { + registry *prometheus.Registry + + proxyRequestsTotal *prometheus.CounterVec + proxyRequestDuration *prometheus.HistogramVec +} + +// New creates a Metrics instance with the proxy collectors registered. If +// proposals is non-nil, a live proposals-by-status gauge is also registered, +// queried directly from the store on every scrape rather than tracked via +// incremented counters — so it can never drift regardless of which code +// path transitioned a proposal's status. +func New(proposals ProposalCounter) *Metrics { + reg := prometheus.NewRegistry() + + m := &Metrics{ + registry: reg, + proxyRequestsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "agent_vault_proxy_requests_total", + Help: "Total proxied requests, labeled by matched service name and response status code.", + }, []string{"service", "status"}), + proxyRequestDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "agent_vault_proxy_request_duration_seconds", + Help: "Latency of proxied requests in seconds, labeled by matched service name.", + Buckets: prometheus.DefBuckets, + }, []string{"service"}), + } + + reg.MustRegister(m.proxyRequestsTotal, m.proxyRequestDuration) + if proposals != nil { + reg.MustRegister(newProposalsCollector(proposals)) + } + + return m +} + +// Handler returns an http.Handler serving this Metrics instance's registry +// in the standard Prometheus text exposition format. +func (m *Metrics) Handler() http.Handler { + return promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{}) +} + +// Sink returns a requestlog.Sink that records proxy metrics from the same +// per-request Record the audit-log sink consumes. Stack it into a +// requestlog.MultiSink alongside the persistence sink — matches the +// package's documented pattern for adding sinks without touching the +// proxy hot path itself. +func (m *Metrics) Sink() requestlog.Sink { + return proxySink{m: m} +} + +type proxySink struct{ m *Metrics } + +// Record implements requestlog.Sink. Must not block meaningfully — Counter +// and Histogram observations are in-memory and effectively instant. +func (s proxySink) Record(_ context.Context, r requestlog.Record) { + service := r.MatchedService + if service == "" { + service = "unmatched" + } + s.m.proxyRequestsTotal.WithLabelValues(service, strconv.Itoa(r.Status)).Inc() + s.m.proxyRequestDuration.WithLabelValues(service).Observe(float64(r.LatencyMs) / 1000) +} diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go new file mode 100644 index 0000000..a1bee91 --- /dev/null +++ b/internal/metrics/metrics_test.go @@ -0,0 +1,202 @@ +package metrics + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Infisical/agent-vault/internal/requestlog" +) + +func TestEnabledFromEnv(t *testing.T) { + cases := map[string]bool{ + "": false, + "false": false, + "0": false, + "bogus": false, + "true": true, + "1": true, + } + for v, want := range cases { + t.Setenv("AGENT_VAULT_METRICS_ENABLED", v) + if got := EnabledFromEnv(); got != want { + t.Errorf("EnabledFromEnv() with %q = %v, want %v", v, got, want) + } + } +} + +type fakeProposalCounter struct { + counts map[string]int + err error +} + +func (f fakeProposalCounter) CountProposalsByStatus(context.Context) (map[string]int, error) { + return f.counts, f.err +} + +func TestMetrics_ProxySinkRecordsRequests(t *testing.T) { + m := New(nil) + sink := m.Sink() + + sink.Record(context.Background(), requestlog.Record{MatchedService: "stripe", Status: 200, LatencyMs: 42}) + sink.Record(context.Background(), requestlog.Record{MatchedService: "stripe", Status: 500, LatencyMs: 10}) + sink.Record(context.Background(), requestlog.Record{MatchedService: "", Status: 403, LatencyMs: 1}) + + rec := httptest.NewRecorder() + m.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + + body := rec.Body.String() + if !strings.Contains(body, `agent_vault_proxy_requests_total{service="stripe",status="200"} 1`) { + t.Errorf("expected stripe/200 counter, got:\n%s", body) + } + if !strings.Contains(body, `agent_vault_proxy_requests_total{service="stripe",status="500"} 1`) { + t.Errorf("expected stripe/500 counter, got:\n%s", body) + } + if !strings.Contains(body, `agent_vault_proxy_requests_total{service="unmatched",status="403"} 1`) { + t.Errorf("expected unmatched/403 counter (empty MatchedService relabeled), got:\n%s", body) + } + if !strings.Contains(body, "agent_vault_proxy_request_duration_seconds") { + t.Errorf("expected duration histogram to be present, got:\n%s", body) + } +} + +func TestMetrics_ProposalsGaugeOmittedWhenNilCounter(t *testing.T) { + m := New(nil) + + rec := httptest.NewRecorder() + m.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + + if strings.Contains(rec.Body.String(), "agent_vault_proposals") { + t.Errorf("expected no proposals gauge when constructed with a nil counter, got:\n%s", rec.Body.String()) + } +} + +func TestMetrics_ProposalsGaugeReportsAllStatuses(t *testing.T) { + m := New(fakeProposalCounter{counts: map[string]int{"pending": 3, "applied": 5}}) + + rec := httptest.NewRecorder() + m.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + + body := rec.Body.String() + for _, want := range []string{ + `agent_vault_proposals{status="pending"} 3`, + `agent_vault_proposals{status="applied"} 5`, + // Statuses absent from the store's result map must still be + // reported as zero, not omitted, so dashboards don't show gaps. + `agent_vault_proposals{status="rejected"} 0`, + `agent_vault_proposals{status="expired"} 0`, + } { + if !strings.Contains(body, want) { + t.Errorf("expected %q in body, got:\n%s", want, body) + } + } +} + +// countingProposalCounter records how many times CountProposalsByStatus is +// actually invoked, and can optionally block until released — used to prove +// concurrent scrapes serialize on one in-flight query rather than each +// hammering the store, and that a slow query is bounded by a timeout. +type countingProposalCounter struct { + calls atomic.Int32 + counts map[string]int + err error + release chan struct{} // if non-nil, CountProposalsByStatus blocks until closed or ctx is done +} + +func (f *countingProposalCounter) CountProposalsByStatus(ctx context.Context) (map[string]int, error) { + f.calls.Add(1) + if f.release != nil { + select { + case <-f.release: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return f.counts, f.err +} + +func TestProposalsCollector_CachesWithinTTL(t *testing.T) { + counter := &countingProposalCounter{counts: map[string]int{"pending": 1}} + c := newProposalsCollector(counter) + + for i := 0; i < 5; i++ { + if _, ok := c.counts(); !ok { + t.Fatal("expected counts() to succeed") + } + } + + if got := counter.calls.Load(); got != 1 { + t.Fatalf("expected the store to be queried once within the cache TTL, got %d calls", got) + } +} + +// TestProposalsCollector_ConcurrentScrapesShareOneQuery is the regression +// test for the Greptile-flagged issue: GET /metrics is unauthenticated, so +// without caching, a flood of concurrent scrapes would each open their own +// CountProposalsByStatus query and could exhaust the database connection +// pool. With the mutex-guarded cache, concurrent callers serialize on one +// in-flight query and share its result. +func TestProposalsCollector_ConcurrentScrapesShareOneQuery(t *testing.T) { + counter := &countingProposalCounter{counts: map[string]int{"pending": 1}, release: make(chan struct{})} + c := newProposalsCollector(counter) + + const concurrency = 20 + var wg sync.WaitGroup + for i := 0; i < concurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, ok := c.counts(); !ok { + t.Error("expected counts() to succeed") + } + }() + } + + // Give every goroutine a chance to reach the query before releasing it. + time.Sleep(50 * time.Millisecond) + close(counter.release) + wg.Wait() + + if got := counter.calls.Load(); got != 1 { + t.Fatalf("expected exactly one underlying query for %d concurrent scrapes, got %d", concurrency, got) + } +} + +func TestProposalsCollector_QueryTimesOutRatherThanBlockingForever(t *testing.T) { + counter := &countingProposalCounter{counts: map[string]int{"pending": 1}, release: make(chan struct{})} + // Never release — the query must give up on its own via the timeout. + c := newProposalsCollector(counter) + + done := make(chan struct{}) + go func() { + c.counts() + close(done) + }() + + select { + case <-done: + case <-time.After(proposalsQueryTimeout + 2*time.Second): + t.Fatal("expected counts() to give up once the query timeout elapsed, but it kept blocking") + } +} + +func TestMetrics_ProposalsGaugeSkipsOnStoreError(t *testing.T) { + m := New(fakeProposalCounter{err: errors.New("db unavailable")}) + + rec := httptest.NewRecorder() + m.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + + // A failed proposals query must not break the rest of the scrape. + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 even when the proposals collector errors, got %d: %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "agent_vault_proposals{") { + t.Errorf("expected proposals series to be skipped on store error, got:\n%s", rec.Body.String()) + } +} diff --git a/internal/metrics/proposals.go b/internal/metrics/proposals.go new file mode 100644 index 0000000..f660a97 --- /dev/null +++ b/internal/metrics/proposals.go @@ -0,0 +1,104 @@ +package metrics + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +// proposalStatuses lists every lifecycle status a proposal can be in +// (proposal.Status), so the gauge always reports all four series — even +// series with a current count of zero — rather than only whichever +// statuses happen to have rows right now. +var proposalStatuses = []string{"pending", "applied", "rejected", "expired"} + +const ( + // proposalsCacheTTL bounds how often a scrape actually queries the + // store. GET /metrics is unauthenticated (like /health), so without + // this, a flood of scrapes could open unbounded concurrent + // CountProposalsByStatus queries and exhaust the database connection + // pool. Caching also means concurrent scrapers block briefly on the + // mutex below and then share one fresh result instead of each issuing + // their own query. + proposalsCacheTTL = 5 * time.Second + // proposalsQueryTimeout caps how long a single query may hold the lock + // (and a connection from the pool) before giving up, so a slow or + // hanging database can't pin every scrape indefinitely. + proposalsQueryTimeout = 3 * time.Second +) + +// proposalsCollector queries ProposalCounter live on every scrape instead of +// tracking a running total, so it reflects the proposals table exactly +// regardless of which code path (approve, reject, expiry sweep) moved a +// proposal between statuses. Results are cached briefly (see +// proposalsCacheTTL) to bound load on the store. +type proposalsCollector struct { + counter ProposalCounter + desc *prometheus.Desc + + mu sync.Mutex + cached map[string]int + cachedAt time.Time +} + +func newProposalsCollector(counter ProposalCounter) *proposalsCollector { + return &proposalsCollector{ + counter: counter, + desc: prometheus.NewDesc( + "agent_vault_proposals", + "Current number of proposals in each lifecycle status, across all vaults.", + []string{"status"}, nil, + ), + } +} + +// Describe implements prometheus.Collector. +func (c *proposalsCollector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.desc +} + +// Collect implements prometheus.Collector. No series are emitted at all if +// a query has never succeeded (e.g. the very first scrape hits a store +// error), matching the pre-existing "skip on error" behavior rather than +// reporting misleading zeros. +func (c *proposalsCollector) Collect(ch chan<- prometheus.Metric) { + counts, ok := c.counts() + if !ok { + return + } + for _, status := range proposalStatuses { + ch <- prometheus.MustNewConstMetric(c.desc, prometheus.GaugeValue, float64(counts[status]), status) + } +} + +// counts returns the cached proposal counts, refreshing them from the store +// at most once per proposalsCacheTTL. Holding the mutex for the duration of +// a refresh means concurrent scrapes serialize on one in-flight query rather +// than each starting their own. A query failure is logged and the previous +// cached value (if any) is served rather than zeros, so a transient store +// issue doesn't take down the whole /metrics response or report a false +// dip; ok is false only when no successful query has ever completed. +func (c *proposalsCollector) counts() (_ map[string]int, ok bool) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.cached != nil && time.Since(c.cachedAt) < proposalsCacheTTL { + return c.cached, true + } + + ctx, cancel := context.WithTimeout(context.Background(), proposalsQueryTimeout) + defer cancel() + + counts, err := c.counter.CountProposalsByStatus(ctx) + if err != nil { + slog.Warn("metrics: failed to collect proposal counts", slog.String("error", err.Error())) //nolint:gosec // G706: structured slog attrs, handlers quote control chars + return c.cached, c.cached != nil + } + + c.cached = counts + c.cachedAt = time.Now() + return c.cached, true +} diff --git a/internal/server/handle_metrics.go b/internal/server/handle_metrics.go new file mode 100644 index 0000000..a17a4b9 --- /dev/null +++ b/internal/server/handle_metrics.go @@ -0,0 +1,16 @@ +package server + +import "net/http" + +// handleMetrics serves the Prometheus text exposition format when metrics +// are enabled (AGENT_VAULT_METRICS_ENABLED=true, wired via AttachMetrics). +// Unauthenticated, like /health and /v1/status — opt-in and meant to be +// scraped from a trusted network, consistent with this server's other +// public observability routes. +func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) { + if s.metrics == nil { + http.NotFound(w, r) + return + } + s.metrics.Handler().ServeHTTP(w, r) +} diff --git a/internal/server/handle_metrics_test.go b/internal/server/handle_metrics_test.go new file mode 100644 index 0000000..4898e6b --- /dev/null +++ b/internal/server/handle_metrics_test.go @@ -0,0 +1,70 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Infisical/agent-vault/internal/metrics" + "github.com/Infisical/agent-vault/internal/requestlog" +) + +func TestHandleMetrics_NotFoundWhenUnattached(t *testing.T) { + srv := newTestServer() + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec := httptest.NewRecorder() + + srv.httpServer.Handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404 when metrics are not attached, got %d", rec.Code) + } +} + +func TestHandleMetrics_ServesPrometheusFormatWhenAttached(t *testing.T) { + srv := newTestServer() + m := metrics.New(nil) + srv.AttachMetrics(m) + + // A CounterVec with no observations yet emits no series at all — record + // one via the sink first, the same hot path attachMetricsIfEnabled wires + // up in cmd/server.go. + m.Sink().Record(context.Background(), requestlog.Record{MatchedService: "stripe", Status: 200}) + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec := httptest.NewRecorder() + + srv.httpServer.Handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "agent_vault_proxy_requests_total") { + t.Fatalf("expected proxy request counter metric family, got:\n%s", rec.Body.String()) + } +} + +// countingProposalStore satisfies metrics.ProposalCounter for the purpose of +// this test without pulling in a full store.Store implementation. +type countingProposalStore struct{ counts map[string]int } + +func (c countingProposalStore) CountProposalsByStatus(context.Context) (map[string]int, error) { + return c.counts, nil +} + +func TestHandleMetrics_ReportsLiveProposalCounts(t *testing.T) { + srv := newTestServer() + srv.AttachMetrics(metrics.New(countingProposalStore{counts: map[string]int{"pending": 2}})) + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec := httptest.NewRecorder() + + srv.httpServer.Handler.ServeHTTP(rec, req) + + if !strings.Contains(rec.Body.String(), `agent_vault_proposals{status="pending"} 2`) { + t.Fatalf("expected pending proposal gauge, got:\n%s", rec.Body.String()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 3e1df30..86a0339 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -22,6 +22,7 @@ import ( "github.com/Infisical/agent-vault/internal/brokercore" "github.com/Infisical/agent-vault/internal/crypto" "github.com/Infisical/agent-vault/internal/infisical" + "github.com/Infisical/agent-vault/internal/metrics" "github.com/Infisical/agent-vault/internal/mitm" "github.com/Infisical/agent-vault/internal/netguard" "github.com/Infisical/agent-vault/internal/notify" @@ -89,6 +90,9 @@ type Server struct { infisicalDynamic *infisical.DynamicResolver oauthRefresher *oauth.Refresher telemetry *telemetry.Telemetry + // metrics is nil unless AGENT_VAULT_METRICS_ENABLED is set; GET /metrics + // 404s when unattached rather than the route not existing at all. + metrics *metrics.Metrics } // lockVaultServices acquires the per-vault mutation lock via the store's @@ -132,6 +136,11 @@ func (s *Server) LogSink() requestlog.Sink { return s.logSink } // default), captureEvent is a no-op. func (s *Server) AttachTelemetry(t *telemetry.Telemetry) { s.telemetry = t } +// AttachMetrics wires a Prometheus /metrics handler, built from +// metrics.New(). Call before Start(). If never called (the default — +// AGENT_VAULT_METRICS_ENABLED unset), GET /metrics 404s. +func (s *Server) AttachMetrics(m *metrics.Metrics) { s.metrics = m } + // captureEvent sends a telemetry event if telemetry is configured. // actor may be nil for pre-auth endpoints (login, register); callers // pass what they already have and never re-resolve from the DB. @@ -801,6 +810,7 @@ func New(addr string, store Store, encKey []byte, notifier *notify.Notifier, ini // server-wide TierGlobal backstop; no per-route limit is useful. mux.HandleFunc("GET /health", s.handleHealth) mux.HandleFunc("GET /v1/status", s.handleStatus) + mux.HandleFunc("GET /metrics", s.handleMetrics) mux.HandleFunc("POST /v1/auth/register", ipAuth(limitBody(s.handleRegister))) mux.HandleFunc("POST /v1/auth/verify", ipAuth(limitBody(s.handleVerify))) mux.HandleFunc("POST /v1/auth/resend-verification", ipAuth(limitBody(s.handleResendVerification))) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index d36df30..12a545c 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -389,6 +389,16 @@ func (m *mockStore) ExpirePendingProposals(_ context.Context, before time.Time) return 0, nil } +func (m *mockStore) CountProposalsByStatus(_ context.Context) (map[string]int, error) { + counts := make(map[string]int) + for _, css := range m.proposals { + for _, cs := range css { + counts[cs.Status]++ + } + } + return counts, nil +} + func (m *mockStore) Close() error { return nil } func (m *mockStore) Ping(_ context.Context) error { return nil } func (m *mockStore) DialectName() string { return "sqlite" } diff --git a/internal/store/sql_store.go b/internal/store/sql_store.go index b56dd3a..918fe27 100644 --- a/internal/store/sql_store.go +++ b/internal/store/sql_store.go @@ -1962,6 +1962,30 @@ func (s *SQLStore) CountPendingProposals(ctx context.Context, vaultID string) (i return count, err } +// CountProposalsByStatus returns the current number of proposals in each +// lifecycle status, across every vault. Used by the /metrics endpoint to +// report a live backlog gauge — intentionally a point-in-time query rather +// than an incrementally-tracked counter, so it can never drift from the +// proposals table regardless of how a status transition happened. +func (s *SQLStore) CountProposalsByStatus(ctx context.Context) (map[string]int, error) { + rows, err := s.db.QueryContext(ctx, "SELECT status, COUNT(*) FROM proposals GROUP BY status") + if err != nil { + return nil, fmt.Errorf("counting proposals by status: %w", err) + } + defer func() { _ = rows.Close() }() + + counts := make(map[string]int) + for rows.Next() { + var status string + var count int + if err := rows.Scan(&status, &count); err != nil { + return nil, fmt.Errorf("scanning proposal status count: %w", err) + } + counts[status] = count + } + return counts, rows.Err() +} + func (s *SQLStore) ExpirePendingProposals(ctx context.Context, before time.Time) (int, error) { nowStr := s.now() res, err := s.db.ExecContext(ctx, diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go index 998d79e..a490545 100644 --- a/internal/store/sqlite_test.go +++ b/internal/store/sqlite_test.go @@ -1310,6 +1310,49 @@ func TestExpirePendingProposals(t *testing.T) { } } +func TestCountProposalsByStatus(t *testing.T) { + s := openTestDB(t) + ctx := context.Background() + + counts, err := s.CountProposalsByStatus(ctx) + if err != nil { + t.Fatalf("CountProposalsByStatus: %v", err) + } + if len(counts) != 0 { + t.Fatalf("expected no rows on an empty table, got %+v", counts) + } + + // Spread proposals across two vaults and every terminal status to + // verify the count is global (not scoped to one vault). + ns1, _ := s.CreateVault(ctx, "status-count-1") + ns2, _ := s.CreateVault(ctx, "status-count-2") + + s.CreateProposal(ctx, ns1.ID, "s1", "[]", "[]", "pending one", "", nil) + s.CreateProposal(ctx, ns1.ID, "s2", "[]", "[]", "pending two", "", nil) + // Proposal IDs are per-vault sequential, so these start back at 1 in ns2. + s.CreateProposal(ctx, ns2.ID, "s3", "[]", "[]", "to reject", "", nil) + s.UpdateProposalStatus(ctx, ns2.ID, 1, "rejected", "no") + s.CreateProposal(ctx, ns2.ID, "s4", "[]", "[]", "to expire", "", nil) + // Set directly rather than via ExpirePendingProposals's time-based sweep — + // the other proposals in this test were created at the same instant and + // would also match a "created before now" cutoff. + s.UpdateProposalStatus(ctx, ns2.ID, 2, "expired", "") + + counts, err = s.CountProposalsByStatus(ctx) + if err != nil { + t.Fatalf("CountProposalsByStatus: %v", err) + } + if counts["pending"] != 2 { + t.Fatalf("expected 2 pending, got %d (%+v)", counts["pending"], counts) + } + if counts["rejected"] != 1 { + t.Fatalf("expected 1 rejected, got %d (%+v)", counts["rejected"], counts) + } + if counts["expired"] != 1 { + t.Fatalf("expected 1 expired, got %d (%+v)", counts["expired"], counts) + } +} + func TestProposalWithCredentials(t *testing.T) { s := openTestDB(t) ctx := context.Background() diff --git a/internal/store/store.go b/internal/store/store.go index dd98fee..457816d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -519,6 +519,7 @@ type Store interface { ListProposals(ctx context.Context, vaultID, status string) ([]Proposal, error) UpdateProposalStatus(ctx context.Context, vaultID string, id int, status, reviewNote string) error CountPendingProposals(ctx context.Context, vaultID string) (int, error) + CountProposalsByStatus(ctx context.Context) (map[string]int, error) ExpirePendingProposals(ctx context.Context, before time.Time) (int, error) GetProposalCredentials(ctx context.Context, vaultID string, proposalID int) (map[string]EncryptedCredential, error) ApplyProposal(ctx context.Context, vaultID string, proposalID int, mergedServicesJSON string, credentials map[string]EncryptedCredential, deleteCredentialKeys []string, oauthConfigs []OAuthCredentialConfig) error