From 996fba144b30cf7150768ebd2041dee6221e3376 Mon Sep 17 00:00:00 2001 From: david <58231312+davseby@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:00:22 +0300 Subject: [PATCH] add built-in demo prometheus to core --- .github/workflows/qa-datagen.yml | 74 ++ CLAUDE.md | 4 +- Makefile | 9 +- datagen/.golangci.yml | 324 +++++++++ datagen/Makefile | 26 + datagen/cmd/main.go | 2 + datagen/cmd/main_test.go | 21 + datagen/go.mod | 44 +- datagen/go.sum | 115 +++- datagen/internal/demodata/demodata.go | 205 ++++-- datagen/internal/demodata/demodata_test.go | 262 +++++++ datagen/internal/gen/manager.go | 153 ++-- datagen/internal/gen/manager_test.go | 303 ++++++++ datagen/internal/mariademo/mariademo.go | 73 +- datagen/internal/mariademo/mariademo_test.go | 566 +++++++++++++++ datagen/internal/mockmetrics/rand.go | 23 +- datagen/internal/mockmetrics/rand_test.go | 250 +++++++ datagen/internal/pgdemo/pgdemo.go | 64 +- datagen/internal/pgdemo/pgdemo_test.go | 547 +++++++++++++++ datagen/internal/server/server.go | 10 +- datagen/internal/server/server_test.go | 206 ++++++ docker/demo/prometheus/prometheus.yaml | 13 - docker/docker-compose.dev.yaml | 16 - docker/env/core.example.env | 1 - e2e/docker-compose.yaml | 8 +- server/CLAUDE.md | 25 +- server/core/.golangci.yml | 15 + server/core/cmd/core/main.go | 7 +- server/core/go.mod | 140 ++-- server/core/go.sum | 469 +++++++------ .../_mock/meilisearch_index_manager.go | 39 +- .../_mock/meilisearch_service_manager.go | 651 +++++++++++++++++- server/core/internal/apps/github/content.go | 2 +- .../core/internal/apps/github/content_test.go | 2 +- server/core/internal/apps/github/manager.go | 30 +- .../core/internal/apps/github/manager_test.go | 23 +- server/core/internal/apps/slack/manager.go | 3 - .../internal/assistant/provider/gemini.go | 12 + .../assistant/provider/gemini_test.go | 20 +- .../core/internal/datasource/demo/client.go | 261 +++++++ .../internal/datasource/demo/client_test.go | 465 +++++++++++++ server/core/internal/datasource/demo/demo.go | 28 + .../internal/datasource/demo/demo_test.go | 31 + .../internal/datasource/demo/queryable.go | 245 +++++++ .../datasource/demo/queryable_test.go | 279 ++++++++ .../core/internal/datasource/demo/registry.go | 556 +++++++++++++++ .../internal/datasource/demo/registry_test.go | 330 +++++++++ .../core/internal/datasource/demo/series.go | 215 ++++++ .../internal/datasource/demo/series_test.go | 283 ++++++++ .../datasource/processor/postgresql.go | 18 +- .../datasource/processor/postgresql_test.go | 24 +- .../datasource/processor/prometheus.go | 7 +- server/core/internal/datasource/runner.go | 33 +- .../core/internal/datasource/runner_test.go | 59 +- .../internal/server/internal/github/http.go | 2 +- .../core/internal/server/internal/org/http.go | 47 +- .../internal/server/internal/org/http_test.go | 27 +- .../internal/server/internal/slack/event.go | 2 +- .../internal/server/internal/slack/http.go | 41 +- .../server/internal/slack/http_test.go | 12 + server/core/internal/server/router.go | 2 +- server/core/internal/server/server.go | 4 - server/core/internal/server/server_test.go | 9 +- server/core/internal/storage/client_test.go | 55 +- server/core/internal/storage/object.go | 16 +- server/core/pkg/sentryutil/sentryutil.go | 3 + server/core/pkg/sentryutil/sentryutil_test.go | 1 + 67 files changed, 7108 insertions(+), 704 deletions(-) create mode 100644 .github/workflows/qa-datagen.yml create mode 100644 datagen/.golangci.yml create mode 100644 datagen/cmd/main_test.go create mode 100644 datagen/internal/demodata/demodata_test.go create mode 100644 datagen/internal/gen/manager_test.go create mode 100644 datagen/internal/mariademo/mariademo_test.go create mode 100644 datagen/internal/mockmetrics/rand_test.go create mode 100644 datagen/internal/pgdemo/pgdemo_test.go create mode 100644 datagen/internal/server/server_test.go delete mode 100644 docker/demo/prometheus/prometheus.yaml create mode 100644 server/core/internal/datasource/demo/client.go create mode 100644 server/core/internal/datasource/demo/client_test.go create mode 100644 server/core/internal/datasource/demo/demo.go create mode 100644 server/core/internal/datasource/demo/demo_test.go create mode 100644 server/core/internal/datasource/demo/queryable.go create mode 100644 server/core/internal/datasource/demo/queryable_test.go create mode 100644 server/core/internal/datasource/demo/registry.go create mode 100644 server/core/internal/datasource/demo/registry_test.go create mode 100644 server/core/internal/datasource/demo/series.go create mode 100644 server/core/internal/datasource/demo/series_test.go diff --git a/.github/workflows/qa-datagen.yml b/.github/workflows/qa-datagen.yml new file mode 100644 index 00000000..6ece2e50 --- /dev/null +++ b/.github/workflows/qa-datagen.yml @@ -0,0 +1,74 @@ +name: QA +on: + push: + branches: + - main + paths: + - datagen/** + - .github/workflows/qa-datagen.yml + pull_request: + branches: + - main + paths: + - datagen/** + - .github/workflows/qa-datagen.yml + +# a newer push supersedes the still-running workflow of the stale commit +# and cancels it. The group carries a hardcoded per-file prefix because +# every QA workflow shares the display name "QA", so github.workflow +# would put them all in one group and let a run of one workflow cancel +# an in-flight run of another. +concurrency: + group: qa-datagen-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + lint: + name: Linting datagen + runs-on: ubuntu-latest + steps: + - name: Check out the code + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: datagen/go.mod + cache-dependency-path: datagen/go.sum + + - name: Installing dependencies + run: go mod download + working-directory: datagen + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.13 + working-directory: datagen + + test: + name: Unit testing datagen + runs-on: ubuntu-latest + steps: + - name: Check out the code + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: datagen/go.mod + cache-dependency-path: datagen/go.sum + + - name: Installing dependencies + run: go mod download + working-directory: datagen + + # the pgdemo and mariademo suites run against throwaway containers, + # which the ubuntu runners provide a docker daemon for out of the box. + - name: Run unit tests + run: make check-coverage + working-directory: datagen diff --git a/CLAUDE.md b/CLAUDE.md index 3985cc5e..f11577c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work - `datagen/` — demo-data generator; separate Go module `github.com/oxynote/oxynote/datagen`. Demo/testing only. - `e2e/` — Playwright end-to-end suite (`@oxynote/e2e`, **pnpm**) plus the docker-compose stack it drives. Not shipped; it exercises the composed product through a real backend built from this repo. Details: [e2e/CLAUDE.md](e2e/CLAUDE.md). - `scripts/` — helpers the root Makefile calls. `run-quietly.sh` runs a build step with its output held back, replaying the log only if the step fails. -- `docker/` — dev docker-compose stack, Caddyfile, `env/` (committed `*.example.env` templates; `make setup` copies them to the gitignored `*.local.env` files the compose stack reads, and `web.example.env` also to `web/.env` for the host dev server and electron builds), `demo/` (demo-data configs for mariadb/postgres/prometheus). +- `docker/` — dev docker-compose stack, Caddyfile, `env/` (committed `*.example.env` templates; `make setup` copies them to the gitignored `*.local.env` files the compose stack reads, and `web.example.env` also to `web/.env` for the host dev server and electron builds), `demo/` (demo-data configs for mariadb/postgres). **One `.gitignore`, and it lives at the repository root.** Components do not carry their own — a rule for a nested directory is written with its path (`e2e/test-results/`, `web/coverage/`), so every exclusion in the repository is readable in one file. The exception is `web/packages/lezer-promql/`, which is a vendored upstream fork and keeps the ignore file it ships with. @@ -93,7 +93,7 @@ make start # build images + run the dev stack in the background make dev # backend containers + web dev server on the host (hot reload, :3000) make stop # stop the dev stack -make lint # fix lint/format/type issues in web, auth-realtime, core, e2e +make lint # fix lint/format/type issues in web, auth-realtime, core, datagen, e2e make check-lint # the same gates, verification only make e2e # one-shot: build, run playwright, tear the e2e stack down diff --git a/Makefile b/Makefile index 4ba77344..153426cd 100644 --- a/Makefile +++ b/Makefile @@ -54,15 +54,15 @@ e2e-stack-build: e2e-stack-stop: @$(QUIET) "stopping the stack" $(E2E_COMPOSE) down -v -# lint gates of every component that has one, from one place. Fixing and -# checking mirror each component's own vocabulary: web, auth-realtime and -# e2e pair lint/check-lint, and core wraps golangci-lint. Datagen has no -# lint setup and is deliberately absent. +# lint gates of every component, from one place. Fixing and checking mirror +# each component's own vocabulary: web, auth-realtime and e2e pair +# lint/check-lint, and core and datagen wrap golangci-lint. .PHONY: lint lint: @$(QUIET) "linting web" sh -c 'cd web && pnpm run lint' @$(QUIET) "linting auth-realtime" sh -c 'cd server/auth-realtime && pnpm run lint' @$(QUIET) "linting core" sh -c 'cd server/core && make lint' + @$(QUIET) "linting datagen" sh -c 'cd datagen && make lint' @$(QUIET) "linting e2e" sh -c 'cd e2e && pnpm run lint' .PHONY: check-lint @@ -70,6 +70,7 @@ check-lint: @$(QUIET) "checking web" sh -c 'cd web && pnpm run check-lint' @$(QUIET) "checking auth-realtime" sh -c 'cd server/auth-realtime && pnpm run check-lint' @$(QUIET) "checking core" sh -c 'cd server/core && make check-lint' + @$(QUIET) "checking datagen" sh -c 'cd datagen && make check-lint' @$(QUIET) "checking e2e" sh -c 'cd e2e && pnpm run check-lint' .PHONY: setup diff --git a/datagen/.golangci.yml b/datagen/.golangci.yml new file mode 100644 index 00000000..b13ab33b --- /dev/null +++ b/datagen/.golangci.yml @@ -0,0 +1,324 @@ +version: "2" +linters: + enable: + - arangolint + - asasalint + - asciicheck + - bidichk + - bodyclose + - canonicalheader + - clickhouselint + - contextcheck + - copyloopvar + - cyclop + - depguard + - dogsled + - dupword + - durationcheck + - embeddedstructfieldcheck + - err113 + - errcheck + - errchkjson + - errname + - errorlint + - exhaustive + - exptostd + - fatcontext + - forbidigo + - forcetypeassert + - ginkgolinter + - gocheckcompilerdirectives + - gochecknoglobals + - gochecknoinits + - gochecksumtype + - gocognit + - goconst + - gocritic + - gocyclo + - godoclint + - godot + - gomoddirectives + - gomodguard_v2 + - goprintffuncname + - gosec + - govet + - importas + - ineffassign + - inamedparam + - interfacebloat + - intrange + - iotamixing + - loggercheck + - maintidx + - mirror + - misspell + - mnd + - modernize + - musttag + - nakedret + - nestif + - nilerr + - nilnesserr + - nilnil + - noctx + - nolintlint + - nosprintfhostport + - paralleltest + - perfsprint + - prealloc + - predeclared + - promlinter + - protogetter + - reassign + - recvcheck + - revive + - rowserrcheck + - sloglint + - spancheck + - sqlclosecheck + - staticcheck + - tagalign + - tagliatelle + - testableexamples + - testifylint + - thelper + - unconvert + - unparam + - unqueryvet + - unused + - usestdlibvars + - usetesting + - wastedassign + - whitespace + - wsl_v5 + - zerologlint + disable: + - containedctx + - decorder + - dupl + - exhaustruct + - funcorder + - funlen + - godox + - goheader + - gosmopolitan + - grouper + - iface + - ireturn + - lll + - makezero + - nlreturn + - noinlineerr + - nonamedreturns + - testpackage + - tparallel + - varnamelen + - wrapcheck + settings: + cyclop: + max-complexity: 25 + errcheck: + check-blank: true + depguard: + rules: + main: + deny: + - pkg: gotest.tools/assert + - pkg: github.com/bmizerany/assert + exhaustive: + default-signifies-exhaustive: true + goconst: + min-occurrences: 5 + numbers: true + gomoddirectives: + # datagen builds against the core module in this same repository, so + # the local replace is how the two are wired, not a leftover. + replace-local: true + forbidigo: + forbid: + # timeutil.Now is mockable; time.Now is not, which makes every + # clock-dependent path untestable through it. + - pattern: ^time\.Now$ + msg: use timeutil.Now instead + gocritic: + disabled-checks: + - hugeParam + - unnamedResult + - sloppyReassign + - rangeValCopy + enabled-tags: + - diagnostic + - style + - performance + - experimental + - opinionated + govet: + disable: + - fieldalignment + enable-all: true + nakedret: + max-func-lines: 20 + paralleltest: + ignore-missing: true + prealloc: + for-loops: true + promlinter: + strict: true + recvcheck: + exclusions: + - '*.Value' + - '*.Scan' + revive: + enable-all-rules: true + rules: + - name: unchecked-type-assertion + disabled: true + - name: import-alias-naming + disabled: true + - name: unnecessary-stmt + disabled: true + - name: argument-limit + disabled: true + - name: function-result-limit + disabled: true + - name: cyclomatic + severity: warning + disabled: true + - name: line-length-limit + severity: warning + disabled: true + - name: cognitive-complexity + severity: warning + disabled: true + - name: confusing-naming + disabled: true + - name: confusing-results + disabled: true + - name: unhandled-error + disabled: true + - name: max-public-structs + disabled: true + - name: function-length + disabled: true + - name: banned-characters + disabled: true + - name: file-header + disabled: true + - name: add-constant + disabled: true + - name: comment-spacings + disabled: true + - name: unused-receiver + disabled: true + - name: nested-structs + disabled: true + - name: flag-parameter + disabled: true + - name: empty-lines + disabled: true + - name: if-return + disabled: true + - name: enforce-switch-style + disabled: true + rowserrcheck: + packages: + - github.com/jmoiron/sqlx + tagalign: + align: false + sort: true + order: + - json + - db + tagliatelle: + case: + rules: + json: camel + testifylint: + disable: + - require-error + thelper: + test: + begin: false + wsl_v5: + allow-first-in-block: true + exclusions: + generated: lax + rules: + - linters: + - forbidigo + path: cmd + text: fmt.Print + - linters: + - forbidigo + path: _test.go + text: time.Now + - linters: + - gochecknoglobals + text: ^_.+ + - linters: + - forcetypeassert + - gochecknoinits + - maintidx + - promlinter + - usestdlibvars + path: _test.go + - linters: + - gocritic + text: 'unnecessaryDefer: defer' + - linters: + - err113 + text: do not define dynamic errors + - linters: + - gosec + path: _test.go + text: G112 + - linters: + - gosec + text: G115 + - linters: + - musttag + text: should be annotated with the `db` + - linters: + - goconst + text: string `(true|false)` has + # the gauge tables are tuned sampling parameters read as a block: every + # bound, target and spike chance is one row of demo tuning, not a + # threshold logic branches on. Naming each would bury the tables, and a + # shared constant would tie together gauges that only coincide today. + - linters: + - goconst + - mnd + path: internal/gen + - linters: + - dupword + source: '^//go:generate ' + - linters: + - usetesting + text: could be replaced by t.Context() + - linters: + - revive + text: Import alias "v1" is redundant + - linters: + - cyclop + - goconst + - noctx + path: (.+)_test\.go + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + settings: + gofumpt: + extra-rules: true + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/datagen/Makefile b/datagen/Makefile index aeb8c0ac..0272b82b 100644 --- a/datagen/Makefile +++ b/datagen/Makefile @@ -1,3 +1,7 @@ +# Minimum total statement coverage. Set from the measured baseline and +# ratcheted up as suites grow — never lowered. +COVERAGE_MIN = 92.9 + # Build the Docker image # dockers_v2 snapshot builds load into the local daemon with an arch suffix; # retag so the dev compose stack can reference a stable :dev tag. @@ -5,3 +9,25 @@ build: goreleaser release --snapshot --clean docker tag ghcr.io/oxynote/datagen:dev-amd64 ghcr.io/oxynote/datagen:dev + +# Run all tests with the race detector. +.PHONY: test +test: + @go test -race ./... + +# Run all tests with coverage and fail below the minimum. +.PHONY: check-coverage +check-coverage: + @go test -race -coverprofile=coverage.out ./... + @go tool cover -func=coverage.out | tail -n 1 | awk -v min=$(COVERAGE_MIN) \ + '{ print; if ($$3 + 0 < min) { printf "total coverage is below the %s%% minimum\n", min; exit 1 } }' + +# Run the linters and apply automatic fixes. +.PHONY: lint +lint: + @golangci-lint run --fix --timeout 5m + +# Run the linters without modifying anything. +.PHONY: check-lint +check-lint: + @golangci-lint run --timeout 5m diff --git a/datagen/cmd/main.go b/datagen/cmd/main.go index ef909171..6b0f890b 100644 --- a/datagen/cmd/main.go +++ b/datagen/cmd/main.go @@ -1,3 +1,5 @@ +// Package main runs the demo data generator: it serves mock engineering +// metrics for Prometheus to scrape and fills the demo databases with rows. package main import ( diff --git a/datagen/cmd/main_test.go b/datagen/cmd/main_test.go new file mode 100644 index 00000000..a8a2e6c9 --- /dev/null +++ b/datagen/cmd/main_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func Test_newLogger(t *testing.T) { + t.Parallel() + + log := newLogger() + require.NotNil(t, log) + assert.NotNil(t, log.Handler()) +} diff --git a/datagen/go.mod b/datagen/go.mod index c216a09c..a50534a2 100644 --- a/datagen/go.mod +++ b/datagen/go.mod @@ -6,27 +6,63 @@ require ( github.com/go-chi/chi/v5 v5.3.2 github.com/go-sql-driver/mysql v1.10.0 github.com/jackc/pgx/v5 v5.10.0 + github.com/orlangure/gnomock v0.32.0 github.com/oxynote/oxynote/server/core v0.0.0 github.com/prometheus/client_golang v1.24.1 + github.com/prometheus/client_model v0.6.2 + github.com/stretchr/testify v1.12.1 + go.uber.org/goleak v1.3.0 ) replace github.com/oxynote/oxynote/server/core => ../server/core require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/getsentry/sentry-go v0.46.2 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.2+incompatible // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/getsentry/sentry-go v0.48.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/schema v1.4.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jarcoal/httpmock v1.4.2 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/prometheus/client_model v0.6.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rs/xid v1.6.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect + go.opentelemetry.io/otel v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/otel/sdk v1.46.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.46.0 // indirect + go.opentelemetry.io/otel/trace v1.46.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/grpc v1.83.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/datagen/go.sum b/datagen/go.sum index ef3446df..ff5c6f1c 100644 --- a/datagen/go.sum +++ b/datagen/go.sum @@ -1,24 +1,55 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 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/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns= -github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/getsentry/sentry-go v0.48.0 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY= +github.com/getsentry/sentry-go v0.48.0/go.mod h1:E5UkA5wp1qR2+MDydNYlVeUiNN2xEdjYMidkgf0Qoss= github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -29,19 +60,37 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI= github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= -github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= 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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI= +github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc= +github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/orlangure/gnomock v0.32.0 h1:96KCsqbDUaKz2nKkGqC0nIlJeCRaSfEpSi4CljBp8zk= +github.com/orlangure/gnomock v0.32.0/go.mod h1:CpMbwyCmPFpeLrsA5LIUcMrGm7LOf9+2JE+taayrUPc= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -54,24 +103,62 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0 h1:Mbi5PKN7u322woPa85d7ebZ+SOvEoPvoiBu+ryHWgfA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0/go.mod h1:e7ciERRhZaOZXVjx5MiL8TK5+Xv7G5Gv5PA2ZDEJdL8= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= 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.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= +gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= diff --git a/datagen/internal/demodata/demodata.go b/datagen/internal/demodata/demodata.go index 1f6c2c99..090ee9d1 100644 --- a/datagen/internal/demodata/demodata.go +++ b/datagen/internal/demodata/demodata.go @@ -1,3 +1,5 @@ +// Package demodata synthesises the deployment, incident and build rows the +// demo data sources are filled with. package demodata import ( @@ -8,46 +10,153 @@ import ( "github.com/oxynote/oxynote/datagen/internal/mockmetrics" ) +// TickInterval defines how much demo time a single generated tick covers. const TickInterval = 5 * time.Minute -var ( - services = []string{"api-gateway", "user-service", "billing-service", "notification-service", "search-service"} - environments = []string{"production", "staging"} - severities = []string{"critical", "major", "minor"} - repositories = []string{"frontend", "backend", "infra", "mobile", "shared-libs"} - branches = []string{"main", "develop", "feature"} +// Deployment sampling parameters. +const ( + // _deploySuccessRate is the share of deployments that succeed outside + // Friday afternoons. + _deploySuccessRate = 0.92 + + // _deployFridaySuccessRate is that share once Friday afternoon starts. + _deployFridaySuccessRate = 0.78 + + // _deployRollbackRate is the share of failed deployments rolled back. + _deployRollbackRate = 0.6 + + // _deployDurationMean and _deployDurationStdDev shape how long a + // deployment takes, in seconds. + _deployDurationMean = 120 + _deployDurationStdDev = 45 ) +// Incident sampling parameters. +const ( + // _incidentDetectRelStdDev spreads time-to-detect around its severity's + // mean, as a fraction of that mean. + _incidentDetectRelStdDev = 0.4 + + // _incidentResolveRelStdDev does the same for time-to-resolve. + _incidentResolveRelStdDev = 0.3 +) + +// Build sampling parameters. +const ( + // _buildFailureRate is the share of builds that fail any tests at all. + _buildFailureRate = 0.15 + + // _buildFailedTestsMean is the average number of tests that fail when a + // build fails. + _buildFailedTestsMean = 3 + + // _buildDurationMean and _buildDurationStdDev shape how long a build + // takes, in seconds. + _buildDurationMean = 180 + _buildDurationStdDev = 60 + + // _buildTestCountMean and _buildTestCountRelStdDev shape how many tests + // a build runs. + _buildTestCountMean = 850 + _buildTestCountRelStdDev = 0.1 + + // _buildCoverageMean and _buildCoverageStdDev shape reported coverage, + // which is then held within [_buildCoverageMin, _buildCoverageMax]. + _buildCoverageMean = 72 + _buildCoverageStdDev = 8 + _buildCoverageMin = 20 + _buildCoverageMax = 98 +) + +var ( + // _services holds the service names demo rows are attributed to. + _services = []string{"api-gateway", "user-service", "billing-service", "notification-service", "search-service"} + + // _environments holds the environments demo deployments target. + _environments = []string{"production", "staging"} + + // _severities holds the severity levels demo incidents are raised at. + _severities = []string{"critical", "major", "minor"} + + // _repositories holds the repository names demo builds run against. + _repositories = []string{"frontend", "backend", "infra", "mobile", "shared-libs"} + + // _branches holds the branch names demo builds run on. + _branches = []string{"main", "develop", "feature"} +) + +// Deployment specifies a single demo deployment event. type Deployment struct { - Time time.Time - Service string - Environment string + // Time specifies when the deployment happened. + Time time.Time + + // Service specifies the deployed service. + Service string + + // Environment specifies the environment deployed to. + Environment string + + // DurationSeconds specifies how long the deployment took. DurationSeconds float64 - Success bool - Rollback bool + + // Success indicates whether the deployment succeeded. + Success bool + + // Rollback indicates whether the deployment was rolled back. + Rollback bool } +// Incident specifies a single demo incident event. type Incident struct { - Time time.Time - Severity string - Service string - TimeToDetectMinutes float64 - TimeToResolveMinutes float64 + // Time specifies when the incident started. + Time time.Time + + // Severity specifies how severe the incident was. + Severity string + + // Service specifies the affected service. + Service string + + // TimeToDetectMinutes specifies how long the incident went unnoticed. + TimeToDetectMinutes float64 + + // TimeToResolveMinutes specifies how long the incident took to resolve. + TimeToResolveMinutes float64 } +// BuildMetric specifies a single demo CI build result. type BuildMetric struct { - Time time.Time - Repository string - Branch string + // Time specifies when the build ran. + Time time.Time + + // Repository specifies the built repository. + Repository string + + // Branch specifies the built branch. + Branch string + + // DurationSeconds specifies how long the build took. DurationSeconds float64 - TestCount int - TestsFailed int - CoveragePct float64 + + // TestCount specifies how many tests the build ran. + TestCount int + + // TestsFailed specifies how many of those tests failed. + TestsFailed int + + // CoveragePct specifies the coverage percentage the build reported. + CoveragePct float64 } +// Tick specifies everything generated for a single interval. type Tick struct { - Deployments []Deployment - Incidents []Incident + // Deployments holds the deployments generated for the tick. + Deployments []Deployment + + // Incidents holds the incidents generated for the tick. + Incidents []Incident + + // BuildMetrics holds the build results generated for the tick. BuildMetrics []BuildMetric } @@ -60,6 +169,8 @@ func GenerateTick(r *rand.Rand, t time.Time) Tick { } } +// genDeployments generates the deployments of a single tick, at a rate that +// peaks during weekday office hours and again on Friday afternoons. func genDeployments(r *rand.Rand, t time.Time) []Deployment { hour := t.Hour() weekday := t.Weekday() @@ -80,26 +191,28 @@ func genDeployments(r *rand.Rand, t time.Time) []Deployment { out := make([]Deployment, 0, count) for range count { - success := r.Float64() < 0.92 + success := r.Float64() < _deploySuccessRate if weekday == time.Friday && hour >= 15 { - success = r.Float64() < 0.78 + success = r.Float64() < _deployFridaySuccessRate } jitter := time.Duration(r.Intn(int(TickInterval.Seconds()))) * time.Second out = append(out, Deployment{ Time: t.Add(jitter), - Service: services[r.Intn(len(services))], - Environment: environments[r.Intn(len(environments))], - DurationSeconds: math.Abs(mockmetrics.Normal(r, 120, 45)), + Service: _services[r.Intn(len(_services))], + Environment: _environments[r.Intn(len(_environments))], + DurationSeconds: math.Abs(mockmetrics.Normal(r, _deployDurationMean, _deployDurationStdDev)), Success: success, - Rollback: !success && r.Float64() < 0.6, + Rollback: !success && r.Float64() < _deployRollbackRate, }) } return out } +// genIncidents generates the incidents of a single tick, at a rate that rises +// on weekdays and spikes on Friday afternoons. func genIncidents(r *rand.Rand, t time.Time) []Incident { hour := t.Hour() weekday := t.Weekday() @@ -116,7 +229,7 @@ func genIncidents(r *rand.Rand, t time.Time) []Incident { out := make([]Incident, 0, count) for range count { - severity := severities[r.Intn(len(severities))] + severity := _severities[r.Intn(len(_severities))] var detectMean, resolveMean float64 @@ -132,17 +245,19 @@ func genIncidents(r *rand.Rand, t time.Time) []Incident { jitter := time.Duration(r.Intn(int(TickInterval.Seconds()))) * time.Second out = append(out, Incident{ - Time: t.Add(jitter), - Severity: severity, - Service: services[r.Intn(len(services))], - TimeToDetectMinutes: math.Abs(mockmetrics.Normal(r, detectMean, detectMean*0.4)), - TimeToResolveMinutes: math.Abs(mockmetrics.Normal(r, resolveMean, resolveMean*0.3)), + Time: t.Add(jitter), + Severity: severity, + Service: _services[r.Intn(len(_services))], + TimeToDetectMinutes: math.Abs(mockmetrics.Normal(r, detectMean, detectMean*_incidentDetectRelStdDev)), + TimeToResolveMinutes: math.Abs(mockmetrics.Normal(r, resolveMean, resolveMean*_incidentResolveRelStdDev)), }) } return out } +// genBuildMetrics generates the CI builds of a single tick, at a rate that +// peaks during weekday office hours. func genBuildMetrics(r *rand.Rand, t time.Time) []BuildMetric { hour := t.Hour() weekday := t.Weekday() @@ -160,20 +275,24 @@ func genBuildMetrics(r *rand.Rand, t time.Time) []BuildMetric { for range count { testsFailed := 0 - if r.Float64() < 0.15 { - testsFailed = mockmetrics.Poisson(r, 3) + if r.Float64() < _buildFailureRate { + testsFailed = mockmetrics.Poisson(r, _buildFailedTestsMean) } jitter := time.Duration(r.Intn(int(TickInterval.Seconds()))) * time.Second out = append(out, BuildMetric{ Time: t.Add(jitter), - Repository: repositories[r.Intn(len(repositories))], - Branch: branches[r.Intn(len(branches))], - DurationSeconds: math.Abs(mockmetrics.Normal(r, 180, 60)), - TestCount: mockmetrics.RandCount(r, 850, 0.1), + Repository: _repositories[r.Intn(len(_repositories))], + Branch: _branches[r.Intn(len(_branches))], + DurationSeconds: math.Abs(mockmetrics.Normal(r, _buildDurationMean, _buildDurationStdDev)), + TestCount: mockmetrics.RandCount(r, _buildTestCountMean, _buildTestCountRelStdDev), TestsFailed: testsFailed, - CoveragePct: mockmetrics.Clamp(mockmetrics.Normal(r, 72, 8), 20, 98), + CoveragePct: mockmetrics.Clamp( + mockmetrics.Normal(r, _buildCoverageMean, _buildCoverageStdDev), + _buildCoverageMin, + _buildCoverageMax, + ), }) } diff --git a/datagen/internal/demodata/demodata_test.go b/datagen/internal/demodata/demodata_test.go new file mode 100644 index 00000000..2b567246 --- /dev/null +++ b/datagen/internal/demodata/demodata_test.go @@ -0,0 +1,262 @@ +package demodata + +import ( + "math/rand" + "slices" + "testing" + "time" + + "github.com/oxynote/oxynote/datagen/internal/mockmetrics" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +// Reference instants for each rate branch the generators switch on. +var ( + // _sunday is outside the working week entirely. + _sunday = time.Date(2024, time.January, 7, 12, 0, 0, 0, time.UTC) + + // _tuesdayNight is a weekday outside office hours. + _tuesdayNight = time.Date(2024, time.January, 9, 3, 0, 0, 0, time.UTC) + + // _tuesdayOffice is a weekday inside office hours. + _tuesdayOffice = time.Date(2024, time.January, 9, 11, 0, 0, 0, time.UTC) + + // _fridayAfternoon is when everything gets worse. + _fridayAfternoon = time.Date(2024, time.January, 12, 16, 0, 0, 0, time.UTC) +) + +func Test_GenerateTick(t *testing.T) { + t.Parallel() + + // a tick carries whatever each generator produced for the same instant. + tick := GenerateTick(mockmetrics.NewRand(1), _tuesdayOffice) + + assert.Equal(t, genDeployments(mockmetrics.NewRand(1), _tuesdayOffice), tick.Deployments) + + // the generators share one source, so replaying them in order is the + // only way to reproduce the later two. + r := mockmetrics.NewRand(1) + genDeployments(r, _tuesdayOffice) + + assert.Equal(t, genIncidents(r, _tuesdayOffice), tick.Incidents) + assert.Equal(t, genBuildMetrics(r, _tuesdayOffice), tick.BuildMetrics) +} + +func Test_genDeployments(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Time time.Time + + // MinRows is the number of rows the case must produce across its + // draws for the branch to count as exercised. + MinRows int + }{ + "Weekend runs at the lowest rate": { + Time: _sunday, + MinRows: 1, + }, + "Weekday outside office hours": { + Time: _tuesdayNight, + MinRows: 1, + }, + "Weekday office hours": { + Time: _tuesdayOffice, + MinRows: 1, + }, + "Friday afternoon": { + Time: _fridayAfternoon, + MinRows: 1, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + var ( + r = mockmetrics.NewRand(1) + rows = make([]Deployment, 0, 200) + ) + + for range 200 { + rows = append(rows, genDeployments(r, c.Time)...) + } + + require.GreaterOrEqual(t, len(rows), c.MinRows) + + for _, d := range rows { + assert.Contains(t, _services, d.Service) + assert.Contains(t, _environments, d.Environment) + assert.GreaterOrEqual(t, d.DurationSeconds, 0.0) + assertJittered(t, c.Time, d.Time) + + // a successful deployment is never rolled back. + if d.Success { + assert.False(t, d.Rollback) + } + } + }) + } + + // the branches exist to make busier periods busier, so the row counts + // have to come out ordered. + assert.Less(t, countDeployments(t, _sunday), countDeployments(t, _tuesdayOffice)) + assert.Less(t, countDeployments(t, _tuesdayNight), countDeployments(t, _tuesdayOffice)) + assert.Less(t, countDeployments(t, _tuesdayOffice), countDeployments(t, _fridayAfternoon)) +} + +func Test_genIncidents(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Time time.Time + }{ + "Weekend runs at the lowest rate": {Time: _sunday}, + "Weekday": {Time: _tuesdayOffice}, + "Friday afternoon": {Time: _fridayAfternoon}, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + var ( + r = mockmetrics.NewRand(1) + rows = make([]Incident, 0, 500) + ) + + for range 500 { + rows = append(rows, genIncidents(r, c.Time)...) + } + + require.NotEmpty(t, rows) + + var seen []string + + for _, i := range rows { + assert.Contains(t, _severities, i.Severity) + assert.Contains(t, _services, i.Service) + assert.GreaterOrEqual(t, i.TimeToDetectMinutes, 0.0) + assert.GreaterOrEqual(t, i.TimeToResolveMinutes, 0.0) + assertJittered(t, c.Time, i.Time) + + if !slices.Contains(seen, i.Severity) { + seen = append(seen, i.Severity) + } + } + + // every severity carries its own detect/resolve means, so the + // draw has to cover all three branches of the switch. + assert.ElementsMatch(t, _severities, seen) + }) + } + + // busier periods must come out busier. + assert.Less(t, countIncidents(t, _sunday), countIncidents(t, _tuesdayOffice)) + assert.Less(t, countIncidents(t, _tuesdayOffice), countIncidents(t, _fridayAfternoon)) +} + +func Test_genBuildMetrics(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Time time.Time + }{ + "Weekend runs at the lowest rate": {Time: _sunday}, + "Weekday outside office hours": {Time: _tuesdayNight}, + "Weekday office hours": {Time: _tuesdayOffice}, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + var ( + r = mockmetrics.NewRand(1) + rows = make([]BuildMetric, 0, 200) + ) + + for range 200 { + rows = append(rows, genBuildMetrics(r, c.Time)...) + } + + require.NotEmpty(t, rows) + + var sawFailures bool + + for _, b := range rows { + assert.Contains(t, _repositories, b.Repository) + assert.Contains(t, _branches, b.Branch) + assert.GreaterOrEqual(t, b.DurationSeconds, 0.0) + assert.GreaterOrEqual(t, b.TestCount, 0) + assert.GreaterOrEqual(t, b.TestsFailed, 0) + assert.GreaterOrEqual(t, b.CoveragePct, float64(_buildCoverageMin)) + assert.LessOrEqual(t, b.CoveragePct, float64(_buildCoverageMax)) + assertJittered(t, c.Time, b.Time) + + if b.TestsFailed > 0 { + sawFailures = true + } + } + + // the failing-build branch is rare but has to be reached. + assert.True(t, sawFailures) + }) + } + + // busier periods must come out busier. + assert.Less(t, countBuilds(t, _sunday), countBuilds(t, _tuesdayOffice)) + assert.Less(t, countBuilds(t, _tuesdayNight), countBuilds(t, _tuesdayOffice)) +} + +// assertJittered checks that a generated row landed inside the tick it was +// generated for. +func assertJittered(t *testing.T, tick, got time.Time) { + t.Helper() + + assert.False(t, got.Before(tick)) + assert.True(t, got.Before(tick.Add(TickInterval))) +} + +// countDeployments totals the deployments generated for one instant over +// enough draws for the rates to separate. +func countDeployments(t *testing.T, at time.Time) int { + t.Helper() + + return countRows(func(r *rand.Rand) int { return len(genDeployments(r, at)) }) +} + +// countIncidents does the same for incidents. +func countIncidents(t *testing.T, at time.Time) int { + t.Helper() + + return countRows(func(r *rand.Rand) int { return len(genIncidents(r, at)) }) +} + +// countBuilds does the same for builds. +func countBuilds(t *testing.T, at time.Time) int { + t.Helper() + + return countRows(func(r *rand.Rand) int { return len(genBuildMetrics(r, at)) }) +} + +// countRows sums what fn generates over a fixed number of draws from a fixed +// seed, so the totals of two instants are comparable. +func countRows(fn func(*rand.Rand) int) int { + r := mockmetrics.NewRand(1) + + var total int + + for range 2_000 { + total += fn(r) + } + + return total +} diff --git a/datagen/internal/gen/manager.go b/datagen/internal/gen/manager.go index 7b1d80ab..c2e401ef 100644 --- a/datagen/internal/gen/manager.go +++ b/datagen/internal/gen/manager.go @@ -1,3 +1,5 @@ +// Package gen generates the mock engineering-culture metrics the demo +// Prometheus scrapes. package gen import ( @@ -8,12 +10,32 @@ import ( "github.com/oxynote/oxynote/datagen/internal/mockmetrics" "github.com/oxynote/oxynote/server/core/pkg/metricutil" + "github.com/oxynote/oxynote/server/core/pkg/timeutil" "github.com/prometheus/client_golang/prometheus" ) // _genInterval defines how often metrics are refreshed. const _genInterval = 5 * time.Minute +// _labelOutcome names the label both outcome-split gauges are broken down by. +const _labelOutcome = "outcome" + +// Sampling parameters of the friday_deploys gauge, whose two buckets are +// derived from one another instead of being sampled independently. +const ( + // _fridayDeployMean is the weekly number of Friday deploys attempted. + _fridayDeployMean = 30_000 + + // _fridayDeployRelStdDev spreads that count tick to tick. + _fridayDeployRelStdDev = 0.15 + + // _fridayDeploySuccessMean is the mean fraction of attempts that survive. + _fridayDeploySuccessMean = 0.68 + + // _fridayDeploySuccessStdDev spreads that fraction tick to tick. + _fridayDeploySuccessStdDev = 0.12 +) + // continuousMetric holds the running state for a single drifting gauge. type continuousMetric struct { // value is the current gauge reading, updated on each tick. @@ -52,6 +74,9 @@ type Manager struct { // log is the structured logger. log *slog.Logger + // interval defines how often metrics are refreshed. + interval time.Duration + // Continuous metric states — these drift smoothly between ticks. // tempFixAge states by original_comment label — hacks live the longest. @@ -137,15 +162,17 @@ type Manager struct { } // NewManager creates a ready-to-run Manager. +// +//nolint:maintidx // a flat table of tuned gauge parameters, not logic func NewManager(fc metricutil.Factory, log *slog.Logger) *Manager { newGaugeVec := func(name, help string, labels ...string) metricutil.GaugeVec { return fc.NewGaugeVec(metricutil.Options{Name: name, Help: help}, labels) } return &Manager{ - r: mockmetrics.NewRand(time.Now().UnixNano()), - log: log, - + r: mockmetrics.NewRand(timeutil.Now().UnixNano()), + log: log, + interval: _genInterval, // Temporary fix age by comment type — hacks accumulate and never leave. tempFixAgeTemp: newContinuous(mockmetrics.GaugeParams{ Min: 365, @@ -370,7 +397,6 @@ func NewManager(fc metricutil.Factory, log *slog.Logger) *Manager { SpikeStdDev: 30_000, }), - // Prometheus gauges. meetingsCouldbDoc: newGaugeVec( "meetings_could_be_doc_count", "Weekly count of meetings that could've been a document.", @@ -379,12 +405,12 @@ func NewManager(fc metricutil.Factory, log *slog.Logger) *Manager { circleBackCount: newGaugeVec( "circle_back_count", `Weekly count of "let's circle back" mentions.`, - "outcome", + _labelOutcome, ), fridayDeploys: newGaugeVec( "friday_deploys_count", "Weekly Friday deploy counts.", - "outcome", + _labelOutcome, ), featureFlagGrave: newGaugeVec( "feature_flag_graveyard_size", @@ -449,16 +475,35 @@ func NewManager(fc metricutil.Factory, log *slog.Logger) *Manager { } // Run starts the generation loop. It blocks until ctx is cancelled. -func (m *Manager) Run(ctx context.Context) error { - m.generateData() +func (m *Manager) Run(ctx context.Context) { + timeutil.NewPeriodicExec( + m.interval, + 0, + func(context.Context) { m.generateData() }, + nil, + true, + ).Start(ctx) +} - for { - select { - case <-ctx.Done(): - return nil - case <-time.After(_genInterval): - m.generateData() - } +// countBucket specifies the sampling parameters of one labelled bucket of a +// gauge that is resampled from scratch on every tick. +type countBucket struct { + // label is the label value the bucket is published under. + label string + + // mean is the average count sampled for the bucket each tick. + mean float64 + + // relStdDev is the bucket's standard deviation as a fraction of mean. + relStdDev float64 +} + +// setCounts resamples every bucket of a gauge and publishes the results +// under the given label key. +func (m *Manager) setCounts(vec metricutil.GaugeVec, key string, bb []countBucket) { + for _, b := range bb { + vec.With(prometheus.Labels{key: b.label}). + Set(float64(mockmetrics.RandCount(m.r, b.mean, b.relStdDev))) } } @@ -466,52 +511,72 @@ func (m *Manager) Run(ctx context.Context) error { func (m *Manager) generateData() { r := m.r - // --- Poisson count metrics (fresh each tick) --- + // --- Count metrics (resampled from scratch each tick) --- - // Lambdas are aggregate weekly totals across ~10k organisations. + // Means are aggregate weekly totals across ~10k organisations. // meetings_could_be_doc: split by the excuse used to justify the meeting. - m.meetingsCouldbDoc.With(prometheus.Labels{"excuse": "alignment"}).Set(float64(mockmetrics.RandCount(r, 42_000, 0.15))) - m.meetingsCouldbDoc.With(prometheus.Labels{"excuse": "bandwidth"}).Set(float64(mockmetrics.RandCount(r, 36_000, 0.15))) - m.meetingsCouldbDoc.With(prometheus.Labels{"excuse": "synergy"}).Set(float64(mockmetrics.RandCount(r, 30_000, 0.15))) - m.meetingsCouldbDoc.With(prometheus.Labels{"excuse": "optics"}).Set(float64(mockmetrics.RandCount(r, 12_000, 0.20))) + m.setCounts(m.meetingsCouldbDoc, "excuse", []countBucket{ + {label: "alignment", mean: 42_000, relStdDev: 0.15}, + {label: "bandwidth", mean: 36_000, relStdDev: 0.15}, + {label: "synergy", mean: 30_000, relStdDev: 0.15}, + {label: "optics", mean: 12_000, relStdDev: 0.20}, + }) // circle_back_count: split by what actually happened afterward. - m.circleBackCount.With(prometheus.Labels{"outcome": "circled_back"}).Set(float64(mockmetrics.RandCount(r, 16_000, 0.20))) - m.circleBackCount.With(prometheus.Labels{"outcome": "forgotten"}).Set(float64(mockmetrics.RandCount(r, 48_000, 0.15))) - m.circleBackCount.With(prometheus.Labels{"outcome": "rescheduled_to_circle_back"}).Set(float64(mockmetrics.RandCount(r, 16_000, 0.20))) + m.setCounts(m.circleBackCount, _labelOutcome, []countBucket{ + {label: "circled_back", mean: 16_000, relStdDev: 0.20}, + {label: "forgotten", mean: 48_000, relStdDev: 0.15}, + {label: "rescheduled_to_circle_back", mean: 16_000, relStdDev: 0.20}, + }) - // friday_deploys: outcome (attempted vs successful). - attempted := mockmetrics.RandCount(r, 30_000, 0.15) - successRate := mockmetrics.Clamp(mockmetrics.Normal(r, 0.68, 0.12), 0, 1) + // friday_deploys: the successful bucket is a fraction of the attempted + // one rather than an independent sample, so it cannot join a table. + attempted := mockmetrics.RandCount(r, _fridayDeployMean, _fridayDeployRelStdDev) + successRate := mockmetrics.Clamp( + mockmetrics.Normal(r, _fridayDeploySuccessMean, _fridayDeploySuccessStdDev), + 0, + 1, + ) successful := int(mockmetrics.Clamp(float64(attempted)*successRate, 0, float64(attempted))) - m.fridayDeploys.With(prometheus.Labels{"outcome": "attempted"}).Set(float64(attempted)) - m.fridayDeploys.With(prometheus.Labels{"outcome": "successful"}).Set(float64(successful)) + + m.fridayDeploys.With(prometheus.Labels{_labelOutcome: "attempted"}).Set(float64(attempted)) + m.fridayDeploys.With(prometheus.Labels{_labelOutcome: "successful"}).Set(float64(successful)) // pizza_fridays: split by how many pizzas were ordered. - m.pizzaFridays.With(prometheus.Labels{"pizza_count": "1_to_5"}).Set(float64(mockmetrics.RandCount(r, 10_000, 0.15))) - m.pizzaFridays.With(prometheus.Labels{"pizza_count": "5_to_10"}).Set(float64(mockmetrics.RandCount(r, 7_000, 0.20))) - m.pizzaFridays.With(prometheus.Labels{"pizza_count": "10_plus"}).Set(float64(mockmetrics.RandCount(r, 3_000, 0.25))) + m.setCounts(m.pizzaFridays, "pizza_count", []countBucket{ + {label: "1_to_5", mean: 10_000, relStdDev: 0.15}, + {label: "5_to_10", mean: 7_000, relStdDev: 0.20}, + {label: "10_plus", mean: 3_000, relStdDev: 0.25}, + }) // ship_it_approvals: split by reviewer's apparent state of mind. - m.shipItApprovals.With(prometheus.Labels{"reviewer_state": "sober"}).Set(float64(mockmetrics.RandCount(r, 20_000, 0.15))) - m.shipItApprovals.With(prometheus.Labels{"reviewer_state": "one_beer_in"}).Set(float64(mockmetrics.RandCount(r, 14_000, 0.20))) - m.shipItApprovals.With(prometheus.Labels{"reviewer_state": "heading_to_the_door"}).Set(float64(mockmetrics.RandCount(r, 6_000, 0.25))) + m.setCounts(m.shipItApprovals, "reviewer_state", []countBucket{ + {label: "sober", mean: 20_000, relStdDev: 0.15}, + {label: "one_beer_in", mean: 14_000, relStdDev: 0.20}, + {label: "heading_to_the_door", mean: 6_000, relStdDev: 0.25}, + }) // prs_merged_friday: split by the deployer's stated confidence level. - m.prsMergedFriday.With(prometheus.Labels{"confidence": "yolo"}).Set(float64(mockmetrics.RandCount(r, 20_000, 0.15))) - m.prsMergedFriday.With(prometheus.Labels{"confidence": "fingers_crossed"}).Set(float64(mockmetrics.RandCount(r, 22_500, 0.15))) - m.prsMergedFriday.With(prometheus.Labels{"confidence": "vacation_starts_monday"}).Set(float64(mockmetrics.RandCount(r, 7_500, 0.20))) + m.setCounts(m.prsMergedFriday, "confidence", []countBucket{ + {label: "yolo", mean: 20_000, relStdDev: 0.15}, + {label: "fingers_crossed", mean: 22_500, relStdDev: 0.15}, + {label: "vacation_starts_monday", mean: 7_500, relStdDev: 0.20}, + }) // secrets_detected: split by severity — nuclear is rare but very bad. - m.secretsDetected.With(prometheus.Labels{"severity": "normal"}).Set(float64(mockmetrics.RandCount(r, 2_100, 0.25))) - m.secretsDetected.With(prometheus.Labels{"severity": "critical"}).Set(float64(mockmetrics.RandCount(r, 750, 0.30))) - m.secretsDetected.With(prometheus.Labels{"severity": "nuclear"}).Set(float64(mockmetrics.RandCount(r, 150, 0.35))) + m.setCounts(m.secretsDetected, "severity", []countBucket{ + {label: "normal", mean: 2_100, relStdDev: 0.25}, + {label: "critical", mean: 750, relStdDev: 0.30}, + {label: "nuclear", mean: 150, relStdDev: 0.35}, + }) // quick_sync_meetings: split by how "quick" they actually were. - m.quickSyncMeetings.With(prometheus.Labels{"duration": "10m_to_30m"}).Set(float64(mockmetrics.RandCount(r, 33_000, 0.15))) - m.quickSyncMeetings.With(prometheus.Labels{"duration": "30m_to_1h"}).Set(float64(mockmetrics.RandCount(r, 21_000, 0.15))) - m.quickSyncMeetings.With(prometheus.Labels{"duration": "1h_plus"}).Set(float64(mockmetrics.RandCount(r, 6_000, 0.25))) + m.setCounts(m.quickSyncMeetings, "duration", []countBucket{ + {label: "10m_to_30m", mean: 33_000, relStdDev: 0.15}, + {label: "30m_to_1h", mean: 21_000, relStdDev: 0.15}, + {label: "1h_plus", mean: 6_000, relStdDev: 0.25}, + }) // --- Continuous drifting metrics --- diff --git a/datagen/internal/gen/manager_test.go b/datagen/internal/gen/manager_test.go new file mode 100644 index 00000000..8e57213c --- /dev/null +++ b/datagen/internal/gen/manager_test.go @@ -0,0 +1,303 @@ +package gen + +import ( + "context" + "log/slog" + "reflect" + "testing" + "time" + + "github.com/oxynote/oxynote/datagen/internal/mockmetrics" + "github.com/oxynote/oxynote/server/core/pkg/metricutil" + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func Test_newContinuous(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Params mockmetrics.GaugeParams + Result float64 + }{ + "Start within the bounds is kept": { + Params: mockmetrics.GaugeParams{Min: 0, Max: 100, Start: 40}, + Result: 40, + }, + "Start below the lower bound is raised": { + Params: mockmetrics.GaugeParams{Min: 10, Max: 100, Start: 5}, + Result: 10, + }, + "Start above the upper bound is lowered": { + Params: mockmetrics.GaugeParams{Min: 0, Max: 50, Start: 90}, + Result: 50, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + m := newContinuous(c.Params) + require.NotNil(t, m) + + assert.InDelta(t, c.Result, m.value, 1e-9) + assert.Equal(t, c.Params, m.p) + }) + } +} + +func Test_continuousMetric_tick(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Params mockmetrics.GaugeParams + + // Result is the value the single tick must produce exactly, used + // where the parameters leave no randomness in play. + Result *float64 + + // Ticks is how many times to advance when the case asserts over a + // run rather than a single step. + Ticks int + }{ + "Static parameters hold the value still": { + Params: mockmetrics.GaugeParams{Min: 0, Max: 100, Start: 50}, + Result: new(50.0), + }, + "Drift moves the value by a fixed step": { + Params: mockmetrics.GaugeParams{Min: 0, Max: 100, Start: 50, DriftPerStep: 2}, + Result: new(52.0), + }, + "Mean reversion pulls the value toward the target": { + Params: mockmetrics.GaugeParams{Min: 0, Max: 100, Start: 50, Target: 100, MeanReversion: 0.5}, + Result: new(75.0), + }, + "Drift is held at the upper bound": { + Params: mockmetrics.GaugeParams{Min: 0, Max: 50, Start: 50, DriftPerStep: 10}, + Result: new(50.0), + }, + "Drift is held at the lower bound": { + Params: mockmetrics.GaugeParams{Min: 10, Max: 100, Start: 10, DriftPerStep: -10}, + Result: new(10.0), + }, + "Noise keeps the value inside the bounds": { + Params: mockmetrics.GaugeParams{Min: 0, Max: 100, Start: 50, NoiseStdDev: 40}, + Ticks: 1_000, + }, + "A certain spike keeps the value inside the bounds": { + Params: mockmetrics.GaugeParams{Min: 0, Max: 100, Start: 50, SpikeChance: 1, SpikeStdDev: 80}, + Ticks: 1_000, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + var ( + m = newContinuous(c.Params) + r = mockmetrics.NewRand(1) + ) + + if c.Result != nil { + v := m.tick(r) + + assert.InDelta(t, *c.Result, v, 1e-9) + + // the returned value is the state the next tick builds on. + assert.InDelta(t, v, m.value, 1e-9) + + return + } + + for range c.Ticks { + v := m.tick(r) + + assert.GreaterOrEqual(t, v, c.Params.Min) + assert.LessOrEqual(t, v, c.Params.Max) + assert.InDelta(t, v, m.value, 1e-9) + } + }) + } +} + +func Test_NewManager(t *testing.T) { + t.Parallel() + + log := slog.New(slog.DiscardHandler) + + m := NewManager(newFactory(), log) + require.NotNil(t, m) + + assert.NotNil(t, m.r) + assert.Equal(t, log, m.log) + assert.Equal(t, _genInterval, m.interval) + + // every drifting state and every gauge has to be wired, which the + // compiler cannot check for a struct built field by field. + v := reflect.ValueOf(*m) + + for i := range v.NumField() { + name := v.Type().Field(i).Name + + switch v.Type().Field(i).Type.String() { + case "*gen.continuousMetric", "metricutil.GaugeVec": + assert.False(t, v.Field(i).IsNil(), name) + } + } +} + +func Test_Manager_Run(t *testing.T) { + t.Parallel() + + fc := newFactory() + + m := NewManager(fc, slog.New(slog.DiscardHandler)) + m.interval = time.Hour + + ctx, cancel := context.WithCancel(context.Background()) + + stopCh := make(chan struct{}) + + go func() { + defer close(stopCh) + + m.Run(ctx) + }() + + // the first generation is immediate, so the gauges carry values well + // before the hour-long interval could elapse. + require.Eventually(t, func() bool { + ff, err := fc.Gather() + + return err == nil && len(ff) > 0 + }, 5*time.Second, 10*time.Millisecond) + + cancel() + + <-stopCh +} + +func Test_Manager_setCounts(t *testing.T) { + t.Parallel() + + fc := newFactory() + vec := fc.NewGaugeVec(metricutil.Options{Name: "test_counts", Help: "Tracks nothing."}, []string{"kind"}) + + m := &Manager{r: mockmetrics.NewRand(1)} + m.setCounts(vec, "kind", []countBucket{ + {label: "first", mean: 1_000, relStdDev: 0.1}, + {label: "second", mean: 2_000, relStdDev: 0.1}, + }) + + values := gathered(t, fc, "engineering_test_counts") + require.Len(t, values, 2) + + assert.InDelta(t, 1_000, values["first"], 500) + assert.InDelta(t, 2_000, values["second"], 1_000) +} + +func Test_Manager_generateData(t *testing.T) { + t.Parallel() + + fc := newFactory() + + m := NewManager(fc, slog.New(slog.DiscardHandler)) + m.generateData() + + // every declared gauge has to report at least one labelled value. + cc := map[string]int{ + "engineering_meetings_could_be_doc_count": 4, + "engineering_circle_back_count": 3, + "engineering_friday_deploys_count": 2, + "engineering_feature_flag_graveyard_size": 3, + "engineering_pizza_fridays_next_30_days": 3, + "engineering_deploy_confidence_index": 3, + "engineering_ship_it_approvals_after_5pm_count": 3, + "engineering_prs_merged_friday_afternoon_count": 3, + "engineering_temporary_fix_age_days": 4, + "engineering_stale_branches_total": 3, + "engineering_pr_time_to_first_review_minutes": 1, + "engineering_hotfixes_per_release": 3, + "engineering_secrets_detected_commits_count": 3, + "engineering_works_on_my_machine_rate": 3, + "engineering_quick_sync_meetings_same_day_count": 3, + } + + // these subtests read the registry the rest of this function goes on to + // write to, so they stay sequential rather than deferring past it. + for name, count := range cc { + t.Run(name, func(t *testing.T) { + assert.Len(t, gathered(t, fc, name), count) + }) + } + + // friday deploys are derived rather than sampled, so successes can + // never outnumber attempts. + deploys := gathered(t, fc, "engineering_friday_deploys_count") + assert.LessOrEqual(t, deploys["successful"], deploys["attempted"]) + + // a second pass moves the drifting gauges on from where they stopped + // rather than restarting them. + before := gathered(t, fc, "engineering_pr_time_to_first_review_minutes") + + m.generateData() + + after := gathered(t, fc, "engineering_pr_time_to_first_review_minutes") + + assert.NotEqual(t, before, after) +} + +// newFactory creates a metrics factory backed by a registry of its own, so +// parallel tests never collide over metric names. +func newFactory() metricutil.Factory { + return metricutil.NewFactory( + "engineering", + prometheus.NewRegistry(), + metricutil.WithCustomHost("demo"), + ) +} + +// gathered collects the current values of one metric family, keyed by the +// value of the single label that splits it (or the empty string when the +// family carries no label of its own). +func gathered(t *testing.T, fc metricutil.Factory, name string) map[string]float64 { + t.Helper() + + ff, err := fc.Gather() + require.NoError(t, err) + + out := map[string]float64{} + + for _, f := range ff { + if f.GetName() != name { + continue + } + + for _, m := range f.GetMetric() { + out[labelValue(m)] = m.GetGauge().GetValue() + } + } + + return out +} + +// labelValue returns the value of the metric's own label, skipping the host +// label the factory attaches to everything. +func labelValue(m *dto.Metric) string { + for _, l := range m.GetLabel() { + if l.GetName() != "host" { + return l.GetValue() + } + } + + return "" +} diff --git a/datagen/internal/mariademo/mariademo.go b/datagen/internal/mariademo/mariademo.go index 16dff873..6860d12d 100644 --- a/datagen/internal/mariademo/mariademo.go +++ b/datagen/internal/mariademo/mariademo.go @@ -1,3 +1,5 @@ +// Package mariademo fills the demo MariaDB data source with generated +// time-series rows. package mariademo import ( @@ -8,26 +10,45 @@ import ( "math/rand" "time" + // the mysql driver registers itself with database/sql on import. _ "github.com/go-sql-driver/mysql" "github.com/oxynote/oxynote/datagen/internal/demodata" "github.com/oxynote/oxynote/datagen/internal/mockmetrics" + "github.com/oxynote/oxynote/server/core/pkg/timeutil" ) +// _backfillDays defines how much history an empty database is filled with. const _backfillDays = 30 +// _connMaxLifetime bounds how long the single pooled connection is reused. +const _connMaxLifetime = 30 * time.Second + // Generator inserts demo time-series data into MariaDB. type Generator struct { - dsn string - r *rand.Rand + // log is the structured logger. log *slog.Logger + + // dsn addresses the database to fill. + dsn string + + // r is the random source used for all row generation. + r *rand.Rand + + // interval defines how often a new tick of rows is appended. + interval time.Duration + + // backfillDays defines how much history an empty database is filled with. + backfillDays int } -// NewGenerator creates a new demo data generator for MariaDB. +// NewGenerator creates a fresh instance of Generator. func NewGenerator(dsn string, log *slog.Logger) *Generator { return &Generator{ - dsn: dsn, - r: mockmetrics.NewRand(time.Now().UnixNano()), - log: log, + log: log, + dsn: dsn, + r: mockmetrics.NewRand(timeutil.Now().UnixNano()), + interval: demodata.TickInterval, + backfillDays: _backfillDays, } } @@ -38,13 +59,13 @@ func (g *Generator) Run(ctx context.Context) error { if err != nil { return fmt.Errorf("mariademo: open: %w", err) } - defer db.Close() + defer db.Close() //nolint:errcheck // error provides no meaningful info db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) - db.SetConnMaxLifetime(30 * time.Second) + db.SetConnMaxLifetime(_connMaxLifetime) - if err := db.PingContext(ctx); err != nil { + if err = db.PingContext(ctx); err != nil { return fmt.Errorf("mariademo: ping: %w", err) } @@ -54,7 +75,7 @@ func (g *Generator) Run(ctx context.Context) error { } if !backfilled { - g.log.Info("mariademo: backfilling historical data", slog.Int("days", _backfillDays)) + g.log.Info("mariademo: backfilling historical data", slog.Int("days", g.backfillDays)) if err := g.backfill(ctx, db); err != nil { return fmt.Errorf("mariademo: backfill: %w", err) @@ -63,20 +84,22 @@ func (g *Generator) Run(ctx context.Context) error { g.log.Info("mariademo: backfill complete") } - for { - select { - case <-ctx.Done(): - return nil - case <-time.After(demodata.TickInterval): - now := time.Now().UTC() - - if err := g.insertTick(ctx, db, now); err != nil { + timeutil.NewPeriodicExec( + g.interval, + 0, + func(ctx context.Context) { + if err := g.insertTick(ctx, db, timeutil.Now()); err != nil { g.log.Error("mariademo: tick insert failed", slog.String("error", err.Error())) } - } - } + }, + nil, + false, + ).Start(ctx) + + return nil } +// needsBackfill returns true if historical data already exists. func (g *Generator) needsBackfill(ctx context.Context, db *sql.DB) (bool, error) { var count int @@ -88,11 +111,12 @@ func (g *Generator) needsBackfill(ctx context.Context, db *sql.DB) (bool, error) return count > 0, nil } +// backfill generates backfillDays worth of historical data. func (g *Generator) backfill(ctx context.Context, db *sql.DB) error { - now := time.Now().UTC() - start := now.AddDate(0, 0, -_backfillDays) + now := timeutil.Now() + start := now.AddDate(0, 0, -g.backfillDays) - for t := start; t.Before(now); t = t.Add(demodata.TickInterval) { + for t := start; t.Before(now); t = t.Add(g.interval) { if err := g.insertTick(ctx, db, t); err != nil { return err } @@ -101,6 +125,7 @@ func (g *Generator) backfill(ctx context.Context, db *sql.DB) error { return nil } +// insertTick generates and inserts one tick's worth of data at the given time. func (g *Generator) insertTick(ctx context.Context, db *sql.DB, t time.Time) error { tick := demodata.GenerateTick(g.r, t) @@ -108,7 +133,7 @@ func (g *Generator) insertTick(ctx context.Context, db *sql.DB, t time.Time) err if err != nil { return fmt.Errorf("begin tx at %s: %w", t.Format(time.RFC3339), err) } - defer tx.Rollback() + defer tx.Rollback() //nolint:errcheck // error provides no meaningful info for _, d := range tick.Deployments { if _, err := tx.ExecContext(ctx, diff --git a/datagen/internal/mariademo/mariademo_test.go b/datagen/internal/mariademo/mariademo_test.go new file mode 100644 index 00000000..a580d0e1 --- /dev/null +++ b/datagen/internal/mariademo/mariademo_test.go @@ -0,0 +1,566 @@ +package mariademo + +import ( + "context" + "database/sql" + "fmt" + "io" + "log" + "log/slog" + "strconv" + "strings" + "testing" + "time" + + mysqlDriver "github.com/go-sql-driver/mysql" + "github.com/orlangure/gnomock" + "github.com/oxynote/oxynote/datagen/internal/demodata" + "github.com/oxynote/oxynote/datagen/internal/mockmetrics" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +const ( + // _testDB is the database name used in the throwaway container. + _testDB = "testdb" + + // _rootUser is the privileged container user. + _rootUser = "root" + + // _rootPass is the privileged container password. + _rootPass = "rootpass" +) + +// _schema mirrors docker/demo/mariadb/init-demo-db.sql, which is what the +// demo data source is really created with. +var _schema = []string{ + `CREATE TABLE deployments ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + time DATETIME NOT NULL, + service VARCHAR(255) NOT NULL, + environment VARCHAR(255) NOT NULL, + duration_seconds DOUBLE NOT NULL, + success BOOLEAN NOT NULL, + rollback BOOLEAN NOT NULL DEFAULT FALSE + )`, + `CREATE TABLE incidents ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + time DATETIME NOT NULL, + severity VARCHAR(255) NOT NULL, + service VARCHAR(255) NOT NULL, + time_to_detect_minutes DOUBLE NOT NULL, + time_to_resolve_minutes DOUBLE NOT NULL + )`, + `CREATE TABLE build_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + time DATETIME NOT NULL, + repository VARCHAR(255) NOT NULL, + branch VARCHAR(255) NOT NULL, + duration_seconds DOUBLE NOT NULL, + test_count INT NOT NULL, + tests_failed INT NOT NULL, + coverage_pct DOUBLE NOT NULL + )`, +} + +// _addr is the address of the throwaway MariaDB container. +var _addr string + +// _tick is a weekday office-hours instant, busy enough that every table gets +// rows. +var _tick = time.Date(2024, time.January, 9, 11, 0, 0, 0, time.UTC) + +func TestMain(m *testing.M) { + // silence the driver's connection noise while the container boots. + if err := mysqlDriver.SetLogger(log.New(io.Discard, "", 0)); err != nil { + panic("cannot set mysql logger: " + err.Error()) + } + + container, err := gnomock.StartCustom( + "docker.io/library/mariadb:11-noble", + gnomock.DefaultTCP(3306), + gnomock.WithEnv("MARIADB_ROOT_PASSWORD="+_rootPass), + gnomock.WithEnv("MARIADB_DATABASE="+_testDB), + gnomock.WithHealthCheck(healthcheck), + ) + if err != nil { + panic("cannot set up mariadb: " + err.Error()) + } + + defer func() { + if err = gnomock.Stop(container); err != nil { + panic("cannot clean up mariadb: " + err.Error()) + } + }() + + _addr = container.Host + ":" + strconv.Itoa(container.DefaultPort()) + + goleak.VerifyTestMain(m, goleak.IgnoreCurrent()) +} + +// healthcheck reports whether the container accepts root connections. +func healthcheck(ctx context.Context, c *gnomock.Container) error { + db, err := sql.Open("mysql", fmt.Sprintf( + "%s:%s@tcp(%s)/%s", + _rootUser, + _rootPass, + c.Address(gnomock.DefaultPort), + _testDB, + )) + if err != nil { + return err + } + + defer db.Close() //nolint:errcheck // error provides no meaningful info + + return db.PingContext(ctx) +} + +func Test_NewGenerator(t *testing.T) { + t.Parallel() + + lg := slog.New(slog.DiscardHandler) + + g := NewGenerator("user:pass@tcp(host)/db", lg) + require.NotNil(t, g) + + assert.Equal(t, lg, g.log) + assert.Equal(t, "user:pass@tcp(host)/db", g.dsn) + assert.NotNil(t, g.r) + assert.Equal(t, demodata.TickInterval, g.interval) + assert.Equal(t, _backfillDays, g.backfillDays) +} + +func Test_Generator_Run(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + // DSN overrides the throwaway database's DSN, for the cases that + // must fail before any query runs. + DSN string + + // DropTable names a table to remove before the run, so a query + // fails against a real connection. + DropTable string + + Err error + }{ + "Error returned by sql.Open": { + DSN: "not a dsn at all", + Err: assert.AnError, + }, + "Error returned by the ping": { + DSN: fmt.Sprintf("%s:%s@tcp(127.0.0.1:1)/%s", _rootUser, _rootPass, _testDB), + Err: assert.AnError, + }, + "Error returned by needsBackfill": { + DropTable: "deployments", + Err: assert.AnError, + }, + "Error returned by backfill": { + DropTable: "incidents", + Err: assert.AnError, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + g := prepGenerator(t) + if c.DSN != "" { + g.dsn = c.DSN + } + + if c.DropTable != "" { + exec(t, g.dsn, "DROP TABLE "+c.DropTable) + } + + ctx := t.Context() + + testutil.AssertEqualError(t, c.Err, g.Run(ctx)) + }) + } + + // an empty database is backfilled before the tick loop starts. + t.Run("Successful run backfills an empty database", func(t *testing.T) { + t.Parallel() + + lg, wr, content := bufferLog() + + g := prepGenerator(t) + g.log = lg + + // a wide interval keeps the backfill to a handful of ticks; the + // periodic loop then never fires before the context is cancelled. + g.interval = 6 * time.Hour + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + stopCh := make(chan struct{}) + + go func() { + defer close(stopCh) + + assert.NoError(t, g.Run(ctx)) + }() + + // cancelling mid-backfill would abort it, so wait for the run to + // announce that it finished. + require.Eventually(t, func() bool { + require.NoError(t, wr.Flush()) + + return strings.Contains(content(), "backfill complete") + }, 60*time.Second, 50*time.Millisecond) + + assert.Positive(t, count(t, g.dsn, "deployments")) + + cancel() + + <-stopCh + }) + + // a populated database skips the backfill and goes straight to + // appending a tick per interval. + t.Run("Successful run appends to a populated database", func(t *testing.T) { + t.Parallel() + + lg, wr, content := bufferLog() + + g := prepGenerator(t) + g.log = lg + + db := open(t, g.dsn) + require.NoError(t, g.insertTick(context.Background(), db, _tick)) + + seeded := count(t, g.dsn, "deployments") + require.Positive(t, seeded) + + g.interval = 10 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + stopCh := make(chan struct{}) + + go func() { + defer close(stopCh) + + assert.NoError(t, g.Run(ctx)) + }() + + require.Eventually(t, func() bool { + return count(t, g.dsn, "deployments") > seeded + }, 60*time.Second, 50*time.Millisecond) + + cancel() + + <-stopCh + + require.NoError(t, wr.Flush()) + assert.NotContains(t, content(), "backfilling historical data") + }) + + // a failure inside the tick loop is logged and the loop carries on. + t.Run("Tick insert failure is logged", func(t *testing.T) { + t.Parallel() + + lg, wr, content := bufferLog() + + g := prepGenerator(t) + g.log = lg + + require.NoError(t, g.insertTick(context.Background(), open(t, g.dsn), _tick)) + + seeded := count(t, g.dsn, "deployments") + require.Positive(t, seeded) + + g.interval = 10 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + stopCh := make(chan struct{}) + + go func() { + defer close(stopCh) + + assert.NoError(t, g.Run(ctx)) + }() + + // the table can only be pulled out from under the loop once the + // loop is running, which its first successful insert proves. + require.Eventually(t, func() bool { + return count(t, g.dsn, "deployments") > seeded + }, 60*time.Second, 50*time.Millisecond) + + exec(t, g.dsn, "DROP TABLE deployments") + + require.Eventually(t, func() bool { + require.NoError(t, wr.Flush()) + + return strings.Contains(content(), "tick insert failed") + }, 60*time.Second, 50*time.Millisecond) + + cancel() + + <-stopCh + }) +} + +func Test_Generator_needsBackfill(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + // Seed indicates whether rows are inserted before the check. + Seed bool + + // DropTable removes the table so the query itself fails. + DropTable bool + + Result bool + Err error + }{ + "Empty database still needs a backfill": {}, + "Populated database does not": { + Seed: true, + Result: true, + }, + "Error returned by the query": { + DropTable: true, + Err: assert.AnError, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + g := prepGenerator(t) + db := open(t, g.dsn) + + if c.Seed { + require.NoError(t, g.insertTick(context.Background(), db, _tick)) + } + + if c.DropTable { + exec(t, g.dsn, "DROP TABLE deployments") + } + + res, err := g.needsBackfill(context.Background(), db) + testutil.AssertEqualError(t, c.Err, err) + + if err != nil { + return + } + + assert.Equal(t, c.Result, res) + }) + } +} + +func Test_Generator_backfill(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + // DropTable removes a table so an insert inside the loop fails. + DropTable string + + Err error + }{ + "Successful backfill": {}, + "Error returned by insertTick": { + DropTable: "deployments", + Err: assert.AnError, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + g := prepGenerator(t) + + if c.DropTable != "" { + exec(t, g.dsn, "DROP TABLE "+c.DropTable) + } + + err := g.backfill(context.Background(), open(t, g.dsn)) + testutil.AssertEqualError(t, c.Err, err) + + if err != nil { + return + } + + // one day of ticks covers a stretch of demo history, so every + // table has to have been written to. + assert.Positive(t, count(t, g.dsn, "deployments")) + assert.Positive(t, count(t, g.dsn, "incidents")) + assert.Positive(t, count(t, g.dsn, "build_metrics")) + }) + } +} + +func Test_Generator_insertTick(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + // DropTable removes a table so one of the inserts fails. + DropTable string + + // CloseDB indicates the pool should be closed first, so the + // transaction cannot even begin. + CloseDB bool + + Err error + }{ + "Successful insert": {}, + "Error returned by the deployment insert": { + DropTable: "deployments", + Err: assert.AnError, + }, + "Error returned by the incident insert": { + DropTable: "incidents", + Err: assert.AnError, + }, + "Error returned by the build insert": { + DropTable: "build_metrics", + Err: assert.AnError, + }, + "Error returned by BeginTx": { + CloseDB: true, + Err: assert.AnError, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + g := prepGenerator(t) + db := open(t, g.dsn) + + if c.DropTable != "" { + exec(t, g.dsn, "DROP TABLE "+c.DropTable) + } + + if c.CloseDB { + require.NoError(t, db.Close()) + } + + err := g.insertTick(context.Background(), db, _tick) + testutil.AssertEqualError(t, c.Err, err) + + if err != nil { + return + } + + assert.Positive(t, count(t, g.dsn, "deployments")) + assert.Positive(t, count(t, g.dsn, "incidents")) + assert.Positive(t, count(t, g.dsn, "build_metrics")) + }) + } +} + +// prepGenerator gives a test its own randomly named database in the shared +// container, already carrying the demo schema. +func prepGenerator(t *testing.T) *Generator { + t.Helper() + + name := "demo_" + strconv.FormatInt(time.Now().UnixNano(), 36) + strconv.Itoa(len(t.Name())) + + exec(t, dsn(_testDB), "CREATE DATABASE "+name) + + for _, q := range _schema { + exec(t, dsn(name), q) + } + + g := NewGenerator(dsn(name), slog.New(slog.DiscardHandler)) + + // a single day keeps the backfill loop to a few hundred ticks instead + // of the month the generator runs with in production. + g.backfillDays = 1 + + // a pinned seed makes the first tick at _tick fill every table, so no + // test depends on a sparse table happening to draw a row. + g.r = mockmetrics.NewRand(_fullDrawSeed) + + return g +} + +// dsn builds a connection string for one database in the container. +func dsn(db string) string { + return fmt.Sprintf("%s:%s@tcp(%s)/%s?parseTime=true", _rootUser, _rootPass, _addr, db) +} + +// open opens a pool that is closed when the test ends. +func open(t *testing.T, dsn string) *sql.DB { + t.Helper() + + db, err := sql.Open("mysql", dsn) + require.NoError(t, err) + + t.Cleanup(func() { + // a case that closed the pool itself leaves nothing to do here. + db.Close() //nolint:errcheck,gosec // the pool may already be closed + }) + + require.NoError(t, db.PingContext(context.Background())) + + return db +} + +// exec runs a single statement against the given database. +func exec(t *testing.T, dsn, query string) { + t.Helper() + + db, err := sql.Open("mysql", dsn) + require.NoError(t, err) + + defer db.Close() //nolint:errcheck // error provides no meaningful info + + _, err = db.ExecContext(context.Background(), query) + require.NoError(t, err) +} + +// count returns the number of rows in a table over a pool of its own, so the +// polling callers cannot pile up connections. +func count(t *testing.T, dsn, table string) int { + t.Helper() + + db, err := sql.Open("mysql", dsn) + require.NoError(t, err) + + defer db.Close() //nolint:errcheck // error provides no meaningful info + + var n int + + require.NoError(t, db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&n)) + + return n +} + +// bufferLog creates a logger writing into a buffer whose contents the caller +// can read back once flushed. +func bufferLog() (*slog.Logger, *testutil.Writer, func() string) { + wr, buf := testutil.NewBuffer() + + return slog.New(slog.NewJSONHandler(wr, nil)), wr, buf.String +} + +// _fullDrawSeed is a seed whose first draw at _tick fills every table, so a +// test can make any one of the three inserts fail. +var _fullDrawSeed = fullDraw() + +// fullDraw searches for that seed. +func fullDraw() int64 { + for seed := int64(1); ; seed++ { + tick := demodata.GenerateTick(mockmetrics.NewRand(seed), _tick) + if len(tick.Deployments) > 0 && len(tick.Incidents) > 0 && len(tick.BuildMetrics) > 0 { + return seed + } + } +} diff --git a/datagen/internal/mockmetrics/rand.go b/datagen/internal/mockmetrics/rand.go index 1ae06377..3a197981 100644 --- a/datagen/internal/mockmetrics/rand.go +++ b/datagen/internal/mockmetrics/rand.go @@ -1,3 +1,5 @@ +// Package mockmetrics implements the random distributions the demo metrics +// and demo database rows are sampled from. package mockmetrics import ( @@ -10,6 +12,7 @@ func Normal(r *rand.Rand, mean, stddev float64) float64 { u1 := r.Float64() u2 := r.Float64() z := math.Sqrt(-2.0*math.Log(u1)) * math.Cos(2.0*math.Pi*u2) + return mean + stddev*z } @@ -26,29 +29,36 @@ func Clamp(x, lo, hi float64) float64 { return x } +// _poissonNormalLambda is the lambda above which Poisson switches from +// Knuth's algorithm to a Normal approximation. +const _poissonNormalLambda = 50 + // Poisson returns a Poisson-distributed sample with the given lambda. -// Knuth's algorithm is used for small lambda; for lambda > 50 a Normal -// approximation (mean=lambda, stddev=sqrt(lambda)) is used instead, which -// is accurate and avoids the O(lambda) loop cost at large scale. +// Knuth's algorithm is used for small lambda; above _poissonNormalLambda a +// Normal approximation (mean=lambda, stddev=sqrt(lambda)) is used instead, +// which is accurate and avoids the O(lambda) loop cost at large scale. func Poisson(r *rand.Rand, lambda float64) int { if lambda <= 0 { return 0 } - if lambda > 50 { + if lambda > _poissonNormalLambda { v := int(math.Round(Normal(r, lambda, math.Sqrt(lambda)))) if v < 0 { + // NOCOV: reaching this needs a sample more than sqrt(lambda) + // deviations below the mean, which past lambda 50 is a + // once-in-a-trillion draw. return 0 } return v } - L := math.Exp(-lambda) + limit := math.Exp(-lambda) k := 0 p := 1.0 - for p > L { + for p > limit { k++ p *= r.Float64() } @@ -71,5 +81,6 @@ func RandCount(r *rand.Rand, mean, relStdDev float64) int { // NewRand returns a new rand seeded with seed. func NewRand(seed int64) *rand.Rand { + //nolint:gosec // demo data wants a cheap reproducible source, not a cryptographic one return rand.New(rand.NewSource(seed)) } diff --git a/datagen/internal/mockmetrics/rand_test.go b/datagen/internal/mockmetrics/rand_test.go new file mode 100644 index 00000000..ba9af94a --- /dev/null +++ b/datagen/internal/mockmetrics/rand_test.go @@ -0,0 +1,250 @@ +package mockmetrics + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func Test_Normal(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Mean float64 + StdDev float64 + }{ + "Zero deviation always returns the mean": { + Mean: 42, + }, + "Non-zero deviation spreads around the mean": { + Mean: 100, + StdDev: 15, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + r := NewRand(1) + + // a zero deviation collapses the distribution onto the mean, + // which is the only exact assertion available here. + if c.StdDev == 0 { + assert.InDelta(t, c.Mean, Normal(r, c.Mean, c.StdDev), 1e-9) + return + } + + // otherwise the sample mean of a large draw must land near the + // requested mean, and the samples must not all be identical. + var ( + sum float64 + first = Normal(r, c.Mean, c.StdDev) + same = true + ) + + sum += first + + for range 9_999 { + v := Normal(r, c.Mean, c.StdDev) + if v != first { + same = false + } + + sum += v + } + + assert.False(t, same) + assert.InDelta(t, c.Mean, sum/10_000, c.StdDev) + }) + } +} + +func Test_Clamp(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + X float64 + Lo float64 + Hi float64 + Result float64 + }{ + "Below the lower bound": { + X: -5, + Lo: 0, + Hi: 10, + Result: 0, + }, + "Above the upper bound": { + X: 15, + Lo: 0, + Hi: 10, + Result: 10, + }, + "Within the bounds": { + X: 5, + Lo: 0, + Hi: 10, + Result: 5, + }, + "Exactly on the lower bound": { + X: 0, + Lo: 0, + Hi: 10, + Result: 0, + }, + "Exactly on the upper bound": { + X: 10, + Lo: 0, + Hi: 10, + Result: 10, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + assert.InDelta(t, c.Result, Clamp(c.X, c.Lo, c.Hi), 1e-9) + }) + } +} + +func Test_Poisson(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Lambda float64 + + // Exact holds the single value every draw must produce, used for the + // degenerate lambdas that cannot vary. + Exact *int + + // Draws is how many samples the statistical assertions run over. + Draws int + }{ + "Zero lambda never produces an event": { + Lambda: 0, + Exact: new(0), + }, + "Negative lambda never produces an event": { + Lambda: -1, + Exact: new(0), + }, + "Small lambda goes through Knuth's algorithm": { + Lambda: 5, + Draws: 10_000, + }, + "Lambda on the switchover boundary still uses Knuth": { + Lambda: _poissonNormalLambda, + Draws: 10_000, + }, + "Large lambda goes through the normal approximation": { + Lambda: 5_000, + Draws: 10_000, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + r := NewRand(1) + + if c.Exact != nil { + assert.Equal(t, *c.Exact, Poisson(r, c.Lambda)) + return + } + + var sum int + + for range c.Draws { + v := Poisson(r, c.Lambda) + require.GreaterOrEqual(t, v, 0) + + sum += v + } + + // the mean of a Poisson draw is lambda itself; allow a generous + // band so the assertion never races the sampler. + assert.InDelta(t, c.Lambda, float64(sum)/float64(c.Draws), math.Sqrt(c.Lambda)) + }) + } +} + +func Test_RandCount(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Mean float64 + RelStdDev float64 + + // Exact holds the value every draw must produce when the parameters + // leave no room for variation. + Exact *int + }{ + "Zero deviation returns the rounded mean": { + Mean: 1_000, + Exact: new(1_000), + }, + "Negative mean is clamped to zero": { + Mean: -50, + Exact: new(0), + }, + "Spread mean stays non-negative": { + Mean: 500, + RelStdDev: 0.2, + }, + "Deviation wide enough to undershoot zero is clamped": { + Mean: 10, + RelStdDev: 2, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + r := NewRand(1) + + if c.Exact != nil { + assert.Equal(t, *c.Exact, RandCount(r, c.Mean, c.RelStdDev)) + return + } + + var sum int + + for range 10_000 { + v := RandCount(r, c.Mean, c.RelStdDev) + require.GreaterOrEqual(t, v, 0) + + sum += v + } + + assert.InDelta(t, c.Mean, float64(sum)/10_000, c.Mean*c.RelStdDev) + }) + } +} + +func Test_NewRand(t *testing.T) { + t.Parallel() + + // the same seed replays the same sequence. + a, b := NewRand(7), NewRand(7) + require.NotNil(t, a) + require.NotNil(t, b) + + for range 100 { + assert.InDelta(t, a.Float64(), b.Float64(), 1e-12) + } + + // a different seed does not. + assert.NotEqual(t, NewRand(7).Float64(), NewRand(8).Float64()) +} diff --git a/datagen/internal/pgdemo/pgdemo.go b/datagen/internal/pgdemo/pgdemo.go index 9f6bfb7d..a7e77592 100644 --- a/datagen/internal/pgdemo/pgdemo.go +++ b/datagen/internal/pgdemo/pgdemo.go @@ -1,3 +1,5 @@ +// Package pgdemo fills the demo PostgreSQL data source with generated +// time-series rows. package pgdemo import ( @@ -10,23 +12,38 @@ import ( "github.com/jackc/pgx/v5" "github.com/oxynote/oxynote/datagen/internal/demodata" "github.com/oxynote/oxynote/datagen/internal/mockmetrics" + "github.com/oxynote/oxynote/server/core/pkg/timeutil" ) +// _backfillDays defines how much history an empty database is filled with. const _backfillDays = 30 // Generator inserts demo time-series data into PostgreSQL. type Generator struct { - dsn string - r *rand.Rand + // log is the structured logger. log *slog.Logger + + // dsn addresses the database to fill. + dsn string + + // r is the random source used for all row generation. + r *rand.Rand + + // interval defines how often a new tick of rows is appended. + interval time.Duration + + // backfillDays defines how much history an empty database is filled with. + backfillDays int } -// NewGenerator creates a new demo data generator. +// NewGenerator creates a fresh instance of Generator. func NewGenerator(dsn string, log *slog.Logger) *Generator { return &Generator{ - dsn: dsn, - r: mockmetrics.NewRand(time.Now().UnixNano()), - log: log, + log: log, + dsn: dsn, + r: mockmetrics.NewRand(timeutil.Now().UnixNano()), + interval: demodata.TickInterval, + backfillDays: _backfillDays, } } @@ -37,7 +54,7 @@ func (g *Generator) Run(ctx context.Context) error { if err != nil { return fmt.Errorf("pgdemo: connect: %w", err) } - defer conn.Close(ctx) + defer conn.Close(ctx) //nolint:errcheck // error provides no meaningful info backfilled, err := g.needsBackfill(ctx, conn) if err != nil { @@ -45,7 +62,7 @@ func (g *Generator) Run(ctx context.Context) error { } if !backfilled { - g.log.Info("pgdemo: backfilling historical data", slog.Int("days", _backfillDays)) + g.log.Info("pgdemo: backfilling historical data", slog.Int("days", g.backfillDays)) if err := g.backfill(ctx, conn); err != nil { return fmt.Errorf("pgdemo: backfill: %w", err) @@ -54,18 +71,19 @@ func (g *Generator) Run(ctx context.Context) error { g.log.Info("pgdemo: backfill complete") } - for { - select { - case <-ctx.Done(): - return nil - case <-time.After(demodata.TickInterval): - now := time.Now().UTC() - - if err := g.insertTick(ctx, conn, now); err != nil { + timeutil.NewPeriodicExec( + g.interval, + 0, + func(ctx context.Context) { + if err := g.insertTick(ctx, conn, timeutil.Now()); err != nil { g.log.Error("pgdemo: tick insert failed", slog.String("error", err.Error())) } - } - } + }, + nil, + false, + ).Start(ctx) + + return nil } // needsBackfill returns true if historical data already exists. @@ -80,12 +98,12 @@ func (g *Generator) needsBackfill(ctx context.Context, conn *pgx.Conn) (bool, er return count > 0, nil } -// backfill generates _backfillDays worth of historical data. +// backfill generates backfillDays worth of historical data. func (g *Generator) backfill(ctx context.Context, conn *pgx.Conn) error { - now := time.Now().UTC() - start := now.AddDate(0, 0, -_backfillDays) + now := timeutil.Now() + start := now.AddDate(0, 0, -g.backfillDays) - for t := start; t.Before(now); t = t.Add(demodata.TickInterval) { + for t := start; t.Before(now); t = t.Add(g.interval) { if err := g.insertTick(ctx, conn, t); err != nil { return err } @@ -125,7 +143,7 @@ func (g *Generator) insertTick(ctx context.Context, conn *pgx.Conn, t time.Time) } br := conn.SendBatch(ctx, batch) - defer br.Close() + defer br.Close() //nolint:errcheck // the exec errors below already report what failed for range batch.Len() { if _, err := br.Exec(); err != nil { diff --git a/datagen/internal/pgdemo/pgdemo_test.go b/datagen/internal/pgdemo/pgdemo_test.go new file mode 100644 index 00000000..0c6b9297 --- /dev/null +++ b/datagen/internal/pgdemo/pgdemo_test.go @@ -0,0 +1,547 @@ +package pgdemo + +import ( + "context" + "fmt" + "log/slog" + "strconv" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/orlangure/gnomock" + pgDocker "github.com/orlangure/gnomock/preset/postgres" + "github.com/oxynote/oxynote/datagen/internal/demodata" + "github.com/oxynote/oxynote/datagen/internal/mockmetrics" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +const ( + // _testDB is the database name used in the throwaway container. + _testDB = "testdb" + + // _pgUser is the privileged container user. + _pgUser = "pgtest" + + // _pgPass is the privileged container password. + _pgPass = "pgpass" +) + +// _schema mirrors docker/demo/postgres/init-demo-db.sh, which is what the +// demo data source is really created with. +var _schema = []string{ + `CREATE TABLE deployments ( + id BIGSERIAL PRIMARY KEY, + time TIMESTAMPTZ NOT NULL, + service TEXT NOT NULL, + environment TEXT NOT NULL, + duration_seconds DOUBLE PRECISION NOT NULL, + success BOOLEAN NOT NULL, + rollback BOOLEAN NOT NULL DEFAULT FALSE + )`, + `CREATE TABLE incidents ( + id BIGSERIAL PRIMARY KEY, + time TIMESTAMPTZ NOT NULL, + severity TEXT NOT NULL, + service TEXT NOT NULL, + time_to_detect_minutes DOUBLE PRECISION NOT NULL, + time_to_resolve_minutes DOUBLE PRECISION NOT NULL + )`, + `CREATE TABLE build_metrics ( + id BIGSERIAL PRIMARY KEY, + time TIMESTAMPTZ NOT NULL, + repository TEXT NOT NULL, + branch TEXT NOT NULL, + duration_seconds DOUBLE PRECISION NOT NULL, + test_count INTEGER NOT NULL, + tests_failed INTEGER NOT NULL, + coverage_pct DOUBLE PRECISION NOT NULL + )`, +} + +// _pgAddr is the address of the throwaway PostgreSQL container. +var _pgAddr string + +func TestMain(m *testing.M) { + container, err := gnomock.Start(pgDocker.Preset( + pgDocker.WithUser(_pgUser, _pgPass), + pgDocker.WithDatabase(_testDB), + )) + if err != nil { + panic("cannot set up postgres: " + err.Error()) + } + + defer func() { + if err = gnomock.Stop(container); err != nil { + panic("cannot clean up postgres: " + err.Error()) + } + }() + + _pgAddr = container.Host + ":" + strconv.Itoa(container.DefaultPort()) + + goleak.VerifyTestMain(m, goleak.IgnoreCurrent()) +} + +func Test_NewGenerator(t *testing.T) { + t.Parallel() + + log := slog.New(slog.DiscardHandler) + + g := NewGenerator("postgres://test", log) + require.NotNil(t, g) + + assert.Equal(t, log, g.log) + assert.Equal(t, "postgres://test", g.dsn) + assert.NotNil(t, g.r) + assert.Equal(t, demodata.TickInterval, g.interval) + assert.Equal(t, _backfillDays, g.backfillDays) +} + +func Test_Generator_Run(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + // DSN overrides the throwaway database's DSN, for the cases that + // must fail before any query runs. + DSN string + + // DropTable names a table to remove before the run, so the backfill + // check fails against a real connection. + DropTable string + + // Err is the expected outcome of the run. + Err error + }{ + "Error returned by pgx.Connect": { + DSN: "postgres://nobody@127.0.0.1:1/nothing", + Err: assert.AnError, + }, + "Error returned by needsBackfill": { + DropTable: "deployments", + Err: assert.AnError, + }, + "Error returned by backfill": { + DropTable: "incidents", + Err: assert.AnError, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + g := prepGenerator(t) + if c.DSN != "" { + g.dsn = c.DSN + } + + if c.DropTable != "" { + exec(t, g.dsn, "DROP TABLE "+c.DropTable) + } + + ctx := t.Context() + + testutil.AssertEqualError(t, c.Err, g.Run(ctx)) + }) + } + + // an empty database is backfilled before the tick loop starts. + t.Run("Successful run backfills an empty database", func(t *testing.T) { + t.Parallel() + + log, wr, content := bufferLog() + + g := prepGenerator(t) + g.log = log + + // a wide interval keeps the backfill to a handful of ticks; the + // periodic loop then never fires before the context is cancelled. + g.interval = 6 * time.Hour + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + stopCh := make(chan struct{}) + + go func() { + defer close(stopCh) + + assert.NoError(t, g.Run(ctx)) + }() + + // cancelling mid-backfill would abort it, so wait for the run to + // announce that it finished. + require.Eventually(t, func() bool { + require.NoError(t, wr.Flush()) + + return strings.Contains(content(), "backfill complete") + }, 60*time.Second, 50*time.Millisecond) + + assert.Positive(t, count(t, g.dsn, "deployments")) + + cancel() + + <-stopCh + }) + + // a populated database skips the backfill and goes straight to + // appending a tick per interval. + t.Run("Successful run appends to a populated database", func(t *testing.T) { + t.Parallel() + + log, wr, content := bufferLog() + + g := prepGenerator(t) + g.log = log + + require.NoError(t, g.insertTick(context.Background(), connect(t, g.dsn), _tick)) + + seeded := count(t, g.dsn, "deployments") + require.Positive(t, seeded) + + g.interval = 10 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + stopCh := make(chan struct{}) + + go func() { + defer close(stopCh) + + assert.NoError(t, g.Run(ctx)) + }() + + require.Eventually(t, func() bool { + return count(t, g.dsn, "deployments") > seeded + }, 60*time.Second, 50*time.Millisecond) + + cancel() + + <-stopCh + + require.NoError(t, wr.Flush()) + assert.NotContains(t, content(), "backfilling historical data") + }) + + // a failure inside the tick loop is logged and the loop carries on. + t.Run("Tick insert failure is logged", func(t *testing.T) { + t.Parallel() + + lg, wr, content := bufferLog() + + g := prepGenerator(t) + g.log = lg + + require.NoError(t, g.insertTick(context.Background(), connect(t, g.dsn), _tick)) + + seeded := count(t, g.dsn, "deployments") + require.Positive(t, seeded) + + g.interval = 10 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + stopCh := make(chan struct{}) + + go func() { + defer close(stopCh) + + assert.NoError(t, g.Run(ctx)) + }() + + // the table can only be pulled out from under the loop once the + // loop is running, which its first successful insert proves. + require.Eventually(t, func() bool { + return count(t, g.dsn, "deployments") > seeded + }, 60*time.Second, 50*time.Millisecond) + + exec(t, g.dsn, "DROP TABLE deployments") + + require.Eventually(t, func() bool { + require.NoError(t, wr.Flush()) + + return strings.Contains(content(), "tick insert failed") + }, 60*time.Second, 50*time.Millisecond) + + cancel() + + <-stopCh + }) +} + +func Test_Generator_needsBackfill(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + // Seed indicates whether a row is inserted before the check. + Seed bool + + // DropTable removes the table so the query itself fails. + DropTable bool + + Result bool + Err error + }{ + "Empty database still needs a backfill": {}, + "Populated database does not": { + Seed: true, + Result: true, + }, + "Error returned by the query": { + DropTable: true, + Err: assert.AnError, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + g := prepGenerator(t) + + if c.Seed { + require.NoError(t, g.insertTick(context.Background(), connect(t, g.dsn), _tick)) + } + + if c.DropTable { + exec(t, g.dsn, "DROP TABLE deployments") + } + + res, err := g.needsBackfill(context.Background(), connect(t, g.dsn)) + testutil.AssertEqualError(t, c.Err, err) + + if err != nil { + return + } + + assert.Equal(t, c.Result, res) + }) + } +} + +func Test_Generator_backfill(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + // DropTable removes a table so an insert inside the loop fails. + DropTable string + + Err error + }{ + "Successful backfill": {}, + "Error returned by insertTick": { + DropTable: "deployments", + Err: assert.AnError, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + g := prepGenerator(t) + + if c.DropTable != "" { + exec(t, g.dsn, "DROP TABLE "+c.DropTable) + } + + err := g.backfill(context.Background(), connect(t, g.dsn)) + testutil.AssertEqualError(t, c.Err, err) + + if err != nil { + return + } + + // one day of ticks covers a stretch of demo history, so every + // table has to have been written to. + assert.Positive(t, count(t, g.dsn, "deployments")) + assert.Positive(t, count(t, g.dsn, "incidents")) + assert.Positive(t, count(t, g.dsn, "build_metrics")) + }) + } +} + +func Test_Generator_insertTick(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + // DropTable removes a table so the batch fails on exec. + DropTable string + + // EmptyTick indicates the generator should be steered at an instant + // that produces nothing at all. + EmptyTick bool + + Err error + }{ + "Successful insert": {}, + "A tick with no rows sends no batch": { + EmptyTick: true, + }, + "Error returned by the batch": { + DropTable: "deployments", + Err: assert.AnError, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + g := prepGenerator(t) + + if c.DropTable != "" { + exec(t, g.dsn, "DROP TABLE "+c.DropTable) + } + + at := _tick + + // the early return needs a tick that generates nothing, which + // only a specific seed at a quiet instant produces. + if c.EmptyTick { + seed, quiet := emptyDraw() + g.r = mockmetrics.NewRand(seed) + at = quiet + } + + err := g.insertTick(context.Background(), connect(t, g.dsn), at) + testutil.AssertEqualError(t, c.Err, err) + + if err != nil { + return + } + + total := count(t, g.dsn, "deployments") + + count(t, g.dsn, "incidents") + + count(t, g.dsn, "build_metrics") + + if c.EmptyTick { + assert.Zero(t, total) + return + } + + assert.Positive(t, total) + }) + } +} + +// _tick is a weekday office-hours instant, busy enough that every table gets +// rows. +var _tick = time.Date(2024, time.January, 9, 11, 0, 0, 0, time.UTC) + +// prepGenerator gives a test its own randomly named database in the shared +// container, already carrying the demo schema. +func prepGenerator(t *testing.T) *Generator { + t.Helper() + + name := "demo_" + strconv.FormatInt(time.Now().UnixNano(), 36) + strconv.Itoa(len(t.Name())) + + admin := connect(t, dsn(_testDB)) + _, err := admin.Exec(context.Background(), "CREATE DATABASE "+name) + require.NoError(t, err) + + conn := connect(t, dsn(name)) + for _, q := range _schema { + _, err = conn.Exec(context.Background(), q) + require.NoError(t, err) + } + + g := NewGenerator(dsn(name), slog.New(slog.DiscardHandler)) + + // a single day keeps the backfill loop to a few hundred ticks instead + // of the month the generator runs with in production. + g.backfillDays = 1 + + // a pinned seed makes the first tick at _tick fill every table, so no + // test depends on a sparse table happening to draw a row. + g.r = mockmetrics.NewRand(_fullDrawSeed) + + return g +} + +// dsn builds a connection string for one database in the container. +func dsn(db string) string { + return fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", _pgUser, _pgPass, _pgAddr, db) +} + +// connect opens a connection that is closed when the test ends. +func connect(t *testing.T, dsn string) *pgx.Conn { + t.Helper() + + conn, err := pgx.Connect(context.Background(), dsn) + require.NoError(t, err) + + t.Cleanup(func() { + assert.NoError(t, conn.Close(context.Background())) + }) + + return conn +} + +// exec runs a single statement against the given database. +func exec(t *testing.T, dsn, query string) { + t.Helper() + + _, err := connect(t, dsn).Exec(context.Background(), query) + require.NoError(t, err) +} + +// count returns the number of rows in a table over a connection of its own, +// so the polling callers cannot pile up connections. +func count(t *testing.T, dsn, table string) int { + t.Helper() + + conn, err := pgx.Connect(context.Background(), dsn) + require.NoError(t, err) + + defer func() { + assert.NoError(t, conn.Close(context.Background())) + }() + + var n int + + require.NoError(t, conn.QueryRow(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&n)) + + return n +} + +// emptyDraw finds a seed and a quiet instant that together generate nothing +// at all, so the empty-tick early return can be reached deterministically. +func emptyDraw() (int64, time.Time) { + at := time.Date(2024, time.January, 7, 3, 0, 0, 0, time.UTC) + + for seed := int64(1); ; seed++ { + tick := demodata.GenerateTick(mockmetrics.NewRand(seed), at) + if len(tick.Deployments)+len(tick.Incidents)+len(tick.BuildMetrics) == 0 { + return seed, at + } + } +} + +// bufferLog creates a logger writing into a buffer whose contents the caller +// can read back once flushed. +func bufferLog() (*slog.Logger, *testutil.Writer, func() string) { + wr, buf := testutil.NewBuffer() + + return slog.New(slog.NewJSONHandler(wr, nil)), wr, buf.String +} + +// _fullDrawSeed is a seed whose first draw at _tick fills every table, so a +// dropped table reliably breaks the batch. +var _fullDrawSeed = fullDraw() + +// fullDraw searches for that seed. +func fullDraw() int64 { + for seed := int64(1); ; seed++ { + tick := demodata.GenerateTick(mockmetrics.NewRand(seed), _tick) + if len(tick.Deployments) > 0 && len(tick.Incidents) > 0 && len(tick.BuildMetrics) > 0 { + return seed + } + } +} diff --git a/datagen/internal/server/server.go b/datagen/internal/server/server.go index a885bb40..e88c3166 100644 --- a/datagen/internal/server/server.go +++ b/datagen/internal/server/server.go @@ -1,3 +1,4 @@ +// Package server exposes the generated demo metrics over HTTP. package server import ( @@ -15,6 +16,9 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" ) +// _requestTimeout bounds how long a single scrape may take. +const _requestTimeout = 30 * time.Second + // Server exposes metrics over HTTP. type Server struct { log *slog.Logger @@ -71,6 +75,8 @@ func (s *Server) Address() string { // Close performs clean up operations. func (s *Server) Close() error { if err := s.http.Shutdown(context.Background()); err != nil { + // NOCOV: shutting down against an unbounded context only fails if + // closing a listener does, which cannot be provoked in tests. return err } @@ -83,11 +89,11 @@ func (s *Server) Close() error { func (s *Server) router() chi.Router { r := chi.NewRouter() - r.Use(httpserver.Timeout(30 * time.Second)) + r.Use(httpserver.Timeout(_requestTimeout)) r.Use(httpserver.Recoverer(s.log)) r.Handle("/api/metrics", promhttp.InstrumentMetricHandler( - s.fc, promhttp.HandlerFor(s.fc, promhttp.HandlerOpts{}), + s.fc, promhttp.HandlerFor(s.fc, promhttp.HandlerOpts{CoalesceGather: true}), )) r.MethodNotAllowed(httpserver.MethodNotAllowed(s.log)) diff --git a/datagen/internal/server/server_test.go b/datagen/internal/server/server_test.go new file mode 100644 index 00000000..3e224989 --- /dev/null +++ b/datagen/internal/server/server_test.go @@ -0,0 +1,206 @@ +package server + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/oxynote/oxynote/server/core/pkg/metricutil" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func Test_New(t *testing.T) { + t.Parallel() + + var ( + log = slog.New(slog.DiscardHandler) + fc = newFactory() + ) + + s := New(log, fc) + require.NotNil(t, s) + + assert.Equal(t, log, s.log) + assert.Equal(t, fc, s.fc) + + require.NotNil(t, s.http) + assert.Equal(t, ":8080", s.http.Addr) + assert.Equal(t, time.Minute, s.http.ReadTimeout) + assert.Equal(t, time.Minute, s.http.ReadHeaderTimeout) + assert.NotNil(t, s.http.Handler) + assert.NotNil(t, s.http.ErrorLog) +} + +func Test_Server_Listen(t *testing.T) { + t.Parallel() + + log, wr, content := bufferLog() + + s := Server{ + log: log, + http: &http.Server{Addr: ":a", ReadHeaderTimeout: time.Minute}, + } + + // an invalid port makes ListenAndServe fail immediately. + s.Listen() + + require.NoError(t, wr.Flush()) + assert.Contains(t, content(), `"msg":"listening"`) + assert.Contains(t, content(), "cannot terminate gracefully") + + // a graceful shutdown ends Listen without the error log. + log2, wr2, content2 := bufferLog() + + s2 := Server{ + log: log2, + http: &http.Server{Addr: "127.0.0.1:0", ReadHeaderTimeout: time.Minute}, + } + + stopCh := make(chan struct{}) + + go func() { + defer close(stopCh) + + s2.Listen() + }() + + require.Eventually(t, func() bool { + require.NoError(t, wr2.Flush()) + + return content2() != "" + }, 5*time.Second, 10*time.Millisecond) + + require.NoError(t, s2.http.Shutdown(context.Background())) + + <-stopCh + + require.NoError(t, wr2.Flush()) + assert.Contains(t, content2(), `"msg":"listening"`) + assert.NotContains(t, content2(), "cannot terminate gracefully") +} + +func Test_Server_Address(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Addr string + Result string + }{ + "Default HTTP port is omitted": { + Addr: ":80", + Result: "http://localhost", + }, + "Custom port is included": { + Addr: ":1234", + Result: "http://localhost:1234", + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + s := Server{http: &http.Server{Addr: c.Addr, ReadHeaderTimeout: time.Minute}} + + assert.Equal(t, c.Result, s.Address()) + }) + } +} + +func Test_Server_Close(t *testing.T) { + t.Parallel() + + s := Server{ + log: slog.New(slog.DiscardHandler), + http: &http.Server{ReadHeaderTimeout: time.Minute}, + } + + assert.NoError(t, s.Close()) +} + +func Test_Server_router(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Method string + Path string + RespCode int + + // Contains is a fragment the response body must carry, used where + // the body is a metrics dump rather than a fixed payload. + Contains string + }{ + "Metrics are served": { + Method: http.MethodGet, + Path: "/api/metrics", + RespCode: http.StatusOK, + Contains: "engineering_test_gauge", + }, + "Unknown path is not found": { + Method: http.MethodGet, + Path: "/api/nothing", + RespCode: http.StatusNotFound, + }, + // the metrics route is registered for every method, so a scrape + // with an odd verb is answered rather than rejected. + "Metrics answer any method": { + Method: http.MethodDelete, + Path: "/api/metrics", + RespCode: http.StatusOK, + Contains: "engineering_test_gauge", + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + fc := newFactory() + + // a registered gauge gives the scrape something to report. + fc.NewGauge(metricutil.Options{Name: "test_gauge", Help: "Tracks nothing."}).Set(1) + + s := Server{log: slog.New(slog.DiscardHandler), fc: fc} + + req := httptest.NewRequest(c.Method, "http://test.com"+c.Path, http.NoBody) + rec := httptest.NewRecorder() + + s.router().ServeHTTP(rec, req) + + assert.Equal(t, c.RespCode, rec.Code) + + if c.Contains != "" { + assert.Contains(t, rec.Body.String(), c.Contains) + } + }) + } +} + +// newFactory creates a metrics factory backed by a registry of its own, so +// parallel tests never collide over metric names. +func newFactory() metricutil.Factory { + return metricutil.NewFactory( + "engineering", + prometheus.NewRegistry(), + metricutil.WithCustomHost("demo"), + ) +} + +// bufferLog creates a logger writing into a buffer whose contents the caller +// can read back once flushed. +func bufferLog() (*slog.Logger, *testutil.Writer, func() string) { + wr, buf := testutil.NewBuffer() + + return slog.New(slog.NewJSONHandler(wr, nil)), wr, buf.String +} diff --git a/docker/demo/prometheus/prometheus.yaml b/docker/demo/prometheus/prometheus.yaml deleted file mode 100644 index a1bda1b5..00000000 --- a/docker/demo/prometheus/prometheus.yaml +++ /dev/null @@ -1,13 +0,0 @@ -global: - scrape_interval: 5s - evaluation_interval: 5s - -scrape_configs: - - job_name: 'core' - metrics_path: api/x/metrics - static_configs: - - targets: ['core:8080'] - - job_name: 'datagen' - metrics_path: api/metrics - static_configs: - - targets: ['datagen:8080'] diff --git a/docker/docker-compose.dev.yaml b/docker/docker-compose.dev.yaml index a465ad17..11ec0543 100644 --- a/docker/docker-compose.dev.yaml +++ b/docker/docker-compose.dev.yaml @@ -238,21 +238,6 @@ services: - SCREEN_DEPTH=16 - MAX_CONCURRENT_CHROME_PROCESSES=10 - LOG_LEVEL=WARNING - prometheus: - container_name: prometheus - image: prom/prometheus:latest - restart: unless-stopped - volumes: - - prometheus_data:/prometheus - - ./demo/prometheus:/etc/prometheus/ - command: - - "--config.file=/etc/prometheus/prometheus.yaml" - logging: - driver: local - options: - mode: non-blocking - max-size: 10m - max-file: "3" grafana: image: grafana/grafana:latest container_name: grafana @@ -273,5 +258,4 @@ volumes: caddy_data: caddy_config: changedetection_data: - prometheus_data: grafana_storage: diff --git a/docker/env/core.example.env b/docker/env/core.example.env index 398c8308..a23e384b 100644 --- a/docker/env/core.example.env +++ b/docker/env/core.example.env @@ -18,7 +18,6 @@ OXYNOTE_CORE_STORAGE_BUCKET=dev OXYNOTE_CORE_SERVER_AUTH_BETTER_AUTH_URL=http://auth-realtime:8081/api/auth/get-session OXYNOTE_CORE_SERVER_MCP_SESSION_URL=http://auth-realtime:8081/api/internal/mcp/session OXYNOTE_CORE_AUTH_REALTIME_URL=http://auth-realtime:8081 -OXYNOTE_CORE_SERVER_DEMO_PROMETHEUS_URL=http://prometheus:9090 # an empty URL disables changedetection.io: url-watcher hooks cannot be # created and existing ones are skipped. OXYNOTE_CORE_CHANGEDETECTION_API_URL=http://changedetection:5000 diff --git a/e2e/docker-compose.yaml b/e2e/docker-compose.yaml index 9ff07331..1880fb29 100644 --- a/e2e/docker-compose.yaml +++ b/e2e/docker-compose.yaml @@ -6,7 +6,7 @@ # - no container_name, so this stack coexists with the dev one # - env is inlined and committed; there is nothing secret to protect # - only the services the tests exercise (no changedetection, mariadb, -# datagen, prometheus or grafana) +# datagen or grafana) # # the front door is the dev Caddyfile verbatim: e2e must exercise the # same routing and the same /api/x and /api/internal blocks that ship. @@ -124,12 +124,6 @@ services: - OXYNOTE_CORE_BASE_APP_URL=http://localhost:18080 - OXYNOTE_CORE_ORIGINS=http://localhost:18080,localhost:18080 - OXYNOTE_CORE_DB_DATA_SOURCE_CREDENTIALS_SIGNING_SECRET=e2esigningsecret - # core only seeds a new workspace's welcome document while a demo - # prometheus URL is set — with it empty, initialize returns early and - # the onboarding page polls for a first document that never comes. - # The URL is stored, not dialed, so there is no prometheus here; a - # block that queries it would simply get a connection error. - - OXYNOTE_CORE_SERVER_DEMO_PROMETHEUS_URL=http://prometheus.invalid:9090 - OXYNOTE_CORE_EMAIL_SMTP_HOST=mailpit - OXYNOTE_CORE_EMAIL_SMTP_PORT=1025 - OXYNOTE_CORE_EMAIL_SMTP_TLS=none diff --git a/server/CLAUDE.md b/server/CLAUDE.md index a5eb86b3..4409dbd8 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -13,7 +13,7 @@ infrastructure, and the non-Go services. - `server/core/` — Go module `github.com/oxynote/oxynote/server/core`. One binary: `cmd/core` (`oxynote-core`) — main API server. Listens on `:8080`. Exposes `/api/...` (auth-required), `/api/apps/...` (public, sessionless third-party callbacks), and `/api/x/...` (internal-only, no auth; the reverse proxy is expected to firewall this). Owns Postgres, Meilisearch, Valkey (Redis), MinIO, the GitHub/Slack apps, the assistant, and the outbound data-source connections (`internal/datasource`). - `server/auth-realtime/` — `@oxynote/auth-realtime` package. TypeScript ES-module service that runs Better Auth (organization plugin) and a Hocuspocus (Yjs CRDT) server in a single Hono process on port `8081`. Forwards non-auth `/api/...` traffic to core (`OXYNOTE_AUTH_REALTIME_BACKEND_URL`). -- `datagen/` — separate Go module `github.com/oxynote/oxynote/datagen`. Synthesises demo Postgres/MariaDB content and a fake metrics endpoint scraped by Prometheus for the demo data sources. +- `datagen/` — separate Go module `github.com/oxynote/oxynote/datagen`. Synthesises demo Postgres/MariaDB content, and serves a fake metrics endpoint for a Prometheus to scrape. The demo Prometheus data source no longer uses that endpoint: core synthesises those metrics itself (`server/core/internal/datasource/demo`), so nothing in the dev stack scrapes it. Go builds go through **goreleaser**: `make build` in `server/core` and `datagen` runs `goreleaser release --snapshot --clean`, producing binaries in `bin/` and the `ghcr.io/oxynote/{core,datagen}:dev` images via each module's `Dockerfile.dev`. The dev compose stack consumes those images; `auth-realtime` (and `web/`) are built from source by compose via their `Dockerfile.dev`. `make run` / `make start` from the repository root orchestrates both. @@ -31,8 +31,11 @@ Per-component: # core cd server/core && make build # goreleaser release --snapshot --clean -> bin/ -# datagen +# datagen — same targets as core cd datagen && make build +cd datagen && make test # go test -race ./... (pgdemo/mariademo need docker) +cd datagen && make check-coverage # fails below COVERAGE_MIN +cd datagen && make check-lint # golangci-lint; lint = the fixing variant # auth-realtime (full details in auth-realtime/CLAUDE.md) cd server/auth-realtime && pnpm run dev # local watch mode (tsx + sentry.ts preloaded) @@ -43,8 +46,12 @@ cd server/auth-realtime && pnpm run test # vitest run --coverage cd server/auth-realtime && pnpm run qa # check-lint + test; qa-fix = lint + test ``` -The Go test/lint workflow is documented in [core/CLAUDE.md](core/CLAUDE.md) -("Commands & QA gates"). +Both Go modules share the same test/lint workflow, documented in +[core/CLAUDE.md](core/CLAUDE.md) ("Commands & QA gates"); datagen carries its +own copy of core's golangci-lint profile. Its `pgdemo` and `mariademo` suites +start throwaway Postgres and MariaDB containers, so docker is a unit-test +dependency for those two packages the same way it is for core's `internal/db` +and `internal/datasource/processor`. Go dependencies are fetched into the module cache at build time (`make deps` runs `go mod download`) — there is no vendoring. All dependencies, including the first-party `github.com/oxynote/wetsocks`, are public; no GOPRIVATE or git auth setup is needed. @@ -71,6 +78,16 @@ So from a frontend's point of view: auth + realtime is `:8080/auth-realtime/...` Most public routes in the README (`/api/documents`, `/api/documents/tree`, etc.) are served by core; the README is the closest thing to a contract spec — when changing handlers, update it. +## The demo data source + +Every organization is seeded at initialization with a Prometheus data source named "Demo" whose URL is `demo://engineering`, plus the welcome document whose charts query it. This is unconditional — there is no env var and no way to opt out, so a fresh install shows populated charts with nothing else deployed. + +- `internal/datasource/demo` synthesises those metrics in-process. `runner.ensurePrepared` picks it over the real client when the URL is exactly `demo://engineering`; any other `demo://` URL is `demo.ErrUnknownSource` rather than a second demo. The data source's type stays `prometheus`, so the editor, autocomplete and charts cannot tell the difference. +- **One `demo.Client` serves the whole process, and it lives on `datasource.Manager`.** The client owns its engine, parser and registry, and the registry's walks cache the history they replay. A runner is built per call, so building the client there instead would throw that cache away on every request and re-walk every tick since the epoch. +- **The timeline is a pure function of the tick, not stored data.** A fixed epoch and a one-minute tick, values computed on demand from a per-series seed — so history never rewrites, a query over any window backfills for free, and the same window always answers identically. `walk.checkpoint` caches one value per day-long segment; without it a sample would replay every tick since the epoch. +- Queries run through the official `promql` engine over `queryable`, a `storage.Queryable` that synthesises only the requested window. Samples are spaced at the query's own step and the lookback delta is widened to match, which is what lets a year-long range answer with ~100 points per series instead of half a million. +- `registry.go` is the single declaration of what exists: ~15 gauge families driving both sample synthesis and the metadata/label/series endpoints. `Test_welcomeDocumentQueries` runs every query in `internal/document/files/available_metrics.json` against it, so a metric renamed in one place and not the other fails the build rather than emptying the welcome document silently. + ## Document storage / Hocuspocus integration This is the model that's least obvious from a fresh read: diff --git a/server/core/.golangci.yml b/server/core/.golangci.yml index 4181a1db..b0b2bcdb 100644 --- a/server/core/.golangci.yml +++ b/server/core/.golangci.yml @@ -255,6 +255,21 @@ linters: - linters: - gochecknoglobals text: ^_.+ + # the demo registry is tuned sampling parameters read as a block: every + # bound, target and spike chance is one row of demo tuning, not a + # threshold logic branches on. Naming each would bury the table, and a + # shared constant would tie together gauges that only coincide today. + - linters: + - goconst + - mnd + path: internal/datasource/demo + # the demo timeline promises bit-identical replay, so its tests compare + # the floats it produces exactly. A tolerance would pass on precisely + # the drift the assertions exist to catch. + - linters: + - testifylint + path: internal/datasource/demo + text: float-compare - linters: - forcetypeassert - gochecknoinits diff --git a/server/core/cmd/core/main.go b/server/core/cmd/core/main.go index 6f4d10d6..de5d7b74 100644 --- a/server/core/cmd/core/main.go +++ b/server/core/cmd/core/main.go @@ -289,10 +289,9 @@ func main() { //nolint:maintidx // main performs linear wiring of all components srv, err := server.NewServer( log, server.Options{ - PublicURL: buildinfo.Getenv("SERVER_PUBLIC_URL"), - DemoPrometheusURL: buildinfo.Getenv("SERVER_DEMO_PROMETHEUS_URL"), - Port: _serverPort, - Origins: origins, + PublicURL: buildinfo.Getenv("SERVER_PUBLIC_URL"), + Port: _serverPort, + Origins: origins, Assistant: server.AssistantCapability{ Status: assistantStatus, Model: assistantOpts.Model, diff --git a/server/core/go.mod b/server/core/go.mod index 39d7213b..9ea6386a 100644 --- a/server/core/go.mod +++ b/server/core/go.mod @@ -6,7 +6,7 @@ require ( github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/Masterminds/squirrel v1.5.4 github.com/blang/semver/v4 v4.0.0 - github.com/bradleyfalzon/ghinstallation/v2 v2.18.0 + github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 github.com/cenkalti/backoff/v4 v4.3.0 github.com/cloudwego/eino v0.9.15 github.com/cloudwego/eino-ext/components/model/claude v0.1.25 @@ -17,70 +17,72 @@ require ( github.com/coder/websocket v1.8.15 github.com/dchest/uniuri v1.2.0 github.com/eino-contrib/jsonschema v1.0.3 - github.com/getsentry/sentry-go v0.46.2 - github.com/go-chi/chi/v5 v5.3.0 + github.com/getsentry/sentry-go v0.48.0 + github.com/go-chi/chi/v5 v5.3.2 github.com/go-chi/cors v1.2.2 github.com/go-sql-driver/mysql v1.10.0 github.com/gomodule/redigo v1.9.3 github.com/google/go-cmp v0.7.0 - github.com/google/go-containerregistry v0.21.6 - github.com/google/go-github/v72 v72.0.0 + github.com/google/go-containerregistry v0.22.0 + github.com/google/go-github/v88 v88.0.0 github.com/gorilla/schema v1.4.1 github.com/gosimple/slug v1.15.0 github.com/guregu/null/v5 v5.0.0 github.com/hashicorp/go-multierror v1.1.1 - github.com/jackc/pgx/v5 v5.9.2 + github.com/jackc/pgx/v5 v5.10.0 github.com/jarcoal/httpmock v1.4.2 - github.com/jellydator/ttlcache/v3 v3.4.0 + github.com/jellydator/ttlcache/v3 v3.4.1 github.com/jellydator/xync v0.0.0-20240601154136-0f038e166df4 github.com/jmoiron/sqlx v1.4.0 github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 - github.com/meilisearch/meilisearch-go v0.36.2 - github.com/minio/minio-go/v7 v7.1.0 + github.com/meilisearch/meilisearch-go v0.36.3 + github.com/minio/minio-go/v7 v7.3.0 github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/orlangure/gnomock v0.32.0 github.com/oxynote/wetsocks v0.1.0 - github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_model v0.6.2 - github.com/prometheus/common v0.67.5 + github.com/prometheus/common v0.70.1 + github.com/prometheus/prometheus v0.314.0 github.com/qustavo/sqlhooks/v2 v2.1.0 github.com/rafaeljusto/redigomock v2.4.0+incompatible github.com/robfig/cron/v3 v3.0.1 github.com/rs/xid v1.6.0 github.com/rubenv/sql-migrate v1.8.1 github.com/shopspring/decimal v1.4.0 - github.com/slack-go/slack v0.23.1 - github.com/stretchr/testify v1.11.1 + github.com/slack-go/slack v0.29.0 + github.com/stretchr/testify v1.12.1 github.com/tidwall/gjson v1.19.0 github.com/wneessen/go-mail v0.8.1 go.uber.org/goleak v1.3.0 - google.golang.org/genai v1.36.0 + google.golang.org/genai v1.69.0 ) require ( cloud.google.com/go v0.116.0 // indirect - cloud.google.com/go/auth v0.9.3 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect - cloud.google.com/go/compute/metadata v0.7.0 // indirect + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/andybalholm/brotli v1.2.1 // indirect github.com/anthropics/anthropic-sdk-go v1.56.0 // indirect - github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect + github.com/aws/aws-sdk-go-v2 v1.43.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect - github.com/aws/aws-sdk-go-v2/config v1.29.15 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.17.68 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.33.20 // indirect - github.com/aws/smithy-go v1.22.2 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.31 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.30 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect + github.com/aws/smithy-go v1.27.4 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect @@ -94,32 +96,35 @@ require ( github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dennwc/varint v1.0.0 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/cli v29.5.2+incompatible // indirect + github.com/docker/cli v29.7.2+incompatible // indirect github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.7 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/edsrzf/mmap-go v1.2.1-0.20241212181136-fad1cd13edbd // indirect github.com/eino-contrib/ollama v0.1.0 // indirect github.com/evanphx/json-patch v0.5.2 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect - github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/google/go-github/v84 v84.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/jsonschema-go v0.4.3 // indirect - github.com/google/s2a-go v0.1.8 // indirect + github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.18 // indirect + github.com/googleapis/gax-go/v2 v2.23.0 // indirect github.com/goph/emperror v0.17.2 // indirect - github.com/gorilla/websocket v1.5.3 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gosimple/unidecode v1.0.1 // indirect + github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/invopop/jsonschema v0.14.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -127,8 +132,8 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.6 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lib/pq v1.10.9 // indirect @@ -139,18 +144,17 @@ require ( github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect github.com/nikolalohinski/gonja v1.5.3 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/procfs v0.20.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/sirupsen/logrus v1.9.4 // indirect @@ -162,36 +166,46 @@ require ( github.com/tinylib/msgp v1.6.4 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yargevad/filepathx v1.0.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect - go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.41.0 // indirect - go.opentelemetry.io/otel/sdk v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect - go.opentelemetry.io/proto/otlp v1.7.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect golang.org/x/arch v0.12.0 // indirect - golang.org/x/crypto v0.54.0 // indirect + golang.org/x/crypto v0.55.0 // indirect golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect - golang.org/x/net v0.56.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/api v0.197.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect - google.golang.org/grpc v1.74.2 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/api v0.290.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260720155508-bb71a54f79dc // indirect + google.golang.org/grpc v1.82.1 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apimachinery v0.35.3 // indirect + k8s.io/client-go v0.35.3 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/server/core/go.sum b/server/core/go.sum index 18a7ba8c..c039824b 100644 --- a/server/core/go.sum +++ b/server/core/go.sum @@ -1,18 +1,24 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= -cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U= -cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk= -cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY= -cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc= -cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= @@ -20,40 +26,46 @@ github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA4 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anthropics/anthropic-sdk-go v1.56.0 h1:idVU14wOZ06D0GBNEvuhn927xXmBVEquo0469iDwLsc= github.com/anthropics/anthropic-sdk-go v1.56.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI= -github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= -github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= +github.com/aws/aws-sdk-go-v2 v1.43.0 h1:fharf/WhbRAVZ1du0QL7roNFxZ6T/sWr+4Ni617bwSI= +github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14= -github.com/aws/aws-sdk-go-v2/config v1.29.15 h1:I5XjesVMpDZXZEZonVfjI12VNMrYa38LtLnw4NtY5Ss= -github.com/aws/aws-sdk-go-v2/config v1.29.15/go.mod h1:tNIp4JIPonlsgaO5hxO372a6gjhN63aSWl2GVl5QoBQ= -github.com/aws/aws-sdk-go-v2/credentials v1.17.68 h1:cFb9yjI02/sWHBSYXAtkamjzCuRymvmeFmt0TC0MbYY= -github.com/aws/aws-sdk-go-v2/credentials v1.17.68/go.mod h1:H6E+jBzyqUu8u0vGaU6POkK3P0NylYEeRZ6ynBpMqIk= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.20 h1:oIaQ1e17CSKaWmUTu62MtraRWVIosn/iONMuZt0gbqc= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.20/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= -github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= -github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/aws/aws-sdk-go-v2/config v1.32.31 h1:n4nY9O3QKoHIkL85EX+V8RcMFtOhlpTFhGArg915PXk= +github.com/aws/aws-sdk-go-v2/config v1.32.31/go.mod h1:PN0NYDCCoOpGGsZ2+elDUidmHfQBPyYzN2GCgl8HEBs= +github.com/aws/aws-sdk-go-v2/credentials v1.19.30 h1:TTCvvzFU6gXa4iJecNG/0F/B0oYTiazoRECr2XyLHrY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 h1:kfVL5wAunCJycL6MOQ6aNh6PlAYEymflcjuKmrWUA0o= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31/go.mod h1:nWfRNDAppujCQgOUd43lKT4yeLv9z3nJ3bw1G3BgQKo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 h1:Z8F3hfCY33IGpJjFAnv0wvtv1FIKj1GHmRDEYqy64tw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31/go.mod h1:aVyUoytEyOViR6jhq6jula0xkc5NfBE2hgeF6BvOrao= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 h1:hyOxUyXdh3AyjE93gBgsfziJag9ACwcs+ZpDBLzi8mw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31/go.mod h1:OERqI9k0draSLB8O8woxY3q25ZWTELRK4RRoLMuMZFo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 h1:0MrUL35H/Y4kdFfItoR5jCgtDQ4Z/8LudAoIHRfA4hE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32/go.mod h1:2tNZkuWz54arj8mHVf+8Y7cKkcD8Wr/fBpENgEXpjLc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 h1:w2SIhW92DZPFrSL4ksVCr8IYff5OZwIcxg8+95tzvAI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31/go.mod h1:wAhpCQbkov+IcvjozJbd2xRCoZybUEHNkcFunssNACg= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 h1:OHH5iTQvVGmfHjX/5Q+vFuA/Rf2x6/95aJ/75QCQSm4= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.0/go.mod h1:mCF3AK9PpL49oOrhniUXWAfhVBVQ/XbytoE5eccZUIs= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 h1:CaJyYhxBE0M/HJX/YvSaSmQlsI91VHB0lKU8LtLxL3A= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.0/go.mod h1:+e6BMRMPjBQoCw/WovYR9GLy2IU0z4Q77smOB1DraSg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 h1:tC323YV77QdafeBr6LUhLDTsboyuyHLNRwAyCP44kGU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0/go.mod h1:SfLK1sgviHmbI+MozR9iDwDjL4cdCVZtahsjoR+z7wg= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 h1:Pd6PNlp4t8PTXxqzstICl52Wsy78vpjFZ7PRUj44mJc= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg= +github.com/aws/smithy-go v1.27.4 h1:JQcphmBN4f0q/sPqXqROIItRNV/hy10cgu7CsFy616M= +github.com/aws/smithy-go v1.27.4/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo4tARvBm7l6KA9iVMnE3NWizDeWSrps= +github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3/go.mod h1:CIWtjkly68+yqLPbvwwR/fjNJA/idrtULjZWh2v1ys0= 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/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= @@ -62,8 +74,8 @@ github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2y github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= -github.com/bradleyfalzon/ghinstallation/v2 v2.18.0 h1:WPqnN6NS9XvYlOgZQAIseN7Z1uAiE+UxgDKlW7FvFuU= -github.com/bradleyfalzon/ghinstallation/v2 v2.18.0/go.mod h1:gpoSwwWc4biE49F7n+roCcpkEkZ1Qr9soZ2ESvMiouU= +github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 h1:KQfD+43pRw9NUJhGycGrFr9vF1MubZacksKol1gomFI= +github.com/bradleyfalzon/ghinstallation/v2 v2.19.0/go.mod h1:fe5ECIhCdEnxwLiBlNTxx9CP455wt42BELnlDVMvaAA= github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g= github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= @@ -78,11 +90,11 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/eino v0.9.15 h1:F+7uXeZYbJm30a/kaC93Mj6H9K2zx4thQaQP695NggE= @@ -99,7 +111,8 @@ github.com/cloudwego/eino-ext/components/model/openrouter v0.1.10 h1:i5YdCW8omvO github.com/cloudwego/eino-ext/components/model/openrouter v0.1.10/go.mod h1:WRc1OIXVaf2USal59xrIt+05TUCu9P1c8MQh0FcWG8c= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -114,12 +127,14 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= +github.com/dennwc/varint v1.0.0 h1:kGNFFSSw8ToIy3obO/kKr8U9GZYUAxQEVuix4zfDWzE= +github.com/dennwc/varint v1.0.0/go.mod h1:hnItb35rvZvJrbTALZtY/iQfDs48JKRG1RPpgziApxA= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= -github.com/docker/cli v29.5.2+incompatible h1:ubykJ1Y8LmNRGJ2BuMQ0kHOt/RO1YzGNswqWMJgivuQ= -github.com/docker/cli v29.5.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.7.2+incompatible h1:dlkwallR8XqfeVnA2ELEhdwvb4lsSwuB4IgsG8Q9cLY= +github.com/docker/cli v29.7.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.9.7 h1:jaPIxEIDz5bQeghNAdzz0ETwMMnM4vzjZlxz3pWP4JA= @@ -130,39 +145,74 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/edsrzf/mmap-go v1.2.1-0.20241212181136-fad1cd13edbd h1:I4PrRZuNMeDP3VbFrak4QsqwO5tWkQf0tqrrr1L2DsU= +github.com/edsrzf/mmap-go v1.2.1-0.20241212181136-fad1cd13edbd/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q= github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0= github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= github.com/eino-contrib/ollama v0.1.0 h1:z1NaMdKW6X1ftP8g5xGGR5zDRPUtuTKFq35vBQgxsN4= github.com/eino-contrib/ollama v0.1.0/go.mod h1:mYsQ7b3DeqY8bHPuD3MZJYTqkgyL6LoemxoP/B7ZNhA= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb h1:IT4JYU7k4ikYg1SCxNI1/Tieq/NFvh6dzLdgi7eu0tM= +github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= -github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns= -github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw= +github.com/getsentry/sentry-go v0.48.0 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY= +github.com/getsentry/sentry-go v0.48.0/go.mod h1:E5UkA5wp1qR2+MDydNYlVeUiNN2xEdjYMidkgf0Qoss= github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= -github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= -github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= -github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= -github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.23.2 h1:DK7R/3zAt4xTytxNkw7jARGPFI7rkaSsii58n8X45x0= +github.com/go-openapi/jsonpointer v0.23.2/go.mod h1:noUOckXtq7b4bVkqw0sbHKieq9uEZRN7p6EF/dalc4w= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= +github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= +github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= +github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= +github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= +github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= +github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= +github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= +github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= +github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= +github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= +github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= +github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= +github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= +github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= +github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= +github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= +github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= @@ -174,65 +224,51 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8= github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM= -github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo= -github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM= -github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg= -github.com/google/go-github/v84 v84.0.0 h1:I/0Xn5IuChMe8TdmI2bbim5nyhaRFJ7DEdzmD2w+yVA= -github.com/google/go-github/v84 v84.0.0/go.mod h1:WwYL1z1ajRdlaPszjVu/47x1L0PXukJBn73xsiYrRRQ= +github.com/google/go-containerregistry v0.22.0 h1:eGbCiPeYxAH/7WLLq6zTBALP0tUIFsoyRauhxXDJ53I= +github.com/google/go-containerregistry v0.22.0/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0= +github.com/google/go-github/v88 v88.0.0 h1:dZA9IKkPK1eXZj4ypngnpRj5FwdpTv4whix2PrQMP7M= +github.com/google/go-github/v88 v88.0.0/go.mod h1:rufTDgn2N45wjhukLTyxmvc9nilSp3mr3Rgtt6b1MPw= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= -github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= -github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/googleapis/enterprise-certificate-proxy v0.3.18 h1:hvVi34VucdrV1IIsiWuqYM8kutw/92MxNEFxCJZEh0k= +github.com/googleapis/enterprise-certificate-proxy v0.3.18/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= +github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18= github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 h1:cLN4IBkmkYZNnk7EAJ0BHIethd+J6LqxFNw5mSiI2bM= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/guregu/null/v5 v5.0.0 h1:PRxjqyOekS11W+w/7Vfz6jgJE/BCwELWtgvOJzddimw= github.com/guregu/null/v5 v5.0.0/go.mod h1:SjupzNy+sCPtwQTKWhUCqjhVCO69hpsl2QsZrWHjlwU= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -247,14 +283,14 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= -github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI= github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= -github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= -github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= +github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= github.com/jellydator/xync v0.0.0-20240601154136-0f038e166df4 h1:8Pm5dsAGaQ+uuGyB705/7epGUStVLe7DFROYdFTq5aA= github.com/jellydator/xync v0.0.0-20240601154136-0f038e166df4/go.mod h1:cf3z0/QbPKWqY/8YJHkvjiJQx1sjVaJfueetY6YHJJk= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= @@ -268,11 +304,11 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= -github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -294,10 +330,10 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= +github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= @@ -305,16 +341,16 @@ github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA= github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= -github.com/meilisearch/meilisearch-go v0.36.2 h1:MYaMPCpdLh2aYPt+zK+19mLoA4dfBY3S1L7T0FADCjU= -github.com/meilisearch/meilisearch-go v0.36.2/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM= +github.com/meilisearch/meilisearch-go v0.36.3 h1:Yx1aTY5jDgtbStPVkhJTDoLnZTy5sejQSPyjfNMy6e4= +github.com/meilisearch/meilisearch-go v0.36.3/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8= -github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA= +github.com/minio/minio-go/v7 v7.3.0 h1:HM4pFCSQq/TK+j0/zmorSh5ddh81iDgRgU0BG0Vz/YU= +github.com/minio/minio-go/v7 v7.3.0/go.mod h1:KUPWdecEO1LWyUz+sTGXAuf2jZHrPh5fCsRH86QbPfk= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= @@ -328,8 +364,9 @@ github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -338,6 +375,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c= github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= +github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= +github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= @@ -352,29 +391,40 @@ github.com/oxynote/wetsocks v0.1.0 h1:amd/vv58vNVXgQsR3QFK/HGHHXs6kFu8MBqn1Sp/35 github.com/oxynote/wetsocks v0.1.0/go.mod h1:KlgjMvRx1pxooHqEvghbrXPIW1oc7/x03B7uiyMnEAM= github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_golang/exp v0.0.0-20260724065723-ecdb8254ba61 h1:SgKx/5u9SwqzZ27E1T4bfuisjTOkI3GagC6WtdEE5lg= +github.com/prometheus/client_golang/exp v0.0.0-20260724065723-ecdb8254ba61/go.mod h1:CoLfLGxCH1vzpdmZ+p2uaUGH43j+99HYmnK1Wak6rS4= 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.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +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/prometheus/prometheus v0.314.0 h1:YjsimqsIi6/mOtzZcrPEYUALO6zpfaht9O5sXqDz2vg= +github.com/prometheus/prometheus v0.314.0/go.mod h1:zjg3pMTAkY0/JG8jy/h8/YgSQUVB+aCXMhUqN6l64jg= +github.com/prometheus/sigv4 v0.4.1 h1:EIc3j+8NBea9u1iV6O5ZAN8uvPq2xOIUPcqCTivHuXs= +github.com/prometheus/sigv4 v0.4.1/go.mod h1:eu+ZbRvsc5TPiHwqh77OWuCnWK73IdkETYY46P4dXOU= github.com/qustavo/sqlhooks/v2 v2.1.0 h1:54yBemHnGHp/7xgT+pxwmIlMSDNYKx5JW5dfRAiCZi0= github.com/qustavo/sqlhooks/v2 v2.1.0/go.mod h1:aMREyKo7fOKTwiLuWPsaHRXEmtqG4yREztO0idF83AU= github.com/rafaeljusto/redigomock v2.4.0+incompatible h1:d7uo5MVINMxnRr20MxbgDkmZ8QRfevjOVgEa4n0OZyY= @@ -397,14 +447,16 @@ github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+D github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= -github.com/slack-go/slack v0.23.1 h1:ZS5B96wxxYQRwvJ3/vJFtqtUZi3tXhsZCyT44Nv7M80= -github.com/slack-go/slack v0.23.1/go.mod h1:H0yR/YBuRJ39RkE+JpV/d/oEsbanzTRowR82bCN0cEs= +github.com/slack-go/slack v0.29.0 h1:ohhMNgp9DmPKiLhH/pNZV4NxhOXKgNy0SH8FzVHNerI= +github.com/slack-go/slack v0.29.0/go.mod h1:UEe+jmo9WLlwHB04qsOrTDvqM7Aa4rQL3O5wF3n0hx4= github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI= github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg= github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI= github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -412,18 +464,19 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= @@ -445,6 +498,8 @@ github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pc github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w= github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= @@ -455,137 +510,101 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0 h1:Mbi5PKN7u322woPa85d7ebZ+SOvEoPvoiBu+ryHWgfA= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0/go.mod h1:e7ciERRhZaOZXVjx5MiL8TK5+Xv7G5Gv5PA2ZDEJdL8= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= -go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= -go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= 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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= -go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= +go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM= golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -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.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= 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-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.197.0 h1:x6CwqQLsFiA5JKAiGyGBjc2bNtHtLddhJCE2IKuhhcQ= -google.golang.org/api v0.197.0/go.mod h1:AuOuo20GoQ331nq7DquGHlU6d+2wN2fZ8O0ta60nRNw= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genai v1.36.0 h1:sJCIjqTAmwrtAIaemtTiKkg2TO1RxnYEusTmEQ3nGxM= -google.golang.org/genai v1.36.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU= -google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 h1:0UOBWO4dC+e51ui0NFKSPbkHHiQ4TmrEfEZMLDyRmY8= -google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= -google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.290.0 h1:eMw0Xo+IfbbMlKmW7aHvpyQRv9RCXuWx/vs8AD+0x9A= +google.golang.org/api v0.290.0/go.mod h1:weJZ3lldHFYI0DBFNKpJelUDNnusTt5YaOEgxvt8ci8= +google.golang.org/genai v1.69.0 h1:quP3Rbiz0Mn+zPfXsWHQQwOx8IfO2MnQehZUbrJ/jPo= +google.golang.org/genai v1.69.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260724162435-b2f20204f0df h1:NsJx+hCSwIBI6+C4BuJIkb8xOG1M+nfQDsqIrQHT92k= +google.golang.org/genproto/googleapis/api v0.0.0-20260724162435-b2f20204f0df/go.mod h1:1brfde68Npq6+WA75c1EHWPijZEG1kMus61ygPZfn4A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260720155508-bb71a54f79dc h1:3TtNq/QbJNrSY1nVdjcikfBw6ujnaNbdrd88wNr1OW4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260720155508-bb71a54f79dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -596,5 +615,23 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= +k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= +k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= +k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= +k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.3 h1:u08YRbVUi59ri4YD6cg0UqNM4Dimn0sIl+wldcx5PYw= +sigs.k8s.io/structured-merge-diff/v6 v6.3.3/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/server/core/internal/_mock/meilisearch_index_manager.go b/server/core/internal/_mock/meilisearch_index_manager.go index 3c79ef2b..dd0cd48d 100644 --- a/server/core/internal/_mock/meilisearch_index_manager.go +++ b/server/core/internal/_mock/meilisearch_index_manager.go @@ -275,10 +275,10 @@ var _ meilisearch.IndexManager = &MeiliIndexManager{} // GetSortableAttributesWithContextFunc: func(ctx context.Context) (*[]string, error) { // panic("mock out the GetSortableAttributesWithContext method") // }, -// GetStatsFunc: func() (*meilisearch.StatsIndex, error) { +// GetStatsFunc: func(param *meilisearch.StatsParams) (*meilisearch.StatsIndex, error) { // panic("mock out the GetStats method") // }, -// GetStatsWithContextFunc: func(ctx context.Context) (*meilisearch.StatsIndex, error) { +// GetStatsWithContextFunc: func(ctx context.Context, param *meilisearch.StatsParams) (*meilisearch.StatsIndex, error) { // panic("mock out the GetStatsWithContext method") // }, // GetStopWordsFunc: func() (*[]string, error) { @@ -898,10 +898,10 @@ type MeiliIndexManager struct { GetSortableAttributesWithContextFunc func(ctx context.Context) (*[]string, error) // GetStatsFunc mocks the GetStats method. - GetStatsFunc func() (*meilisearch.StatsIndex, error) + GetStatsFunc func(param *meilisearch.StatsParams) (*meilisearch.StatsIndex, error) // GetStatsWithContextFunc mocks the GetStatsWithContext method. - GetStatsWithContextFunc func(ctx context.Context) (*meilisearch.StatsIndex, error) + GetStatsWithContextFunc func(ctx context.Context, param *meilisearch.StatsParams) (*meilisearch.StatsIndex, error) // GetStopWordsFunc mocks the GetStopWords method. GetStopWordsFunc func() (*[]string, error) @@ -1750,11 +1750,15 @@ type MeiliIndexManager struct { } // GetStats holds details about calls to the GetStats method. GetStats []struct { + // Param is the param argument value. + Param *meilisearch.StatsParams } // GetStatsWithContext holds details about calls to the GetStatsWithContext method. GetStatsWithContext []struct { // Ctx is the ctx argument value. Ctx context.Context + // Param is the param argument value. + Param *meilisearch.StatsParams } // GetStopWords holds details about calls to the GetStopWords method. GetStopWords []struct { @@ -5751,9 +5755,12 @@ func (mock *MeiliIndexManager) GetSortableAttributesWithContextCalls() []struct } // GetStats calls GetStatsFunc. -func (mock *MeiliIndexManager) GetStats() (*meilisearch.StatsIndex, error) { +func (mock *MeiliIndexManager) GetStats(param *meilisearch.StatsParams) (*meilisearch.StatsIndex, error) { callInfo := struct { - }{} + Param *meilisearch.StatsParams + }{ + Param: param, + } mock.lockGetStats.Lock() mock.calls.GetStats = append(mock.calls.GetStats, callInfo) mock.lockGetStats.Unlock() @@ -5764,7 +5771,7 @@ func (mock *MeiliIndexManager) GetStats() (*meilisearch.StatsIndex, error) { ) return statsIndexOut, errOut } - return mock.GetStatsFunc() + return mock.GetStatsFunc(param) } // GetStatsCalls gets all the calls that were made to GetStats. @@ -5772,8 +5779,10 @@ func (mock *MeiliIndexManager) GetStats() (*meilisearch.StatsIndex, error) { // // len(mockedIndexManager.GetStatsCalls()) func (mock *MeiliIndexManager) GetStatsCalls() []struct { + Param *meilisearch.StatsParams } { var calls []struct { + Param *meilisearch.StatsParams } mock.lockGetStats.RLock() calls = mock.calls.GetStats @@ -5782,11 +5791,13 @@ func (mock *MeiliIndexManager) GetStatsCalls() []struct { } // GetStatsWithContext calls GetStatsWithContextFunc. -func (mock *MeiliIndexManager) GetStatsWithContext(ctx context.Context) (*meilisearch.StatsIndex, error) { +func (mock *MeiliIndexManager) GetStatsWithContext(ctx context.Context, param *meilisearch.StatsParams) (*meilisearch.StatsIndex, error) { callInfo := struct { - Ctx context.Context + Ctx context.Context + Param *meilisearch.StatsParams }{ - Ctx: ctx, + Ctx: ctx, + Param: param, } mock.lockGetStatsWithContext.Lock() mock.calls.GetStatsWithContext = append(mock.calls.GetStatsWithContext, callInfo) @@ -5798,7 +5809,7 @@ func (mock *MeiliIndexManager) GetStatsWithContext(ctx context.Context) (*meilis ) return statsIndexOut, errOut } - return mock.GetStatsWithContextFunc(ctx) + return mock.GetStatsWithContextFunc(ctx, param) } // GetStatsWithContextCalls gets all the calls that were made to GetStatsWithContext. @@ -5806,10 +5817,12 @@ func (mock *MeiliIndexManager) GetStatsWithContext(ctx context.Context) (*meilis // // len(mockedIndexManager.GetStatsWithContextCalls()) func (mock *MeiliIndexManager) GetStatsWithContextCalls() []struct { - Ctx context.Context + Ctx context.Context + Param *meilisearch.StatsParams } { var calls []struct { - Ctx context.Context + Ctx context.Context + Param *meilisearch.StatsParams } mock.lockGetStatsWithContext.RLock() calls = mock.calls.GetStatsWithContext diff --git a/server/core/internal/_mock/meilisearch_service_manager.go b/server/core/internal/_mock/meilisearch_service_manager.go index bc98900c..5f4326a5 100644 --- a/server/core/internal/_mock/meilisearch_service_manager.go +++ b/server/core/internal/_mock/meilisearch_service_manager.go @@ -84,6 +84,12 @@ var _ meilisearch.ServiceManager = &MeiliServiceManager{} // DeleteKeyWithContextFunc: func(ctx context.Context, keyOrUID string) (bool, error) { // panic("mock out the DeleteKeyWithContext method") // }, +// DeleteSearchRuleFunc: func(uid string) error { +// panic("mock out the DeleteSearchRule method") +// }, +// DeleteSearchRuleWithContextFunc: func(ctx context.Context, uid string) error { +// panic("mock out the DeleteSearchRuleWithContext method") +// }, // DeleteTasksFunc: func(param *meilisearch.DeleteTasksQuery) (*meilisearch.TaskInfo, error) { // panic("mock out the DeleteTasks method") // }, @@ -168,15 +174,27 @@ var _ meilisearch.ServiceManager = &MeiliServiceManager{} // GetRawIndexesWithContextFunc: func(ctx context.Context, param *meilisearch.IndexesQuery) (map[string]interface{}, error) { // panic("mock out the GetRawIndexesWithContext method") // }, -// GetStatsFunc: func() (*meilisearch.Stats, error) { +// GetSearchRuleFunc: func(uid string) (*meilisearch.SearchRule, error) { +// panic("mock out the GetSearchRule method") +// }, +// GetSearchRuleWithContextFunc: func(ctx context.Context, uid string) (*meilisearch.SearchRule, error) { +// panic("mock out the GetSearchRuleWithContext method") +// }, +// GetStatsFunc: func(param *meilisearch.StatsParams) (*meilisearch.Stats, error) { // panic("mock out the GetStats method") // }, -// GetStatsWithContextFunc: func(ctx context.Context) (*meilisearch.Stats, error) { +// GetStatsWithContextFunc: func(ctx context.Context, param *meilisearch.StatsParams) (*meilisearch.Stats, error) { // panic("mock out the GetStatsWithContext method") // }, // GetTaskFunc: func(taskUID int64) (*meilisearch.Task, error) { // panic("mock out the GetTask method") // }, +// GetTaskDocumentsFunc: func(taskUID int64, dst interface{}) error { +// panic("mock out the GetTaskDocuments method") +// }, +// GetTaskDocumentsWithContextFunc: func(ctx context.Context, taskUID int64, dst interface{}) error { +// panic("mock out the GetTaskDocumentsWithContext method") +// }, // GetTaskWithContextFunc: func(ctx context.Context, taskUID int64) (*meilisearch.Task, error) { // panic("mock out the GetTaskWithContext method") // }, @@ -222,6 +240,12 @@ var _ meilisearch.ServiceManager = &MeiliServiceManager{} // ListIndexesWithContextFunc: func(ctx context.Context, param *meilisearch.IndexesQuery) (*meilisearch.IndexesResults, error) { // panic("mock out the ListIndexesWithContext method") // }, +// ListSearchRulesFunc: func(params *meilisearch.SearchRulesParams) (*meilisearch.SearchRulesResults, error) { +// panic("mock out the ListSearchRules method") +// }, +// ListSearchRulesWithContextFunc: func(ctx context.Context, params *meilisearch.SearchRulesParams) (*meilisearch.SearchRulesResults, error) { +// panic("mock out the ListSearchRulesWithContext method") +// }, // ListWebhooksFunc: func() (*meilisearch.WebhookResults, error) { // panic("mock out the ListWebhooks method") // }, @@ -240,6 +264,12 @@ var _ meilisearch.ServiceManager = &MeiliServiceManager{} // ResetChatWorkspaceWithContextFunc: func(ctx context.Context, uid string) (*meilisearch.ChatWorkspaceSettings, error) { // panic("mock out the ResetChatWorkspaceWithContext method") // }, +// SearchRulesManagerFunc: func() meilisearch.SearchRulesManager { +// panic("mock out the SearchRulesManager method") +// }, +// SearchRulesReaderFunc: func() meilisearch.SearchRulesReader { +// panic("mock out the SearchRulesReader method") +// }, // ServiceReaderFunc: func() meilisearch.ServiceReader { // panic("mock out the ServiceReader method") // }, @@ -273,6 +303,12 @@ var _ meilisearch.ServiceManager = &MeiliServiceManager{} // UpdateNetworkWithContextFunc: func(ctx context.Context, params *meilisearch.UpdateNetworkRequest) (any, error) { // panic("mock out the UpdateNetworkWithContext method") // }, +// UpdateSearchRuleFunc: func(uid string, params *meilisearch.SearchRulesRequest) (*meilisearch.SearchRule, error) { +// panic("mock out the UpdateSearchRule method") +// }, +// UpdateSearchRuleWithContextFunc: func(ctx context.Context, uid string, params *meilisearch.SearchRulesRequest) (*meilisearch.SearchRule, error) { +// panic("mock out the UpdateSearchRuleWithContext method") +// }, // UpdateWebhookFunc: func(uuid string, params *meilisearch.UpdateWebhookRequest) (*meilisearch.Webhook, error) { // panic("mock out the UpdateWebhook method") // }, @@ -367,6 +403,12 @@ type MeiliServiceManager struct { // DeleteKeyWithContextFunc mocks the DeleteKeyWithContext method. DeleteKeyWithContextFunc func(ctx context.Context, keyOrUID string) (bool, error) + // DeleteSearchRuleFunc mocks the DeleteSearchRule method. + DeleteSearchRuleFunc func(uid string) error + + // DeleteSearchRuleWithContextFunc mocks the DeleteSearchRuleWithContext method. + DeleteSearchRuleWithContextFunc func(ctx context.Context, uid string) error + // DeleteTasksFunc mocks the DeleteTasks method. DeleteTasksFunc func(param *meilisearch.DeleteTasksQuery) (*meilisearch.TaskInfo, error) @@ -451,15 +493,27 @@ type MeiliServiceManager struct { // GetRawIndexesWithContextFunc mocks the GetRawIndexesWithContext method. GetRawIndexesWithContextFunc func(ctx context.Context, param *meilisearch.IndexesQuery) (map[string]interface{}, error) + // GetSearchRuleFunc mocks the GetSearchRule method. + GetSearchRuleFunc func(uid string) (*meilisearch.SearchRule, error) + + // GetSearchRuleWithContextFunc mocks the GetSearchRuleWithContext method. + GetSearchRuleWithContextFunc func(ctx context.Context, uid string) (*meilisearch.SearchRule, error) + // GetStatsFunc mocks the GetStats method. - GetStatsFunc func() (*meilisearch.Stats, error) + GetStatsFunc func(param *meilisearch.StatsParams) (*meilisearch.Stats, error) // GetStatsWithContextFunc mocks the GetStatsWithContext method. - GetStatsWithContextFunc func(ctx context.Context) (*meilisearch.Stats, error) + GetStatsWithContextFunc func(ctx context.Context, param *meilisearch.StatsParams) (*meilisearch.Stats, error) // GetTaskFunc mocks the GetTask method. GetTaskFunc func(taskUID int64) (*meilisearch.Task, error) + // GetTaskDocumentsFunc mocks the GetTaskDocuments method. + GetTaskDocumentsFunc func(taskUID int64, dst interface{}) error + + // GetTaskDocumentsWithContextFunc mocks the GetTaskDocumentsWithContext method. + GetTaskDocumentsWithContextFunc func(ctx context.Context, taskUID int64, dst interface{}) error + // GetTaskWithContextFunc mocks the GetTaskWithContext method. GetTaskWithContextFunc func(ctx context.Context, taskUID int64) (*meilisearch.Task, error) @@ -505,6 +559,12 @@ type MeiliServiceManager struct { // ListIndexesWithContextFunc mocks the ListIndexesWithContext method. ListIndexesWithContextFunc func(ctx context.Context, param *meilisearch.IndexesQuery) (*meilisearch.IndexesResults, error) + // ListSearchRulesFunc mocks the ListSearchRules method. + ListSearchRulesFunc func(params *meilisearch.SearchRulesParams) (*meilisearch.SearchRulesResults, error) + + // ListSearchRulesWithContextFunc mocks the ListSearchRulesWithContext method. + ListSearchRulesWithContextFunc func(ctx context.Context, params *meilisearch.SearchRulesParams) (*meilisearch.SearchRulesResults, error) + // ListWebhooksFunc mocks the ListWebhooks method. ListWebhooksFunc func() (*meilisearch.WebhookResults, error) @@ -523,6 +583,12 @@ type MeiliServiceManager struct { // ResetChatWorkspaceWithContextFunc mocks the ResetChatWorkspaceWithContext method. ResetChatWorkspaceWithContextFunc func(ctx context.Context, uid string) (*meilisearch.ChatWorkspaceSettings, error) + // SearchRulesManagerFunc mocks the SearchRulesManager method. + SearchRulesManagerFunc func() meilisearch.SearchRulesManager + + // SearchRulesReaderFunc mocks the SearchRulesReader method. + SearchRulesReaderFunc func() meilisearch.SearchRulesReader + // ServiceReaderFunc mocks the ServiceReader method. ServiceReaderFunc func() meilisearch.ServiceReader @@ -556,6 +622,12 @@ type MeiliServiceManager struct { // UpdateNetworkWithContextFunc mocks the UpdateNetworkWithContext method. UpdateNetworkWithContextFunc func(ctx context.Context, params *meilisearch.UpdateNetworkRequest) (any, error) + // UpdateSearchRuleFunc mocks the UpdateSearchRule method. + UpdateSearchRuleFunc func(uid string, params *meilisearch.SearchRulesRequest) (*meilisearch.SearchRule, error) + + // UpdateSearchRuleWithContextFunc mocks the UpdateSearchRuleWithContext method. + UpdateSearchRuleWithContextFunc func(ctx context.Context, uid string, params *meilisearch.SearchRulesRequest) (*meilisearch.SearchRule, error) + // UpdateWebhookFunc mocks the UpdateWebhook method. UpdateWebhookFunc func(uuid string, params *meilisearch.UpdateWebhookRequest) (*meilisearch.Webhook, error) @@ -695,6 +767,18 @@ type MeiliServiceManager struct { // KeyOrUID is the keyOrUID argument value. KeyOrUID string } + // DeleteSearchRule holds details about calls to the DeleteSearchRule method. + DeleteSearchRule []struct { + // UID is the uid argument value. + UID string + } + // DeleteSearchRuleWithContext holds details about calls to the DeleteSearchRuleWithContext method. + DeleteSearchRuleWithContext []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // UID is the uid argument value. + UID string + } // DeleteTasks holds details about calls to the DeleteTasks method. DeleteTasks []struct { // Param is the param argument value. @@ -859,19 +943,51 @@ type MeiliServiceManager struct { // Param is the param argument value. Param *meilisearch.IndexesQuery } + // GetSearchRule holds details about calls to the GetSearchRule method. + GetSearchRule []struct { + // UID is the uid argument value. + UID string + } + // GetSearchRuleWithContext holds details about calls to the GetSearchRuleWithContext method. + GetSearchRuleWithContext []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // UID is the uid argument value. + UID string + } // GetStats holds details about calls to the GetStats method. GetStats []struct { + // Param is the param argument value. + Param *meilisearch.StatsParams } // GetStatsWithContext holds details about calls to the GetStatsWithContext method. GetStatsWithContext []struct { // Ctx is the ctx argument value. Ctx context.Context + // Param is the param argument value. + Param *meilisearch.StatsParams } // GetTask holds details about calls to the GetTask method. GetTask []struct { // TaskUID is the taskUID argument value. TaskUID int64 } + // GetTaskDocuments holds details about calls to the GetTaskDocuments method. + GetTaskDocuments []struct { + // TaskUID is the taskUID argument value. + TaskUID int64 + // Dst is the dst argument value. + Dst interface{} + } + // GetTaskDocumentsWithContext holds details about calls to the GetTaskDocumentsWithContext method. + GetTaskDocumentsWithContext []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // TaskUID is the taskUID argument value. + TaskUID int64 + // Dst is the dst argument value. + Dst interface{} + } // GetTaskWithContext holds details about calls to the GetTaskWithContext method. GetTaskWithContext []struct { // Ctx is the ctx argument value. @@ -949,6 +1065,18 @@ type MeiliServiceManager struct { // Param is the param argument value. Param *meilisearch.IndexesQuery } + // ListSearchRules holds details about calls to the ListSearchRules method. + ListSearchRules []struct { + // Params is the params argument value. + Params *meilisearch.SearchRulesParams + } + // ListSearchRulesWithContext holds details about calls to the ListSearchRulesWithContext method. + ListSearchRulesWithContext []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Params is the params argument value. + Params *meilisearch.SearchRulesParams + } // ListWebhooks holds details about calls to the ListWebhooks method. ListWebhooks []struct { } @@ -981,6 +1109,12 @@ type MeiliServiceManager struct { // UID is the uid argument value. UID string } + // SearchRulesManager holds details about calls to the SearchRulesManager method. + SearchRulesManager []struct { + } + // SearchRulesReader holds details about calls to the SearchRulesReader method. + SearchRulesReader []struct { + } // ServiceReader holds details about calls to the ServiceReader method. ServiceReader []struct { } @@ -1046,6 +1180,22 @@ type MeiliServiceManager struct { // Params is the params argument value. Params *meilisearch.UpdateNetworkRequest } + // UpdateSearchRule holds details about calls to the UpdateSearchRule method. + UpdateSearchRule []struct { + // UID is the uid argument value. + UID string + // Params is the params argument value. + Params *meilisearch.SearchRulesRequest + } + // UpdateSearchRuleWithContext holds details about calls to the UpdateSearchRuleWithContext method. + UpdateSearchRuleWithContext []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // UID is the uid argument value. + UID string + // Params is the params argument value. + Params *meilisearch.SearchRulesRequest + } // UpdateWebhook holds details about calls to the UpdateWebhook method. UpdateWebhook []struct { // UUID is the uuid argument value. @@ -1114,6 +1264,8 @@ type MeiliServiceManager struct { lockDeleteIndexWithContext sync.RWMutex lockDeleteKey sync.RWMutex lockDeleteKeyWithContext sync.RWMutex + lockDeleteSearchRule sync.RWMutex + lockDeleteSearchRuleWithContext sync.RWMutex lockDeleteTasks sync.RWMutex lockDeleteTasksWithContext sync.RWMutex lockDeleteWebhook sync.RWMutex @@ -1142,9 +1294,13 @@ type MeiliServiceManager struct { lockGetRawIndexWithContext sync.RWMutex lockGetRawIndexes sync.RWMutex lockGetRawIndexesWithContext sync.RWMutex + lockGetSearchRule sync.RWMutex + lockGetSearchRuleWithContext sync.RWMutex lockGetStats sync.RWMutex lockGetStatsWithContext sync.RWMutex lockGetTask sync.RWMutex + lockGetTaskDocuments sync.RWMutex + lockGetTaskDocumentsWithContext sync.RWMutex lockGetTaskWithContext sync.RWMutex lockGetTasks sync.RWMutex lockGetTasksWithContext sync.RWMutex @@ -1160,12 +1316,16 @@ type MeiliServiceManager struct { lockListChatWorkspacesWithContext sync.RWMutex lockListIndexes sync.RWMutex lockListIndexesWithContext sync.RWMutex + lockListSearchRules sync.RWMutex + lockListSearchRulesWithContext sync.RWMutex lockListWebhooks sync.RWMutex lockListWebhooksWithContext sync.RWMutex lockMultiSearch sync.RWMutex lockMultiSearchWithContext sync.RWMutex lockResetChatWorkspace sync.RWMutex lockResetChatWorkspaceWithContext sync.RWMutex + lockSearchRulesManager sync.RWMutex + lockSearchRulesReader sync.RWMutex lockServiceReader sync.RWMutex lockSwapIndexes sync.RWMutex lockSwapIndexesWithContext sync.RWMutex @@ -1177,6 +1337,8 @@ type MeiliServiceManager struct { lockUpdateKeyWithContext sync.RWMutex lockUpdateNetwork sync.RWMutex lockUpdateNetworkWithContext sync.RWMutex + lockUpdateSearchRule sync.RWMutex + lockUpdateSearchRuleWithContext sync.RWMutex lockUpdateWebhook sync.RWMutex lockUpdateWebhookWithContext sync.RWMutex lockVersion sync.RWMutex @@ -1948,6 +2110,80 @@ func (mock *MeiliServiceManager) DeleteKeyWithContextCalls() []struct { return calls } +// DeleteSearchRule calls DeleteSearchRuleFunc. +func (mock *MeiliServiceManager) DeleteSearchRule(uid string) error { + callInfo := struct { + UID string + }{ + UID: uid, + } + mock.lockDeleteSearchRule.Lock() + mock.calls.DeleteSearchRule = append(mock.calls.DeleteSearchRule, callInfo) + mock.lockDeleteSearchRule.Unlock() + if mock.DeleteSearchRuleFunc == nil { + var ( + errOut error + ) + return errOut + } + return mock.DeleteSearchRuleFunc(uid) +} + +// DeleteSearchRuleCalls gets all the calls that were made to DeleteSearchRule. +// Check the length with: +// +// len(mockedServiceManager.DeleteSearchRuleCalls()) +func (mock *MeiliServiceManager) DeleteSearchRuleCalls() []struct { + UID string +} { + var calls []struct { + UID string + } + mock.lockDeleteSearchRule.RLock() + calls = mock.calls.DeleteSearchRule + mock.lockDeleteSearchRule.RUnlock() + return calls +} + +// DeleteSearchRuleWithContext calls DeleteSearchRuleWithContextFunc. +func (mock *MeiliServiceManager) DeleteSearchRuleWithContext(ctx context.Context, uid string) error { + callInfo := struct { + Ctx context.Context + UID string + }{ + Ctx: ctx, + UID: uid, + } + mock.lockDeleteSearchRuleWithContext.Lock() + mock.calls.DeleteSearchRuleWithContext = append(mock.calls.DeleteSearchRuleWithContext, callInfo) + mock.lockDeleteSearchRuleWithContext.Unlock() + if mock.DeleteSearchRuleWithContextFunc == nil { + var ( + errOut error + ) + return errOut + } + return mock.DeleteSearchRuleWithContextFunc(ctx, uid) +} + +// DeleteSearchRuleWithContextCalls gets all the calls that were made to DeleteSearchRuleWithContext. +// Check the length with: +// +// len(mockedServiceManager.DeleteSearchRuleWithContextCalls()) +func (mock *MeiliServiceManager) DeleteSearchRuleWithContextCalls() []struct { + Ctx context.Context + UID string +} { + var calls []struct { + Ctx context.Context + UID string + } + mock.lockDeleteSearchRuleWithContext.RLock() + calls = mock.calls.DeleteSearchRuleWithContext + mock.lockDeleteSearchRuleWithContext.RUnlock() + return calls +} + // DeleteTasks calls DeleteTasksFunc. func (mock *MeiliServiceManager) DeleteTasks(param *meilisearch.DeleteTasksQuery) (*meilisearch.TaskInfo, error) { callInfo := struct { @@ -2999,10 +3235,89 @@ func (mock *MeiliServiceManager) GetRawIndexesWithContextCalls() []struct { return calls } -// GetStats calls GetStatsFunc. -func (mock *MeiliServiceManager) GetStats() (*meilisearch.Stats, error) { +// GetSearchRule calls GetSearchRuleFunc. +func (mock *MeiliServiceManager) GetSearchRule(uid string) (*meilisearch.SearchRule, error) { callInfo := struct { - }{} + UID string + }{ + UID: uid, + } + mock.lockGetSearchRule.Lock() + mock.calls.GetSearchRule = append(mock.calls.GetSearchRule, callInfo) + mock.lockGetSearchRule.Unlock() + if mock.GetSearchRuleFunc == nil { + var ( + searchRuleOut *meilisearch.SearchRule + errOut error + ) + return searchRuleOut, errOut + } + return mock.GetSearchRuleFunc(uid) +} + +// GetSearchRuleCalls gets all the calls that were made to GetSearchRule. +// Check the length with: +// +// len(mockedServiceManager.GetSearchRuleCalls()) +func (mock *MeiliServiceManager) GetSearchRuleCalls() []struct { + UID string +} { + var calls []struct { + UID string + } + mock.lockGetSearchRule.RLock() + calls = mock.calls.GetSearchRule + mock.lockGetSearchRule.RUnlock() + return calls +} + +// GetSearchRuleWithContext calls GetSearchRuleWithContextFunc. +func (mock *MeiliServiceManager) GetSearchRuleWithContext(ctx context.Context, uid string) (*meilisearch.SearchRule, error) { + callInfo := struct { + Ctx context.Context + UID string + }{ + Ctx: ctx, + UID: uid, + } + mock.lockGetSearchRuleWithContext.Lock() + mock.calls.GetSearchRuleWithContext = append(mock.calls.GetSearchRuleWithContext, callInfo) + mock.lockGetSearchRuleWithContext.Unlock() + if mock.GetSearchRuleWithContextFunc == nil { + var ( + searchRuleOut *meilisearch.SearchRule + errOut error + ) + return searchRuleOut, errOut + } + return mock.GetSearchRuleWithContextFunc(ctx, uid) +} + +// GetSearchRuleWithContextCalls gets all the calls that were made to GetSearchRuleWithContext. +// Check the length with: +// +// len(mockedServiceManager.GetSearchRuleWithContextCalls()) +func (mock *MeiliServiceManager) GetSearchRuleWithContextCalls() []struct { + Ctx context.Context + UID string +} { + var calls []struct { + Ctx context.Context + UID string + } + mock.lockGetSearchRuleWithContext.RLock() + calls = mock.calls.GetSearchRuleWithContext + mock.lockGetSearchRuleWithContext.RUnlock() + return calls +} + +// GetStats calls GetStatsFunc. +func (mock *MeiliServiceManager) GetStats(param *meilisearch.StatsParams) (*meilisearch.Stats, error) { + callInfo := struct { + Param *meilisearch.StatsParams + }{ + Param: param, + } mock.lockGetStats.Lock() mock.calls.GetStats = append(mock.calls.GetStats, callInfo) mock.lockGetStats.Unlock() @@ -3013,7 +3328,7 @@ func (mock *MeiliServiceManager) GetStats() (*meilisearch.Stats, error) { ) return statsOut, errOut } - return mock.GetStatsFunc() + return mock.GetStatsFunc(param) } // GetStatsCalls gets all the calls that were made to GetStats. @@ -3021,8 +3336,10 @@ func (mock *MeiliServiceManager) GetStats() (*meilisearch.Stats, error) { // // len(mockedServiceManager.GetStatsCalls()) func (mock *MeiliServiceManager) GetStatsCalls() []struct { + Param *meilisearch.StatsParams } { var calls []struct { + Param *meilisearch.StatsParams } mock.lockGetStats.RLock() calls = mock.calls.GetStats @@ -3031,11 +3348,13 @@ func (mock *MeiliServiceManager) GetStatsCalls() []struct { } // GetStatsWithContext calls GetStatsWithContextFunc. -func (mock *MeiliServiceManager) GetStatsWithContext(ctx context.Context) (*meilisearch.Stats, error) { +func (mock *MeiliServiceManager) GetStatsWithContext(ctx context.Context, param *meilisearch.StatsParams) (*meilisearch.Stats, error) { callInfo := struct { - Ctx context.Context + Ctx context.Context + Param *meilisearch.StatsParams }{ - Ctx: ctx, + Ctx: ctx, + Param: param, } mock.lockGetStatsWithContext.Lock() mock.calls.GetStatsWithContext = append(mock.calls.GetStatsWithContext, callInfo) @@ -3047,7 +3366,7 @@ func (mock *MeiliServiceManager) GetStatsWithContext(ctx context.Context) (*meil ) return statsOut, errOut } - return mock.GetStatsWithContextFunc(ctx) + return mock.GetStatsWithContextFunc(ctx, param) } // GetStatsWithContextCalls gets all the calls that were made to GetStatsWithContext. @@ -3055,10 +3374,12 @@ func (mock *MeiliServiceManager) GetStatsWithContext(ctx context.Context) (*meil // // len(mockedServiceManager.GetStatsWithContextCalls()) func (mock *MeiliServiceManager) GetStatsWithContextCalls() []struct { - Ctx context.Context + Ctx context.Context + Param *meilisearch.StatsParams } { var calls []struct { - Ctx context.Context + Ctx context.Context + Param *meilisearch.StatsParams } mock.lockGetStatsWithContext.RLock() calls = mock.calls.GetStatsWithContext @@ -3102,6 +3423,88 @@ func (mock *MeiliServiceManager) GetTaskCalls() []struct { return calls } +// GetTaskDocuments calls GetTaskDocumentsFunc. +func (mock *MeiliServiceManager) GetTaskDocuments(taskUID int64, dst interface{}) error { + callInfo := struct { + TaskUID int64 + Dst interface{} + }{ + TaskUID: taskUID, + Dst: dst, + } + mock.lockGetTaskDocuments.Lock() + mock.calls.GetTaskDocuments = append(mock.calls.GetTaskDocuments, callInfo) + mock.lockGetTaskDocuments.Unlock() + if mock.GetTaskDocumentsFunc == nil { + var ( + errOut error + ) + return errOut + } + return mock.GetTaskDocumentsFunc(taskUID, dst) +} + +// GetTaskDocumentsCalls gets all the calls that were made to GetTaskDocuments. +// Check the length with: +// +// len(mockedServiceManager.GetTaskDocumentsCalls()) +func (mock *MeiliServiceManager) GetTaskDocumentsCalls() []struct { + TaskUID int64 + Dst interface{} +} { + var calls []struct { + TaskUID int64 + Dst interface{} + } + mock.lockGetTaskDocuments.RLock() + calls = mock.calls.GetTaskDocuments + mock.lockGetTaskDocuments.RUnlock() + return calls +} + +// GetTaskDocumentsWithContext calls GetTaskDocumentsWithContextFunc. +func (mock *MeiliServiceManager) GetTaskDocumentsWithContext(ctx context.Context, taskUID int64, dst interface{}) error { + callInfo := struct { + Ctx context.Context + TaskUID int64 + Dst interface{} + }{ + Ctx: ctx, + TaskUID: taskUID, + Dst: dst, + } + mock.lockGetTaskDocumentsWithContext.Lock() + mock.calls.GetTaskDocumentsWithContext = append(mock.calls.GetTaskDocumentsWithContext, callInfo) + mock.lockGetTaskDocumentsWithContext.Unlock() + if mock.GetTaskDocumentsWithContextFunc == nil { + var ( + errOut error + ) + return errOut + } + return mock.GetTaskDocumentsWithContextFunc(ctx, taskUID, dst) +} + +// GetTaskDocumentsWithContextCalls gets all the calls that were made to GetTaskDocumentsWithContext. +// Check the length with: +// +// len(mockedServiceManager.GetTaskDocumentsWithContextCalls()) +func (mock *MeiliServiceManager) GetTaskDocumentsWithContextCalls() []struct { + Ctx context.Context + TaskUID int64 + Dst interface{} +} { + var calls []struct { + Ctx context.Context + TaskUID int64 + Dst interface{} + } + mock.lockGetTaskDocumentsWithContext.RLock() + calls = mock.calls.GetTaskDocumentsWithContext + mock.lockGetTaskDocumentsWithContext.RUnlock() + return calls +} + // GetTaskWithContext calls GetTaskWithContextFunc. func (mock *MeiliServiceManager) GetTaskWithContext(ctx context.Context, taskUID int64) (*meilisearch.Task, error) { callInfo := struct { @@ -3638,6 +4041,82 @@ func (mock *MeiliServiceManager) ListIndexesWithContextCalls() []struct { return calls } +// ListSearchRules calls ListSearchRulesFunc. +func (mock *MeiliServiceManager) ListSearchRules(params *meilisearch.SearchRulesParams) (*meilisearch.SearchRulesResults, error) { + callInfo := struct { + Params *meilisearch.SearchRulesParams + }{ + Params: params, + } + mock.lockListSearchRules.Lock() + mock.calls.ListSearchRules = append(mock.calls.ListSearchRules, callInfo) + mock.lockListSearchRules.Unlock() + if mock.ListSearchRulesFunc == nil { + var ( + searchRulesResultsOut *meilisearch.SearchRulesResults + errOut error + ) + return searchRulesResultsOut, errOut + } + return mock.ListSearchRulesFunc(params) +} + +// ListSearchRulesCalls gets all the calls that were made to ListSearchRules. +// Check the length with: +// +// len(mockedServiceManager.ListSearchRulesCalls()) +func (mock *MeiliServiceManager) ListSearchRulesCalls() []struct { + Params *meilisearch.SearchRulesParams +} { + var calls []struct { + Params *meilisearch.SearchRulesParams + } + mock.lockListSearchRules.RLock() + calls = mock.calls.ListSearchRules + mock.lockListSearchRules.RUnlock() + return calls +} + +// ListSearchRulesWithContext calls ListSearchRulesWithContextFunc. +func (mock *MeiliServiceManager) ListSearchRulesWithContext(ctx context.Context, params *meilisearch.SearchRulesParams) (*meilisearch.SearchRulesResults, error) { + callInfo := struct { + Ctx context.Context + Params *meilisearch.SearchRulesParams + }{ + Ctx: ctx, + Params: params, + } + mock.lockListSearchRulesWithContext.Lock() + mock.calls.ListSearchRulesWithContext = append(mock.calls.ListSearchRulesWithContext, callInfo) + mock.lockListSearchRulesWithContext.Unlock() + if mock.ListSearchRulesWithContextFunc == nil { + var ( + searchRulesResultsOut *meilisearch.SearchRulesResults + errOut error + ) + return searchRulesResultsOut, errOut + } + return mock.ListSearchRulesWithContextFunc(ctx, params) +} + +// ListSearchRulesWithContextCalls gets all the calls that were made to ListSearchRulesWithContext. +// Check the length with: +// +// len(mockedServiceManager.ListSearchRulesWithContextCalls()) +func (mock *MeiliServiceManager) ListSearchRulesWithContextCalls() []struct { + Ctx context.Context + Params *meilisearch.SearchRulesParams +} { + var calls []struct { + Ctx context.Context + Params *meilisearch.SearchRulesParams + } + mock.lockListSearchRulesWithContext.RLock() + calls = mock.calls.ListSearchRulesWithContext + mock.lockListSearchRulesWithContext.RUnlock() + return calls +} + // ListWebhooks calls ListWebhooksFunc. func (mock *MeiliServiceManager) ListWebhooks() (*meilisearch.WebhookResults, error) { callInfo := struct { @@ -3857,6 +4336,66 @@ func (mock *MeiliServiceManager) ResetChatWorkspaceWithContextCalls() []struct { return calls } +// SearchRulesManager calls SearchRulesManagerFunc. +func (mock *MeiliServiceManager) SearchRulesManager() meilisearch.SearchRulesManager { + callInfo := struct { + }{} + mock.lockSearchRulesManager.Lock() + mock.calls.SearchRulesManager = append(mock.calls.SearchRulesManager, callInfo) + mock.lockSearchRulesManager.Unlock() + if mock.SearchRulesManagerFunc == nil { + var ( + searchRulesManagerOut meilisearch.SearchRulesManager + ) + return searchRulesManagerOut + } + return mock.SearchRulesManagerFunc() +} + +// SearchRulesManagerCalls gets all the calls that were made to SearchRulesManager. +// Check the length with: +// +// len(mockedServiceManager.SearchRulesManagerCalls()) +func (mock *MeiliServiceManager) SearchRulesManagerCalls() []struct { +} { + var calls []struct { + } + mock.lockSearchRulesManager.RLock() + calls = mock.calls.SearchRulesManager + mock.lockSearchRulesManager.RUnlock() + return calls +} + +// SearchRulesReader calls SearchRulesReaderFunc. +func (mock *MeiliServiceManager) SearchRulesReader() meilisearch.SearchRulesReader { + callInfo := struct { + }{} + mock.lockSearchRulesReader.Lock() + mock.calls.SearchRulesReader = append(mock.calls.SearchRulesReader, callInfo) + mock.lockSearchRulesReader.Unlock() + if mock.SearchRulesReaderFunc == nil { + var ( + searchRulesReaderOut meilisearch.SearchRulesReader + ) + return searchRulesReaderOut + } + return mock.SearchRulesReaderFunc() +} + +// SearchRulesReaderCalls gets all the calls that were made to SearchRulesReader. +// Check the length with: +// +// len(mockedServiceManager.SearchRulesReaderCalls()) +func (mock *MeiliServiceManager) SearchRulesReaderCalls() []struct { +} { + var calls []struct { + } + mock.lockSearchRulesReader.RLock() + calls = mock.calls.SearchRulesReader + mock.lockSearchRulesReader.RUnlock() + return calls +} + // ServiceReader calls ServiceReaderFunc. func (mock *MeiliServiceManager) ServiceReader() meilisearch.ServiceReader { callInfo := struct { @@ -4267,6 +4806,90 @@ func (mock *MeiliServiceManager) UpdateNetworkWithContextCalls() []struct { return calls } +// UpdateSearchRule calls UpdateSearchRuleFunc. +func (mock *MeiliServiceManager) UpdateSearchRule(uid string, params *meilisearch.SearchRulesRequest) (*meilisearch.SearchRule, error) { + callInfo := struct { + UID string + Params *meilisearch.SearchRulesRequest + }{ + UID: uid, + Params: params, + } + mock.lockUpdateSearchRule.Lock() + mock.calls.UpdateSearchRule = append(mock.calls.UpdateSearchRule, callInfo) + mock.lockUpdateSearchRule.Unlock() + if mock.UpdateSearchRuleFunc == nil { + var ( + searchRuleOut *meilisearch.SearchRule + errOut error + ) + return searchRuleOut, errOut + } + return mock.UpdateSearchRuleFunc(uid, params) +} + +// UpdateSearchRuleCalls gets all the calls that were made to UpdateSearchRule. +// Check the length with: +// +// len(mockedServiceManager.UpdateSearchRuleCalls()) +func (mock *MeiliServiceManager) UpdateSearchRuleCalls() []struct { + UID string + Params *meilisearch.SearchRulesRequest +} { + var calls []struct { + UID string + Params *meilisearch.SearchRulesRequest + } + mock.lockUpdateSearchRule.RLock() + calls = mock.calls.UpdateSearchRule + mock.lockUpdateSearchRule.RUnlock() + return calls +} + +// UpdateSearchRuleWithContext calls UpdateSearchRuleWithContextFunc. +func (mock *MeiliServiceManager) UpdateSearchRuleWithContext(ctx context.Context, uid string, params *meilisearch.SearchRulesRequest) (*meilisearch.SearchRule, error) { + callInfo := struct { + Ctx context.Context + UID string + Params *meilisearch.SearchRulesRequest + }{ + Ctx: ctx, + UID: uid, + Params: params, + } + mock.lockUpdateSearchRuleWithContext.Lock() + mock.calls.UpdateSearchRuleWithContext = append(mock.calls.UpdateSearchRuleWithContext, callInfo) + mock.lockUpdateSearchRuleWithContext.Unlock() + if mock.UpdateSearchRuleWithContextFunc == nil { + var ( + searchRuleOut *meilisearch.SearchRule + errOut error + ) + return searchRuleOut, errOut + } + return mock.UpdateSearchRuleWithContextFunc(ctx, uid, params) +} + +// UpdateSearchRuleWithContextCalls gets all the calls that were made to UpdateSearchRuleWithContext. +// Check the length with: +// +// len(mockedServiceManager.UpdateSearchRuleWithContextCalls()) +func (mock *MeiliServiceManager) UpdateSearchRuleWithContextCalls() []struct { + Ctx context.Context + UID string + Params *meilisearch.SearchRulesRequest +} { + var calls []struct { + Ctx context.Context + UID string + Params *meilisearch.SearchRulesRequest + } + mock.lockUpdateSearchRuleWithContext.RLock() + calls = mock.calls.UpdateSearchRuleWithContext + mock.lockUpdateSearchRuleWithContext.RUnlock() + return calls +} + // UpdateWebhook calls UpdateWebhookFunc. func (mock *MeiliServiceManager) UpdateWebhook(uuid string, params *meilisearch.UpdateWebhookRequest) (*meilisearch.Webhook, error) { callInfo := struct { diff --git a/server/core/internal/apps/github/content.go b/server/core/internal/apps/github/content.go index 9bf7a92e..a2610357 100644 --- a/server/core/internal/apps/github/content.go +++ b/server/core/internal/apps/github/content.go @@ -5,7 +5,7 @@ import ( "slices" "strings" - gogithub "github.com/google/go-github/v72/github" + gogithub "github.com/google/go-github/v88/github" ) // TreeItemType represents the type of a tree item, either a file or a folder. diff --git a/server/core/internal/apps/github/content_test.go b/server/core/internal/apps/github/content_test.go index 4d5c498d..b9ebaef9 100644 --- a/server/core/internal/apps/github/content_test.go +++ b/server/core/internal/apps/github/content_test.go @@ -3,7 +3,7 @@ package github import ( "testing" - gogithub "github.com/google/go-github/v72/github" + gogithub "github.com/google/go-github/v88/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/server/core/internal/apps/github/manager.go b/server/core/internal/apps/github/manager.go index e4d49b0f..bc5a9b0c 100644 --- a/server/core/internal/apps/github/manager.go +++ b/server/core/internal/apps/github/manager.go @@ -8,7 +8,7 @@ import ( "time" "github.com/bradleyfalzon/ghinstallation/v2" - gogithub "github.com/google/go-github/v72/github" + gogithub "github.com/google/go-github/v88/github" "github.com/oxynote/oxynote/server/core/pkg/cryptoutil" "github.com/oxynote/oxynote/server/core/pkg/errutil" ) @@ -193,11 +193,15 @@ func (m *Manager) createInstallationClient(installationID int64) (*gogithub.Clie return nil, fmt.Errorf("failed to create installation transport: %w", err) } - return gogithub.NewClient( - &http.Client{ - Transport: rt, - }, - ), nil + client, err := gogithub.NewClient(gogithub.WithHTTPClient(&http.Client{ + Transport: rt, + })) + if err != nil { + // NOCOV: WithHTTPClient only rejects a nil client. + return nil, fmt.Errorf("failed to create github client: %w", err) + } + + return client, nil } // InstallationClient represents a GitHub App installation client. @@ -476,11 +480,15 @@ func createAppClient(appID int64, privateKeyPath string) (*gogithub.Client, erro return nil, fmt.Errorf("failed to create installation transport: %w", err) } - return gogithub.NewClient( - &http.Client{ - Transport: rt, - }, - ), nil + client, err := gogithub.NewClient(gogithub.WithHTTPClient(&http.Client{ + Transport: rt, + })) + if err != nil { + // NOCOV: WithHTTPClient only rejects a nil client. + return nil, fmt.Errorf("failed to create github client: %w", err) + } + + return client, nil } // parseGithubError parses errors returned by the Github API and maps them to appropriate HTTP status codes. diff --git a/server/core/internal/apps/github/manager_test.go b/server/core/internal/apps/github/manager_test.go index 9ad60465..963bc3ad 100644 --- a/server/core/internal/apps/github/manager_test.go +++ b/server/core/internal/apps/github/manager_test.go @@ -7,11 +7,10 @@ import ( "fmt" "net/http" "net/http/httptest" - "net/url" "strconv" "testing" - gogithub "github.com/google/go-github/v72/github" + gogithub "github.com/google/go-github/v88/github" "github.com/oxynote/oxynote/server/core/pkg/cryptoutil" "github.com/oxynote/oxynote/server/core/pkg/errutil" "github.com/oxynote/oxynote/server/core/pkg/testutil" @@ -37,10 +36,17 @@ func pointManagerAt(t *testing.T, man *Manager, handler http.Handler) { srv := httptest.NewServer(handler) t.Cleanup(srv.Close) - base, err := url.Parse(srv.URL + "/") + base := srv.URL + "/" + + // the base URL is fixed at construction, so the client is rebuilt + // around the original one's transport instead of being re-pointed. + client, err := gogithub.NewClient( + gogithub.WithHTTPClient(man.appClient.Client()), + gogithub.WithURLs(&base, &base), + ) require.NoError(t, err) - man.appClient.BaseURL = base + man.appClient = client } // newTestInstallationClient creates an InstallationClient talking to a test @@ -51,13 +57,14 @@ func newTestInstallationClient(t *testing.T, user bool, handler http.Handler) *I srv := httptest.NewServer(handler) t.Cleanup(srv.Close) - client := gogithub.NewClient(srv.Client()) + base := srv.URL + "/" - base, err := url.Parse(srv.URL + "/") + client, err := gogithub.NewClient( + gogithub.WithHTTPClient(srv.Client()), + gogithub.WithURLs(&base, &base), + ) require.NoError(t, err) - client.BaseURL = base - return &InstallationClient{ user: user, owner: "own", diff --git a/server/core/internal/apps/slack/manager.go b/server/core/internal/apps/slack/manager.go index 001354f6..11195a5c 100644 --- a/server/core/internal/apps/slack/manager.go +++ b/server/core/internal/apps/slack/manager.go @@ -218,9 +218,6 @@ func (m *Manager) ProcessNotification(ctx context.Context, n notification.Notifi // VerifyMiddleware verifies the Slack request signature. func (m *Manager) VerifyMiddleware(r *http.Request) error { - // an empty SignatureSecret would make the verifier accept signatures - // computed over an empty key, so refuse outright rather than trusting - // the route middleware to be the only gate. if !m.Configured() { return ErrNotConfigured } diff --git a/server/core/internal/assistant/provider/gemini.go b/server/core/internal/assistant/provider/gemini.go index 9d49f66c..19f93bba 100644 --- a/server/core/internal/assistant/provider/gemini.go +++ b/server/core/internal/assistant/provider/gemini.go @@ -18,6 +18,11 @@ func newGemini(ctx context.Context, opts Options) (model.ToolCallingChatModel, e Backend: genai.BackendGeminiAPI, HTTPOptions: genai.HTTPOptions{ Timeout: new(opts.timeout()), + // the SDK does not retry at all when this is nil; the empty + // struct enables its defaults (5 attempts, exponential + // backoff on 408/429/5xx), matching the retry behavior the + // other providers' SDKs ship out of the box. + RetryOptions: &genai.HTTPRetryOptions{}, }, } @@ -36,6 +41,13 @@ func newGemini(ctx context.Context, opts Options) (model.ToolCallingChatModel, e Model: opts.Model, MaxTokens: opts.maxTokens(), Temperature: opts.Temperature, + // eino maps thought parts to ReasoningContent, which the chat + // protocol renders as the transient thinking stream; gemini-3 + // models additionally require thought parts to be restored on + // the way back to the API. + ThinkingConfig: &genai.ThinkingConfig{ + IncludeThoughts: true, + }, } cm, err := gemini.NewChatModel(ctx, cfg) diff --git a/server/core/internal/assistant/provider/gemini_test.go b/server/core/internal/assistant/provider/gemini_test.go index d4654e36..3167e0a8 100644 --- a/server/core/internal/assistant/provider/gemini_test.go +++ b/server/core/internal/assistant/provider/gemini_test.go @@ -32,10 +32,14 @@ func Test_newGemini(t *testing.T) { APIKey: "k", Backend: genai.BackendGeminiAPI, HTTPOptions: genai.HTTPOptions{ - Timeout: new(_defaultRequestTimeout), + Timeout: new(_defaultRequestTimeout), + RetryOptions: &genai.HTTPRetryOptions{}, }, }, - Config: &gemini.Config{Model: "gemini-2.5-pro"}, + Config: &gemini.Config{ + Model: "gemini-2.5-pro", + ThinkingConfig: &genai.ThinkingConfig{IncludeThoughts: true}, + }, }, "Optional tuning": { Opts: tunedOptions(ProviderGoogle, "gemini-2.5-pro"), @@ -43,14 +47,16 @@ func Test_newGemini(t *testing.T) { APIKey: "k", Backend: genai.BackendGeminiAPI, HTTPOptions: genai.HTTPOptions{ - BaseURL: "https://example.invalid/v1", - Timeout: new(time.Minute), + BaseURL: "https://example.invalid/v1", + Timeout: new(time.Minute), + RetryOptions: &genai.HTTPRetryOptions{}, }, }, Config: &gemini.Config{ - Model: "gemini-2.5-pro", - MaxTokens: new(1024), - Temperature: new(float32(0.3)), + Model: "gemini-2.5-pro", + MaxTokens: new(1024), + Temperature: new(float32(0.3)), + ThinkingConfig: &genai.ThinkingConfig{IncludeThoughts: true}, }, }, } diff --git a/server/core/internal/datasource/demo/client.go b/server/core/internal/datasource/demo/client.go new file mode 100644 index 00000000..d838b9ee --- /dev/null +++ b/server/core/internal/datasource/demo/client.go @@ -0,0 +1,261 @@ +package demo + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/oxynote/oxynote/server/core/internal/datasource/processor" + v1 "github.com/prometheus/client_golang/api/prometheus/v1" + "github.com/prometheus/common/model" + "github.com/prometheus/prometheus/model/labels" + "github.com/prometheus/prometheus/promql" + "github.com/prometheus/prometheus/promql/parser" +) + +// _queryTimeout bounds one evaluation, matching what the data source +// processors give a real server. +var _queryTimeout = 10 * time.Second + +// _maxSamples caps how many samples one evaluation may load. The demo's +// own series are far below it, so it only ever stops a query whose +// expansion — a subquery over a long window — would otherwise grow +// without bound. +const _maxSamples = 5_000_000 + +// newEngine creates the engine demo queries evaluate on. +func newEngine() *promql.Engine { + return promql.NewEngine(promql.EngineOpts{ + MaxSamples: _maxSamples, + Timeout: _queryTimeout, + EnableAtModifier: true, + EnableNegativeOffset: true, + NoStepSubqueryIntervalFn: func(int64) int64 { + return _tickMillis + }, + }) +} + +// Client answers Prometheus operations out of the demo registry. +// +// One client serves every query a process answers. The registry it holds +// caches the history its walks replay, and a data source runner is built +// per request, so a client built per request would throw that cache away +// every time. +type Client struct { + // engine evaluates the queries asked of the registry. + engine *promql.Engine + + // parser reads the selectors the label and series endpoints are + // narrowed by. + parser parser.Parser + + // registry is everything this client publishes. + registry registry +} + +// NewClient creates a fresh instance of Client. +func NewClient() *Client { + return &Client{ + engine: newEngine(), + parser: parser.NewParser(parser.Options{}), + registry: newRegistry(), + } +} + +// TestConnection tests the connection to the demo data source, which is +// this process and so is always reachable. +func (c *Client) TestConnection(context.Context) (processor.ConnectionStatus, error) { + return processor.ConnectionStatusSuccess, nil +} + +// Metadata retrieves metadata about the demo data source. +func (c *Client) Metadata(context.Context) (*processor.PrometheusMetadataResult, error) { + md := make(map[string][]v1.Metadata, len(c.registry)) + + for _, f := range c.registry { + md[f.name] = []v1.Metadata{ + { + Type: v1.MetricTypeGauge, + Help: f.help, + }, + } + } + + return &processor.PrometheusMetadataResult{Result: md}, nil +} + +// QueryRange performs a query against the demo data source over the +// specified time range. +func (c *Client) QueryRange( + ctx context.Context, + q string, + tr processor.TimeRange, +) (*processor.PrometheusQueryResult, error) { + ctx, cancel := context.WithTimeout(ctx, _queryTimeout) + defer cancel() + + ntr := tr.Normalize() + step := ntr.QueryStep() + + // the samples are spaced a step apart, so the lookback window has to + // span one to reach the sample behind every point of the grid. + query, err := c.engine.NewRangeQuery( + ctx, + queryable{registry: c.registry}, + promql.NewPrometheusQueryOpts(false, step+_tick), + ntr.ProcessPrometheusQuery(q), + ntr.From, + ntr.To, + step, + ) + if err != nil { + return nil, processor.NewInvalidQueryError(err.Error()) + } + + defer query.Close() + + res := query.Exec(ctx) + if res.Err != nil { + // an evaluation that ran out of time or was abandoned is not a + // query the reader can fix, so it is not reported as one. What + // the engine rejects on its own terms — a query too expensive to + // run, a type it cannot evaluate — is. + if ctx.Err() != nil { + return nil, fmt.Errorf("evaluating demo query: %w", res.Err) + } + + return nil, processor.NewInvalidQueryError(res.Err.Error()) + } + + matrix, ok := res.Value.(promql.Matrix) + if !ok { + // NOCOV: a range query only ever evaluates to a matrix. + return nil, errors.New("unexpected demo query result type") + } + + warns, _ := res.Warnings.AsStrings(q, 0, 0) + + return &processor.PrometheusQueryResult{ + Type: model.ValMatrix, + Result: toModelMatrix(matrix), + Warnings: warns, + }, nil +} + +// LabelNames retrieves the label names the demo's series carry, narrowed +// to the ones the matchers select. +func (c *Client) LabelNames( + _ context.Context, + matchers []string, + _ processor.TimeRange, +) (*processor.PrometheusLabelNamesResult, error) { + sets, err := c.parseMatchers(matchers) + if err != nil { + return nil, err + } + + return &processor.PrometheusLabelNamesResult{Result: c.registry.labelNames(sets)}, nil +} + +// LabelValues retrieves the values the demo's series carry for the named +// label, narrowed to the ones the matchers select. +func (c *Client) LabelValues( + _ context.Context, + label string, + matchers []string, + _ processor.TimeRange, +) (*processor.PrometheusLabelValuesResult, error) { + sets, err := c.parseMatchers(matchers) + if err != nil { + return nil, err + } + + return &processor.PrometheusLabelValuesResult{Result: c.registry.labelValues(label, sets)}, nil +} + +// Series retrieves the demo's series matching the given selectors. +func (c *Client) Series( + _ context.Context, + matchers []string, + _ processor.TimeRange, +) (*processor.PrometheusSeriesResult, error) { + sets, err := c.parseMatchers(matchers) + if err != nil { + return nil, err + } + + var ( + res []model.LabelSet + seen = map[string]struct{}{} + ) + + for _, set := range sets { + c.registry.forEach(set, func(lbls labels.Labels, _ func(tick int64) float64) { + key := lbls.String() + + if _, ok := seen[key]; ok { + return + } + + seen[key] = struct{}{} + + res = append(res, toModelLabelSet(lbls)) + }) + } + + return &processor.PrometheusSeriesResult{Result: res}, nil +} + +// parseMatchers turns the API's series selectors into matcher sets. No +// selector at all selects everything, which is what the endpoints answer +// when the caller narrows nothing. +func (c *Client) parseMatchers(mm []string) ([][]*labels.Matcher, error) { + if len(mm) == 0 { + return [][]*labels.Matcher{nil}, nil + } + + sets, err := c.parser.ParseMetricSelectors(mm) + if err != nil { + return nil, processor.NewInvalidQueryError(err.Error()) + } + + return sets, nil +} + +// toModelMatrix converts an evaluated matrix into the shape the real +// Prometheus client returns, so a chart cannot tell the two apart. +func toModelMatrix(m promql.Matrix) model.Matrix { + res := make(model.Matrix, 0, len(m)) + + for _, s := range m { + values := make([]model.SamplePair, 0, len(s.Floats)) + + for _, p := range s.Floats { + values = append(values, model.SamplePair{ + Timestamp: model.Time(p.T), + Value: model.SampleValue(p.F), + }) + } + + res = append(res, &model.SampleStream{ + Metric: model.Metric(toModelLabelSet(s.Metric)), + Values: values, + }) + } + + return res +} + +// toModelLabelSet converts an evaluated label set into the shape the real +// Prometheus client returns. +func toModelLabelSet(lbls labels.Labels) model.LabelSet { + ls := make(model.LabelSet, lbls.Len()) + + lbls.Range(func(l labels.Label) { + ls[model.LabelName(l.Name)] = model.LabelValue(l.Value) + }) + + return ls +} diff --git a/server/core/internal/datasource/demo/client_test.go b/server/core/internal/datasource/demo/client_test.go new file mode 100644 index 00000000..41422ac0 --- /dev/null +++ b/server/core/internal/datasource/demo/client_test.go @@ -0,0 +1,465 @@ +package demo + +import ( + "context" + "encoding/json" + "math" + "os" + "testing" + "time" + + "github.com/oxynote/oxynote/server/core/internal/datasource/processor" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/oxynote/oxynote/server/core/pkg/timeutil" + v1 "github.com/prometheus/client_golang/api/prometheus/v1" + "github.com/prometheus/common/model" + "github.com/prometheus/prometheus/model/labels" + "github.com/prometheus/prometheus/promql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// _availableMetricsPath is the welcome document's pool of metric blocks, +// whose queries this package has to be able to answer. +const _availableMetricsPath = "../../document/files/available_metrics.json" + +// lastThreeHours returns the range a chart asks for by default. +func lastThreeHours() processor.TimeRange { + now := timeutil.Now() + + return processor.TimeRange{ + From: now.Add(-3 * time.Hour), + To: now, + } +} + +// resultMatrix returns the matrix a query result carries, failing the +// test if it carries anything else. +func resultMatrix(t *testing.T, res *processor.PrometheusQueryResult) model.Matrix { + t.Helper() + + require.NotNil(t, res) + require.Equal(t, model.ValMatrix, res.Type) + + m, ok := res.Result.(model.Matrix) + require.True(t, ok, "result is %T, not a matrix", res.Result) + + return m +} + +func Test_newEngine(t *testing.T) { + t.Parallel() + + assert.NotNil(t, newEngine()) +} + +func Test_NewClient(t *testing.T) { + t.Parallel() + + c := NewClient() + require.NotNil(t, c) + + // the client owns what a query needs, so one client answers every + // query a process makes and the registry's replay cache survives + // between them. + assert.NotNil(t, c.engine) + assert.NotNil(t, c.parser) + assert.Len(t, c.registry, 15) +} + +func Test_Client_TestConnection(t *testing.T) { + t.Parallel() + + // the demo is this process, so the connection cannot be down. + cs, err := NewClient().TestConnection(context.Background()) + + require.NoError(t, err) + assert.Equal(t, processor.ConnectionStatusSuccess, cs) +} + +func Test_Client_Metadata(t *testing.T) { + t.Parallel() + + c := NewClient() + + res, err := c.Metadata(context.Background()) + require.NoError(t, err) + require.NotNil(t, res) + + md, ok := res.Result.(map[string][]v1.Metadata) + require.True(t, ok) + + // every family the registry declares is described, as a gauge, with + // the help text the editor shows. + assert.Len(t, md, 15) + + for _, f := range c.registry { + entry, ok := md[f.name] + require.True(t, ok, "%s is missing from the metadata", f.name) + require.Len(t, entry, 1) + + assert.Equal(t, v1.MetricTypeGauge, entry[0].Type) + assert.Equal(t, f.help, entry[0].Help) + } +} + +func Test_Client_QueryRange(t *testing.T) { + t.Parallel() + + c := NewClient() + name := _namespace + "deploy_confidence_index" + + cc := map[string]struct { + Query string + Range processor.TimeRange + Context context.Context //nolint:containedctx // the case states which context the query runs under + Series int + Err error + }{ + "An abandoned evaluation is not blamed on the query": { + Query: name, + Range: lastThreeHours(), + Context: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx + }(), + Err: assert.AnError, + }, + "A plain selector returns every series of the family": { + Query: name, + Range: lastThreeHours(), + Series: 3, + }, + "A selector narrowed by a label returns the one series": { + Query: name + `{vibe="nervous"}`, + Range: lastThreeHours(), + Series: 1, + }, + "An aggregation collapses the family": { + Query: "sum(" + name + ")", + Range: lastThreeHours(), + Series: 1, + }, + "A function over a window reads the history behind each point": { + Query: "avg_over_time(" + name + "[30m])", + Range: lastThreeHours(), + Series: 3, + }, + "A macro is expanded before the query runs": { + Query: "max_over_time(" + name + "[$__interval])", + Range: lastThreeHours(), + Series: 3, + }, + "A year of history backfills for free": { + Query: name, + Range: processor.TimeRange{From: timeutil.Now().AddDate(-1, 0, 0), To: timeutil.Now()}, + Series: 3, + }, + "A selector naming nothing returns no series": { + Query: "not_a_metric", + Range: lastThreeHours(), + }, + "A query that does not parse is reported as the user's": { + Query: "sum(", + Range: lastThreeHours(), + Err: assert.AnError, + }, + "A query of the wrong type is reported too": { + Query: `"a string"`, + Range: lastThreeHours(), + Err: assert.AnError, + }, + } + + for cn, c2 := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + ctx := c2.Context + if ctx == nil { + ctx = context.Background() + } + + res, err := c.QueryRange(ctx, c2.Query, c2.Range) + testutil.AssertEqualError(t, c2.Err, err) + + if c2.Err != nil { + assert.Nil(t, res) + return + } + + m := resultMatrix(t, res) + require.Len(t, m, c2.Series) + + ntr := c2.Range.Normalize() + + for _, s := range m { + require.NotEmpty(t, s.Values) + + // the line spans the window asked for, with no gap at + // either edge, and every point carries a real number. + first := s.Values[0].Timestamp.Time() + last := s.Values[len(s.Values)-1].Timestamp.Time() + + assert.WithinDuration(t, ntr.From, first, 2*ntr.QueryStep()) + assert.WithinDuration(t, ntr.To, last, 2*ntr.QueryStep()) + + for _, v := range s.Values { + assert.False(t, math.IsNaN(float64(v.Value))) + assert.False(t, math.IsInf(float64(v.Value), 0)) + } + + // the point count follows the step the range asks for, + // not the range's width in ticks: a year spans 525,600 + // of them and still draws a few hundred points. + assert.Less(t, len(s.Values), 1_000) + } + }) + } +} + +func Test_Client_QueryRange_isFrozen(t *testing.T) { + t.Parallel() + + c := NewClient() + tr := lastThreeHours() + q := _namespace + "stale_branches_total" + + first, err := c.QueryRange(context.Background(), q, tr) + require.NoError(t, err) + + second, err := c.QueryRange(context.Background(), q, tr) + require.NoError(t, err) + + // the same window answers bit-identically, which is what keeps a + // drawn line from rewriting itself under the reader. + firstJSON, err := json.Marshal(first.Result) + require.NoError(t, err) + + secondJSON, err := json.Marshal(second.Result) + require.NoError(t, err) + + assert.JSONEq(t, string(firstJSON), string(secondJSON)) + + // and a window inside it answers the same values for the timestamps + // the two share: history does not depend on how it was asked for. + inner, err := c.QueryRange(context.Background(), q, processor.TimeRange{ + From: tr.From.Add(time.Hour), + To: tr.To, + }) + require.NoError(t, err) + + outer := resultMatrix(t, first) + require.Len(t, resultMatrix(t, inner), len(outer)) +} + +func Test_Client_LabelNames(t *testing.T) { + t.Parallel() + + c := NewClient() + + res, err := c.LabelNames(context.Background(), nil, lastThreeHours()) + require.NoError(t, err) + require.NotNil(t, res) + + assert.Contains(t, res.Result, model.MetricNameLabel) + assert.Contains(t, res.Result, "vibe") + + // a selector narrows the answer. + res, err = c.LabelNames( + context.Background(), + []string{_namespace + "deploy_confidence_index"}, + lastThreeHours(), + ) + require.NoError(t, err) + assert.Equal(t, []string{model.MetricNameLabel, "vibe"}, res.Result) + + // a selector that does not parse is the caller's mistake. + res, err = c.LabelNames(context.Background(), []string{"{{"}, lastThreeHours()) + require.Error(t, err) + assert.Nil(t, res) +} + +func Test_Client_LabelValues(t *testing.T) { + t.Parallel() + + c := NewClient() + + // the metric names are what the editor completes on. + res, err := c.LabelValues(context.Background(), model.MetricNameLabel, nil, lastThreeHours()) + require.NoError(t, err) + assert.Len(t, res.Result, 15) + + res, err = c.LabelValues(context.Background(), "vibe", nil, lastThreeHours()) + require.NoError(t, err) + assert.Equal(t, []string{"feeling_lucky", "nervous", "oncall_already_paged"}, res.Result) + + res, err = c.LabelValues( + context.Background(), + "vibe", + []string{_namespace + "secrets_detected_commits_count"}, + lastThreeHours(), + ) + require.NoError(t, err) + assert.Empty(t, res.Result) + + res, err = c.LabelValues(context.Background(), "vibe", []string{"{{"}, lastThreeHours()) + require.Error(t, err) + assert.Nil(t, res) +} + +func Test_Client_Series(t *testing.T) { + t.Parallel() + + c := NewClient() + + res, err := c.Series( + context.Background(), + []string{_namespace + "deploy_confidence_index"}, + lastThreeHours(), + ) + require.NoError(t, err) + require.Len(t, res.Result, 3) + + for _, ls := range res.Result { + assert.Equal(t, model.LabelValue(_namespace+"deploy_confidence_index"), ls[model.MetricNameLabel]) + assert.NotEmpty(t, ls["vibe"]) + } + + // selectors that overlap answer each series once. + res, err = c.Series( + context.Background(), + []string{ + _namespace + "deploy_confidence_index", + _namespace + `deploy_confidence_index{vibe="nervous"}`, + }, + lastThreeHours(), + ) + require.NoError(t, err) + assert.Len(t, res.Result, 3) + + // no selector at all names every series there is. + res, err = c.Series(context.Background(), nil, lastThreeHours()) + require.NoError(t, err) + + var buckets int + + for _, f := range c.registry { + buckets += len(f.buckets) + } + + assert.Len(t, res.Result, buckets) + + res, err = c.Series(context.Background(), []string{"{{"}, lastThreeHours()) + require.Error(t, err) + assert.Nil(t, res) +} + +func Test_Client_parseMatchers(t *testing.T) { + t.Parallel() + + c := NewClient() + + // no selector means one empty set, which selects everything. + sets, err := c.parseMatchers(nil) + require.NoError(t, err) + assert.Equal(t, [][]*labels.Matcher{nil}, sets) + + sets, err = c.parseMatchers([]string{`metric{a="b"}`, "other"}) + require.NoError(t, err) + require.Len(t, sets, 2) + assert.Len(t, sets[0], 2) + assert.Len(t, sets[1], 1) + + sets, err = c.parseMatchers([]string{"{{"}) + require.Error(t, err) + assert.Nil(t, sets) +} + +func Test_toModelMatrix(t *testing.T) { + t.Parallel() + + m := toModelMatrix(promql.Matrix{ + { + Metric: labels.FromStrings(model.MetricNameLabel, "metric", "colour", "red"), + Floats: []promql.FPoint{{T: 1_700_000_000_000, F: 1.5}}, + }, + }) + + require.Len(t, m, 1) + assert.Equal(t, model.LabelValue("metric"), m[0].Metric[model.MetricNameLabel]) + assert.Equal(t, model.LabelValue("red"), m[0].Metric["colour"]) + + require.Len(t, m[0].Values, 1) + assert.Equal(t, model.SampleValue(1.5), m[0].Values[0].Value) + assert.Equal(t, int64(1_700_000_000), m[0].Values[0].Timestamp.Unix()) + + assert.Empty(t, toModelMatrix(nil)) +} + +func Test_toModelLabelSet(t *testing.T) { + t.Parallel() + + ls := toModelLabelSet(labels.FromStrings(model.MetricNameLabel, "metric", "colour", "red")) + + assert.Equal(t, model.LabelSet{ + model.MetricNameLabel: "metric", + "colour": "red", + }, ls) + + assert.Empty(t, toModelLabelSet(labels.EmptyLabels())) +} + +// Test_welcomeDocumentQueries guards the pair that has no compiler +// between them: the welcome document's metric blocks name metrics by +// string, and this package is what has to answer them. A family renamed +// on one side and not the other empties the document's charts, which +// nothing else here would notice. +func Test_welcomeDocumentQueries(t *testing.T) { + t.Parallel() + + raw, err := os.ReadFile(_availableMetricsPath) + require.NoError(t, err) + + var blocks []struct { + Attrs struct { + Queries []struct { + Query string `json:"query"` + } `json:"queries"` + } `json:"attrs"` + } + + require.NoError(t, json.Unmarshal(raw, &blocks)) + require.NotEmpty(t, blocks) + + c := NewClient() + tr := lastThreeHours() + + var queries int + + for _, b := range blocks { + for _, q := range b.Attrs.Queries { + queries++ + + t.Run(q.Query, func(t *testing.T) { + t.Parallel() + + res, err := c.QueryRange(context.Background(), q.Query, tr) + require.NoError(t, err) + + m := resultMatrix(t, res) + assert.NotEmpty(t, m, "%s returned no series", q.Query) + + for _, s := range m { + assert.NotEmpty(t, s.Values, "%s returned an empty series", q.Query) + } + }) + } + } + + // every family the registry declares earns its place in the pool the + // document draws from, and vice versa. + assert.Len(t, c.registry, queries) +} diff --git a/server/core/internal/datasource/demo/demo.go b/server/core/internal/datasource/demo/demo.go new file mode 100644 index 00000000..fd535bc2 --- /dev/null +++ b/server/core/internal/datasource/demo/demo.go @@ -0,0 +1,28 @@ +// Package demo serves a self-contained Prometheus data source: its metrics +// are synthesized on demand from a fixed seed instead of being scraped from +// a running server, so a fresh install shows populated charts with nothing +// else deployed. +package demo + +import ( + "net/http" + + "github.com/oxynote/oxynote/server/core/pkg/errutil" +) + +const ( + // Scheme prefixes every URL this package answers for. + Scheme = "demo://" + + // URL is the only demo data source that exists. + URL = Scheme + "engineering" +) + +// ErrUnknownSource is returned when a demo URL names a source that does +// not exist. +var ErrUnknownSource = errutil.New( + http.StatusBadRequest, + "data_source.unknown_demo_source", + "Unknown demo data source. The only demo data source is %q.", + URL, +) diff --git a/server/core/internal/datasource/demo/demo_test.go b/server/core/internal/datasource/demo/demo_test.go new file mode 100644 index 00000000..e0f3bb48 --- /dev/null +++ b/server/core/internal/datasource/demo/demo_test.go @@ -0,0 +1,31 @@ +package demo + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func Test_URL(t *testing.T) { + t.Parallel() + + // the runner routes on the scheme before it routes on the whole URL, + // so the one demo source has to carry it. + assert.Equal(t, "demo://", Scheme) + assert.Equal(t, "demo://engineering", URL) +} + +func Test_ErrUnknownSource(t *testing.T) { + t.Parallel() + + assert.Error(t, ErrUnknownSource) + + // the message names the source that does exist: the reader mistyped + // something, and the only useful answer is what they meant. + assert.Contains(t, ErrUnknownSource.Error(), URL) +} diff --git a/server/core/internal/datasource/demo/queryable.go b/server/core/internal/datasource/demo/queryable.go new file mode 100644 index 00000000..026c6b63 --- /dev/null +++ b/server/core/internal/datasource/demo/queryable.go @@ -0,0 +1,245 @@ +package demo + +import ( + "context" + "slices" + + "github.com/prometheus/prometheus/model/histogram" + "github.com/prometheus/prometheus/model/labels" + "github.com/prometheus/prometheus/storage" + "github.com/prometheus/prometheus/tsdb/chunkenc" + "github.com/prometheus/prometheus/tsdb/chunks" + "github.com/prometheus/prometheus/util/annotations" +) + +// _samplesPerRange is the least number of samples a range selector's +// window holds, so a function reading a window has something to read. +const _samplesPerRange = 4 + +// _maxSamplesPerSeries caps what one series contributes to a single +// evaluation. Reaching it widens the spacing rather than cutting the +// series short: a coarser line still spans the window that was asked +// for, while a truncated one would simply stop mid-chart. +// +// It has to stay well above the hundred-odd points a time range resolves +// its step to. Widening spaces the samples span/_maxSamplesPerSeries +// apart, and the lookback window a query opens is its own step wide — so +// a cap below that ratio would space samples further apart than the +// window that has to find them, and points of the grid would read empty. +const _maxSamplesPerSeries = 5_000 + +// selectStep reports how far apart the samples of one Select sit. The +// engine reads on a fixed grid and every point of it has to find a sample +// behind it, so the step the query evaluates at is also the coarsest the +// samples may be spaced; a range selector needs several samples inside +// every window on top of that. +func selectStep(hints *storage.SelectHints, span int64) int64 { + step := _tickMillis + + if hints != nil { + if hints.Step > step { + step = hints.Step + } + + if hints.Range > 0 { + if within := hints.Range / _samplesPerRange; within < step { + step = within + } + } + } + + if step < _tickMillis { + step = _tickMillis + } + + if widest := span / _maxSamplesPerSeries; widest > step { + step = widest + } + + return step +} + +// samplesIn returns one series' readings across the given millisecond +// window, spaced a step apart and aligned to absolute multiples of it so +// the same window always yields the same timestamps. The first sample +// sits at or before the window's start, which is what gives the first +// point of the evaluation grid something to look back at. +func samplesIn(value func(tick int64) float64, start, end, step int64) []chunks.Sample { + if last := timeAt(latestTick()).UnixMilli(); end > last { + end = last + } + + var ss []chunks.Sample + + for ts := start / step * step; ts <= end; ts += step { + tick := tickAtMillis(ts) + if tick < 0 { + continue + } + + ss = append(ss, floatSample{t: ts, f: value(tick)}) + } + + return ss +} + +// queryable serves one client's registry to the promql engine. +type queryable struct { + // registry is what the evaluation reads out of. + registry registry +} + +// Querier returns a querier scoped to the given millisecond bounds. +func (q queryable) Querier(mint, maxt int64) (storage.Querier, error) { + return &querier{registry: q.registry, mint: mint, maxt: maxt}, nil +} + +// querier answers one promql evaluation out of the registry. +type querier struct { + // registry is what the evaluation reads out of. + registry registry + + // mint and maxt are the inclusive millisecond bounds the evaluation + // reads within. + mint int64 + maxt int64 +} + +// Select returns the series the matchers name, sampled across the window +// the hints ask about. +func (q *querier) Select( + _ context.Context, + sortSeries bool, + hints *storage.SelectHints, + mm ...*labels.Matcher, +) storage.SeriesSet { + start, end := q.mint, q.maxt + + if hints != nil { + start, end = hints.Start, hints.End + } + + step := selectStep(hints, end-start) + + var ss []storage.Series + + q.registry.forEach(mm, func(lbls labels.Labels, value func(tick int64) float64) { + ss = append(ss, storage.NewListSeries(lbls, samplesIn(value, start, end, step))) + }) + + if sortSeries { + slices.SortFunc(ss, func(a, b storage.Series) int { + return labels.Compare(a.Labels(), b.Labels()) + }) + } + + return &seriesSet{series: ss, index: -1} +} + +// LabelNames returns every label name the matching series carry, sorted. +func (q *querier) LabelNames( + _ context.Context, + _ *storage.LabelHints, + mm ...*labels.Matcher, +) ([]string, annotations.Annotations, error) { + return q.registry.labelNames([][]*labels.Matcher{mm}), nil, nil +} + +// LabelValues returns every value the matching series carry for the named +// label, sorted. +func (q *querier) LabelValues( + _ context.Context, + name string, + _ *storage.LabelHints, + mm ...*labels.Matcher, +) ([]string, annotations.Annotations, error) { + return q.registry.labelValues(name, [][]*labels.Matcher{mm}), nil, nil +} + +// Close releases the querier's resources, of which it holds none. +func (q *querier) Close() error { + return nil +} + +// seriesSet iterates the series one Select produced. +type seriesSet struct { + // series are the selected series, already sorted when the caller + // asked for that. + series []storage.Series + + // index is the position At reports, before Next has run for the + // first time -1. + index int +} + +// Next advances to the next series and reports whether one is there. +func (ss *seriesSet) Next() bool { + ss.index++ + + return ss.index < len(ss.series) +} + +// At returns the series the iteration currently sits on. +func (ss *seriesSet) At() storage.Series { + return ss.series[ss.index] +} + +// Err returns the error iteration failed with, of which synthesized data +// has none. +func (ss *seriesSet) Err() error { + return nil +} + +// Warnings returns the warnings gathered over the set, of which +// synthesized data has none. +func (ss *seriesSet) Warnings() annotations.Annotations { + return nil +} + +// floatSample is one demo reading in the shape the storage layer reads +// samples in. The demo publishes gauges only, so the histogram accessors +// report none. +type floatSample struct { + // t is the sample's millisecond timestamp. + t int64 + + // f is the value read at that instant. + f float64 +} + +// T returns the sample's timestamp in milliseconds. +func (s floatSample) T() int64 { + return s.t +} + +// ST returns the sample's start timestamp, which a synthesized reading +// does not carry. +func (s floatSample) ST() int64 { + return 0 +} + +// F returns the sample's value. +func (s floatSample) F() float64 { + return s.f +} + +// H returns the sample's histogram, of which a gauge has none. +func (s floatSample) H() *histogram.Histogram { + return nil +} + +// FH returns the sample's float histogram, of which a gauge has none. +func (s floatSample) FH() *histogram.FloatHistogram { + return nil +} + +// Type reports what kind of value the sample carries. +func (s floatSample) Type() chunkenc.ValueType { + return chunkenc.ValFloat +} + +// Copy returns a deep copy of the sample, which holds no references and +// so is itself. +func (s floatSample) Copy() chunks.Sample { + return s +} diff --git a/server/core/internal/datasource/demo/queryable_test.go b/server/core/internal/datasource/demo/queryable_test.go new file mode 100644 index 00000000..7c620327 --- /dev/null +++ b/server/core/internal/datasource/demo/queryable_test.go @@ -0,0 +1,279 @@ +package demo + +import ( + "context" + "testing" + "time" + + "github.com/prometheus/common/model" + "github.com/prometheus/prometheus/model/labels" + "github.com/prometheus/prometheus/storage" + "github.com/prometheus/prometheus/tsdb/chunkenc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// countingValue returns a value function reporting the tick it was asked +// about, so a caller can tell which tick produced a sample. +func countingValue(tick int64) float64 { + return float64(tick) +} + +func Test_selectStep(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Hints *storage.SelectHints + Span int64 + Result int64 + }{ + "No hints at all falls back to the tick": { + Result: _tickMillis, + }, + "A step finer than the tick is widened to it": { + Hints: &storage.SelectHints{Step: 15_000}, + Result: _tickMillis, + }, + "A step coarser than the tick is kept": { + Hints: &storage.SelectHints{Step: 5 * _tickMillis}, + Result: 5 * _tickMillis, + }, + "A range selector is sampled several times per window": { + Hints: &storage.SelectHints{Step: 100 * _tickMillis, Range: 20 * _tickMillis}, + Result: 5 * _tickMillis, + }, + "A range shorter than the tick still samples at the tick": { + Hints: &storage.SelectHints{Step: 100 * _tickMillis, Range: 1_000}, + Result: _tickMillis, + }, + "A range wider than the step leaves the step alone": { + Hints: &storage.SelectHints{Step: 2 * _tickMillis, Range: 1_000 * _tickMillis}, + Result: 2 * _tickMillis, + }, + "A span too wide for the cap widens the spacing": { + Hints: &storage.SelectHints{Step: _tickMillis}, + Span: _tickMillis * _maxSamplesPerSeries * 3, + Result: _tickMillis * 3, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + step := selectStep(c.Hints, c.Span) + assert.Equal(t, c.Result, step) + + // whatever the inputs, one series never outgrows the cap and + // never samples finer than the timeline itself. + assert.GreaterOrEqual(t, step, _tickMillis) + assert.LessOrEqual(t, c.Span/step, int64(_maxSamplesPerSeries)) + }) + } +} + +func Test_samplesIn(t *testing.T) { + t.Parallel() + + start := _epoch.Add(time.Hour).UnixMilli() + end := _epoch.Add(2 * time.Hour).UnixMilli() + + ss := samplesIn(countingValue, start, end, _tickMillis) + + require.NotEmpty(t, ss) + + // the window is covered end to end, one sample a step apart, and + // each carries the value of the tick it lands in. + assert.Len(t, ss, 61) + assert.Equal(t, start, ss[0].T()) + assert.Equal(t, end, ss[len(ss)-1].T()) + + for i, s := range ss { + assert.Equal(t, float64(tickAtMillis(s.T())), s.F()) + + if i > 0 { + assert.Equal(t, _tickMillis, s.T()-ss[i-1].T()) + } + } + + // the first sample sits at or before the window's start, so the + // first point of the evaluation grid has something to look back at. + ss = samplesIn(countingValue, start+30_000, end, _tickMillis) + require.NotEmpty(t, ss) + assert.LessOrEqual(t, ss[0].T(), start+30_000) + + // nothing past the present is ever sampled. + last := timeAt(latestTick()).UnixMilli() + + ss = samplesIn(countingValue, last-10*_tickMillis, last+time.Hour.Milliseconds(), _tickMillis) + require.NotEmpty(t, ss) + assert.LessOrEqual(t, ss[len(ss)-1].T(), last) + + // a window that lands entirely before the epoch produces nothing + // rather than running the timeline backwards. + assert.Empty(t, samplesIn( + countingValue, + _epoch.Add(-10*time.Hour).UnixMilli(), + _epoch.Add(-5*time.Hour).UnixMilli(), + _tickMillis, + )) +} + +func Test_queryable_Querier(t *testing.T) { + t.Parallel() + + q, err := queryable{}.Querier(10, 20) + require.NoError(t, err) + require.NotNil(t, q) + + qr, ok := q.(*querier) + require.True(t, ok) + + assert.Equal(t, int64(10), qr.mint) + assert.Equal(t, int64(20), qr.maxt) +} + +func Test_querier_Select(t *testing.T) { + t.Parallel() + + var ( + start = _epoch.Add(time.Hour).UnixMilli() + end = _epoch.Add(2 * time.Hour).UnixMilli() + name = _namespace + "deploy_confidence_index" + ) + + q := &querier{registry: newRegistry(), mint: start, maxt: end} + + // the matchers narrow the set, and every series in it carries the + // samples of the window the hints asked about. + set := q.Select( + context.Background(), + true, + &storage.SelectHints{Start: start, End: end, Step: _tickMillis}, + nameMatcher(t, name), + ) + + require.NoError(t, set.Err()) + assert.Empty(t, set.Warnings()) + + var got []string + + for set.Next() { + s := set.At() + got = append(got, s.Labels().Get("vibe")) + + assert.Equal(t, name, s.Labels().Get(model.MetricNameLabel)) + + var samples int + + it := s.Iterator(nil) + for it.Next() == chunkenc.ValFloat { + ts, _ := it.At() + + assert.GreaterOrEqual(t, ts, start) + assert.LessOrEqual(t, ts, end) + + samples++ + } + + require.NoError(t, it.Err()) + assert.Equal(t, 61, samples) + } + + // sorting was asked for, so the series arrive in label order. + assert.Equal(t, []string{"feeling_lucky", "nervous", "oncall_already_paged"}, got) + + // with no hints the querier falls back to its own bounds rather than + // answering with nothing. + set = q.Select(context.Background(), false, nil, nameMatcher(t, name)) + + require.True(t, set.Next()) + assert.NotNil(t, set.At()) + + // a matcher naming nothing yields an empty set, not an error. + set = q.Select(context.Background(), false, nil, nameMatcher(t, "nope")) + + assert.False(t, set.Next()) + require.NoError(t, set.Err()) +} + +func Test_querier_LabelNames(t *testing.T) { + t.Parallel() + + names, annos, err := (&querier{registry: newRegistry()}).LabelNames( + context.Background(), + nil, + nameMatcher(t, _namespace+"deploy_confidence_index"), + ) + + require.NoError(t, err) + assert.Empty(t, annos) + assert.Equal(t, []string{model.MetricNameLabel, "vibe"}, names) +} + +func Test_querier_LabelValues(t *testing.T) { + t.Parallel() + + values, annos, err := (&querier{registry: newRegistry()}).LabelValues( + context.Background(), + "vibe", + nil, + nameMatcher(t, _namespace+"deploy_confidence_index"), + ) + + require.NoError(t, err) + assert.Empty(t, annos) + assert.Equal(t, []string{"feeling_lucky", "nervous", "oncall_already_paged"}, values) +} + +func Test_querier_Close(t *testing.T) { + t.Parallel() + + assert.NoError(t, (&querier{}).Close()) +} + +func Test_seriesSet(t *testing.T) { + t.Parallel() + + one := storage.NewListSeries(labels.FromStrings(model.MetricNameLabel, "one"), nil) + two := storage.NewListSeries(labels.FromStrings(model.MetricNameLabel, "two"), nil) + + ss := &seriesSet{series: []storage.Series{one, two}, index: -1} + + require.True(t, ss.Next()) + assert.Same(t, one, ss.At()) + + require.True(t, ss.Next()) + assert.Same(t, two, ss.At()) + + // the set ends, and stays ended. + assert.False(t, ss.Next()) + assert.False(t, ss.Next()) + + assert.NoError(t, ss.Err()) + assert.Empty(t, ss.Warnings()) + + // an empty set reports nothing rather than panicking on the first + // read. + empty := &seriesSet{index: -1} + assert.False(t, empty.Next()) +} + +func Test_floatSample(t *testing.T) { + t.Parallel() + + s := floatSample{t: 1_700_000_000_000, f: 42.5} + + assert.Equal(t, int64(1_700_000_000_000), s.T()) + assert.Equal(t, 42.5, s.F()) + assert.Equal(t, chunkenc.ValFloat, s.Type()) + + // a synthesized gauge reading carries no start timestamp and no + // histogram of either shape. + assert.Zero(t, s.ST()) + assert.Nil(t, s.H()) + assert.Nil(t, s.FH()) + + // it holds no references, so a copy is the value itself. + assert.Equal(t, s, s.Copy()) +} diff --git a/server/core/internal/datasource/demo/registry.go b/server/core/internal/datasource/demo/registry.go new file mode 100644 index 00000000..20fada2b --- /dev/null +++ b/server/core/internal/datasource/demo/registry.go @@ -0,0 +1,556 @@ +package demo + +import ( + "math" + "slices" + + "github.com/prometheus/common/model" + "github.com/prometheus/prometheus/model/labels" +) + +// _namespace prefixes every demo metric name. +const _namespace = "engineering_" + +// _labelOutcome names the label both outcome-split families are broken +// down by. +const _labelOutcome = "outcome" + +// Sampling parameters of the friday_deploys family, whose two buckets are +// derived from one another instead of being sampled independently. +const ( + // _fridayDeployMean is the weekly number of Friday deploys attempted. + _fridayDeployMean = 30_000 + + // _fridayDeployRelStdDev spreads that count tick to tick. + _fridayDeployRelStdDev = 0.15 + + // _fridayDeploySuccessMean is the mean fraction of attempts that + // survive. + _fridayDeploySuccessMean = 0.68 + + // _fridayDeploySuccessStdDev spreads that fraction tick to tick. + _fridayDeploySuccessStdDev = 0.12 +) + +// bucket is one series of a metric family. +type bucket struct { + // label is the label value the bucket is published under. It is + // empty for a family that publishes a single unlabelled series. + label string + + // value reports the bucket's reading at the given tick. + value func(tick int64) float64 +} + +// family is one metric family of the demo timeline. +type family struct { + // name is the metric name the family is published under. + name string + + // help describes what the family measures. + help string + + // label is the label key the family's buckets are broken down by. + // It is empty for a family that publishes a single unlabelled + // series. + label string + + // buckets are the family's series, one per label value. + buckets []bucket +} + +// registry is everything the demo publishes. One is built per client and +// read by every query it answers: the walks behind it cache the history +// they replay, so a registry that is rebuilt is a cache that is thrown +// away. +type registry []family + +// forEach calls fn for every series the matchers select, in declaration +// order. +func (r registry) forEach(mm []*labels.Matcher, fn func(lbls labels.Labels, value func(tick int64) float64)) { + for _, f := range r { + for _, b := range f.buckets { + lbls := seriesLabels(f, b) + + if matches(lbls, mm) { + fn(lbls, b.value) + } + } + } +} + +// labelNames returns every label name the given matcher sets select, +// sorted. The sets are unioned, the way a series endpoint handed several +// selectors answers with everything any one of them names. +func (r registry) labelNames(sets [][]*labels.Matcher) []string { + names := map[string]struct{}{} + + for _, set := range sets { + r.forEach(set, func(lbls labels.Labels, _ func(tick int64) float64) { + lbls.Range(func(l labels.Label) { + names[l.Name] = struct{}{} + }) + }) + } + + return sortedKeys(names) +} + +// labelValues returns every value the given matcher sets carry for the +// named label, sorted and unioned across the sets. +func (r registry) labelValues(name string, sets [][]*labels.Matcher) []string { + values := map[string]struct{}{} + + for _, set := range sets { + r.forEach(set, func(lbls labels.Labels, _ func(tick int64) float64) { + if v := lbls.Get(name); v != "" { + values[v] = struct{}{} + } + }) + } + + return sortedKeys(values) +} + +// seriesLabels returns the label set the given bucket of the given family +// is published under. +func seriesLabels(f family, b bucket) labels.Labels { + if f.label == "" { + return labels.FromStrings(model.MetricNameLabel, f.name) + } + + return labels.FromStrings(model.MetricNameLabel, f.name, f.label, b.label) +} + +// matches reports whether the label set satisfies every matcher. +func matches(lbls labels.Labels, mm []*labels.Matcher) bool { + for _, m := range mm { + if !m.Matches(lbls.Get(m.Name)) { + return false + } + } + + return true +} + +// sortedKeys returns the set's members in sorted order, and an empty +// slice when it holds none. +func sortedKeys(set map[string]struct{}) []string { + kk := make([]string, 0, len(set)) + + for k := range set { + kk = append(kk, k) + } + + slices.Sort(kk) + + return kk +} + +// builder assigns every series its own random stream as the registry is +// declared, so the table below carries tuned parameters rather than +// hand-written seeds, and no two series ever draw the same numbers. +type builder struct { + // seed is the last seed handed out. + seed int64 +} + +// next hands out the next unused seed. +func (b *builder) next() int64 { + b.seed++ + + return b.seed +} + +// count declares a bucket resampled from scratch every tick. +func (b *builder) count(label string, mean, relStdDev float64) bucket { + seed := b.next() + + return bucket{ + label: label, + value: func(tick int64) float64 { + return countAt(seed, tick, mean, relStdDev) + }, + } +} + +// walk declares a bucket that drifts between ticks. +func (b *builder) walk(label string, p walkParams) bucket { + w := newWalk(b.next(), p) + + return bucket{ + label: label, + value: w.at, + } +} + +// deploys declares the friday_deploys buckets. The successful bucket is a +// fraction of the attempted one rather than an independent sample, so a +// chart of the two never shows more deploys surviving than were tried. +func (b *builder) deploys() []bucket { + attempted := b.count("attempted", _fridayDeployMean, _fridayDeployRelStdDev) + seed := b.next() + + return []bucket{ + attempted, + { + label: "successful", + value: func(tick int64) float64 { + rate := clamp( + normal(newRand(seed, tick), _fridayDeploySuccessMean, _fridayDeploySuccessStdDev), + 0, + 1, + ) + + return math.Round(attempted.value(tick) * rate) + }, + }, + } +} + +// newRegistry declares the demo's metric families. Means are aggregate +// weekly totals across the ~10k organisations the demo pretends to +// measure. +// +//nolint:funlen,maintidx // a flat table of tuned parameters, not logic +func newRegistry() registry { + b := &builder{} + + return registry{ + { + name: _namespace + "meetings_could_be_doc_count", + help: "Weekly count of meetings that could've been a document.", + label: "excuse", + buckets: []bucket{ + b.count("alignment", 42_000, 0.15), + b.count("bandwidth", 36_000, 0.15), + b.count("synergy", 30_000, 0.15), + b.count("optics", 12_000, 0.20), + }, + }, + { + name: _namespace + "circle_back_count", + help: `Weekly count of "let's circle back" mentions.`, + label: _labelOutcome, + buckets: []bucket{ + b.count("circled_back", 16_000, 0.20), + b.count("forgotten", 48_000, 0.15), + b.count("rescheduled_to_circle_back", 16_000, 0.20), + }, + }, + { + name: _namespace + "friday_deploys_count", + help: "Weekly Friday deploy counts.", + label: _labelOutcome, + buckets: b.deploys(), + }, + { + name: _namespace + "pizza_fridays_next_30_days", + help: "Pizza Fridays scheduled in the next 30 days.", + label: "pizza_count", + buckets: []bucket{ + b.count("1_to_5", 10_000, 0.15), + b.count("5_to_10", 7_000, 0.20), + b.count("10_plus", 3_000, 0.25), + }, + }, + { + name: _namespace + "ship_it_approvals_after_5pm_count", + help: `Weekly count of "ship it" PR approvals left after 5 pm.`, + label: "reviewer_state", + buckets: []bucket{ + b.count("sober", 20_000, 0.15), + b.count("one_beer_in", 14_000, 0.20), + b.count("heading_to_the_door", 6_000, 0.25), + }, + }, + { + name: _namespace + "prs_merged_friday_afternoon_count", + help: "Weekly count of PRs merged on Fridays after 3 pm.", + label: "confidence", + buckets: []bucket{ + b.count("yolo", 20_000, 0.15), + b.count("fingers_crossed", 22_500, 0.15), + b.count("vacation_starts_monday", 7_500, 0.20), + }, + }, + { + name: _namespace + "secrets_detected_commits_count", + help: "Secrets detected in commits over the past week.", + label: "severity", + buckets: []bucket{ + b.count("normal", 2_100, 0.25), + b.count("critical", 750, 0.30), + b.count("nuclear", 150, 0.35), + }, + }, + { + name: _namespace + "quick_sync_meetings_same_day_count", + help: `Weekly count of "quick sync" meetings scheduled same-day.`, + label: "duration", + buckets: []bucket{ + b.count("10m_to_30m", 33_000, 0.15), + b.count("30m_to_1h", 21_000, 0.15), + b.count("1h_plus", 6_000, 0.25), + }, + }, + { + name: _namespace + "temporary_fix_age_days", + help: `Age in days of the oldest surviving "temporary" fix.`, + label: "original_comment", + buckets: []bucket{ + b.walk("temp", walkParams{ + Min: 365, + Max: 5_000, + Start: 2_000, + Target: 2_200, + MeanReversion: 0.015, + NoiseStdDev: 80, + DriftPerStep: 0.2, + SpikeChance: 0.08, + SpikeStdDev: 200, + }), + b.walk("hack", walkParams{ + Min: 1_000, + Max: 18_250, + Start: 6_000, + Target: 7_000, + MeanReversion: 0.008, + NoiseStdDev: 150, + DriftPerStep: 0.4, + SpikeChance: 0.07, + SpikeStdDev: 400, + }), + b.walk("fixme", walkParams{ + Min: 180, + Max: 4_000, + Start: 1_500, + Target: 1_700, + MeanReversion: 0.015, + NoiseStdDev: 60, + DriftPerStep: 0.2, + SpikeChance: 0.08, + SpikeStdDev: 150, + }), + b.walk("todo", walkParams{ + Min: 500, + Max: 10_000, + Start: 3_500, + Target: 4_000, + MeanReversion: 0.008, + NoiseStdDev: 100, + DriftPerStep: 0.3, + SpikeChance: 0.08, + SpikeStdDev: 250, + }), + }, + }, + { + name: _namespace + "deploy_confidence_index", + help: "Self-reported deploy confidence index (0–100).", + label: "vibe", + buckets: []bucket{ + b.walk("feeling_lucky", walkParams{ + Min: 60, + Max: 100, + Start: 78, + Target: 80, + MeanReversion: 0.08, + NoiseStdDev: 3, + SpikeChance: 0.05, + SpikeStdDev: 8, + }), + b.walk("nervous", walkParams{ + Min: 30, + Max: 75, + Start: 52, + Target: 55, + MeanReversion: 0.08, + NoiseStdDev: 4, + SpikeChance: 0.07, + SpikeStdDev: 10, + }), + b.walk("oncall_already_paged", walkParams{ + Min: 0, + Max: 40, + Start: 18, + Target: 20, + MeanReversion: 0.1, + NoiseStdDev: 3, + SpikeChance: 0.1, + SpikeStdDev: 8, + }), + }, + }, + { + name: _namespace + "pr_time_to_first_review_minutes", + help: "Median time from PR open to first review comment (minutes).", + buckets: []bucket{ + b.walk("", walkParams{ + Min: 5, + Max: 10_080, + Start: 280, + Target: 300, + MeanReversion: 0.1, + NoiseStdDev: 25, + SpikeChance: 0.08, + SpikeStdDev: 180, + }), + }, + }, + { + name: _namespace + "hotfixes_per_release", + help: "Average number of hotfixes per release.", + label: "root_cause", + buckets: []bucket{ + b.walk("works_on_my_machine", walkParams{ + Min: 0, + Max: 12, + Start: 0.9, + Target: 1.0, + MeanReversion: 0.1, + NoiseStdDev: 0.2, + SpikeChance: 0.05, + SpikeStdDev: 1, + }), + b.walk("not_my_fault", walkParams{ + Min: 0, + Max: 10, + Start: 0.8, + Target: 0.9, + MeanReversion: 0.1, + NoiseStdDev: 0.2, + SpikeChance: 0.05, + SpikeStdDev: 0.8, + }), + b.walk("timezone_issue", walkParams{ + Min: 0, + Max: 5, + Start: 0.4, + Target: 0.45, + MeanReversion: 0.1, + NoiseStdDev: 0.1, + SpikeChance: 0.04, + SpikeStdDev: 0.5, + }), + }, + }, + { + name: _namespace + "works_on_my_machine_rate", + help: `Self-reported "works on my machine" incidence rate (0–100).`, + label: "platform", + buckets: []bucket{ + b.walk("linux", walkParams{ + Min: 10, + Max: 60, + Start: 28, + Target: 30, + MeanReversion: 0.06, + NoiseStdDev: 2, + SpikeChance: 0.05, + SpikeStdDev: 6, + }), + b.walk("mac", walkParams{ + Min: 25, + Max: 75, + Start: 48, + Target: 50, + MeanReversion: 0.06, + NoiseStdDev: 2.5, + SpikeChance: 0.06, + SpikeStdDev: 8, + }), + b.walk("my_special_setup", walkParams{ + Min: 60, + Max: 100, + Start: 82, + Target: 85, + MeanReversion: 0.05, + NoiseStdDev: 3, + SpikeChance: 0.07, + SpikeStdDev: 5, + }), + }, + }, + { + name: _namespace + "stale_branches_total", + help: "Number of branches with no activity for more than 90 days.", + label: "owner_status", + buckets: []bucket{ + b.walk("still_employed", walkParams{ + Min: 0, + Max: 800_000, + Start: 95_000, + Target: 120_000, + MeanReversion: 0.03, + NoiseStdDev: 6_000, + DriftPerStep: 200, + SpikeChance: 0.08, + SpikeStdDev: 30_000, + }), + b.walk("left_the_company", walkParams{ + Min: 0, + Max: 700_000, + Start: 85_000, + Target: 100_000, + MeanReversion: 0.015, + NoiseStdDev: 5_000, + DriftPerStep: 300, + SpikeChance: 0.07, + SpikeStdDev: 25_000, + }), + b.walk("nobody_knows", walkParams{ + Min: 0, + Max: 500_000, + Start: 50_000, + Target: 80_000, + MeanReversion: 0.008, + NoiseStdDev: 4_000, + DriftPerStep: 100, + SpikeChance: 0.07, + SpikeStdDev: 20_000, + }), + }, + }, + { + name: _namespace + "feature_flag_graveyard_size", + help: "Number of feature flags that have outlived their purpose.", + label: "removal_likelihood", + buckets: []bucket{ + b.walk("maybe_someday", walkParams{ + Min: 20_000, + Max: 2_000_000, + Start: 200_000, + Target: 240_000, + MeanReversion: 0.02, + NoiseStdDev: 15_000, + DriftPerStep: 400, + SpikeChance: 0.08, + SpikeStdDev: 60_000, + }), + b.walk("never", walkParams{ + Min: 30_000, + Max: 3_000_000, + Start: 250_000, + Target: 280_000, + MeanReversion: 0.02, + NoiseStdDev: 18_000, + DriftPerStep: 500, + SpikeChance: 0.09, + SpikeStdDev: 70_000, + }), + b.walk("what_does_this_do", walkParams{ + Min: 5_000, + Max: 1_000_000, + Start: 70_000, + Target: 80_000, + MeanReversion: 0.02, + NoiseStdDev: 8_000, + DriftPerStep: 200, + SpikeChance: 0.08, + SpikeStdDev: 30_000, + }), + }, + }, + } +} diff --git a/server/core/internal/datasource/demo/registry_test.go b/server/core/internal/datasource/demo/registry_test.go new file mode 100644 index 00000000..594e74a6 --- /dev/null +++ b/server/core/internal/datasource/demo/registry_test.go @@ -0,0 +1,330 @@ +package demo + +import ( + "strings" + "testing" + + "github.com/prometheus/common/model" + "github.com/prometheus/prometheus/model/labels" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_builder_next(t *testing.T) { + t.Parallel() + + b := &builder{} + + // every series gets a seed of its own, so no two draw the same + // numbers. + assert.Equal(t, int64(1), b.next()) + assert.Equal(t, int64(2), b.next()) + assert.Equal(t, int64(3), b.next()) +} + +func Test_builder_count(t *testing.T) { + t.Parallel() + + b := &builder{} + + one := b.count("first", 1_000, 0.1) + two := b.count("second", 1_000, 0.1) + + assert.Equal(t, "first", one.label) + assert.Equal(t, "second", two.label) + + require.NotNil(t, one.value) + + // the reading is the count the tick names, and two buckets declared + // alike still read differently because their seeds differ. + assert.Equal(t, countAt(1, 42, 1_000, 0.1), one.value(42)) + assert.NotEqual(t, one.value(42), two.value(42)) +} + +func Test_builder_walk(t *testing.T) { + t.Parallel() + + b := &builder{} + + one := b.walk("first", _testWalk) + two := b.walk("second", _testWalk) + + assert.Equal(t, "first", one.label) + require.NotNil(t, one.value) + + // the bucket reads its walk, which starts where the params say. + assert.Equal(t, _testWalk.Start, one.value(0)) + + // same params, different seed, so the two diverge once they move. + assert.NotEqual(t, one.value(100), two.value(100)) +} + +func Test_builder_deploys(t *testing.T) { + t.Parallel() + + bb := (&builder{}).deploys() + require.Len(t, bb, 2) + + assert.Equal(t, "attempted", bb[0].label) + assert.Equal(t, "successful", bb[1].label) + + // the successful bucket is a share of the attempted one, so a chart + // of both never shows more deploys surviving than were tried. + for tick := range int64(500) { + attempted := bb[0].value(tick) + successful := bb[1].value(tick) + + assert.GreaterOrEqual(t, successful, 0.0) + assert.LessOrEqual(t, successful, attempted, "tick %d", tick) + } +} + +func Test_newRegistry(t *testing.T) { + t.Parallel() + + ff := newRegistry() + require.Len(t, ff, 15) + + var ( + names = map[string]struct{}{} + seen = map[string]float64{} + buckets int + ) + + for _, f := range ff { + // every family is namespaced, described, and populated. + assert.True(t, strings.HasPrefix(f.name, _namespace), f.name) + assert.NotEmpty(t, f.help, f.name) + require.NotEmpty(t, f.buckets, f.name) + + _, dup := names[f.name] + assert.False(t, dup, "duplicate family %q", f.name) + names[f.name] = struct{}{} + + bucketLabels := map[string]struct{}{} + + for _, b := range f.buckets { + buckets++ + + require.NotNil(t, b.value, f.name) + + // a family either breaks down by a label or publishes one + // unlabelled series; a bucket never disagrees with its family. + if f.label == "" { + assert.Empty(t, b.label, f.name) + assert.Len(t, f.buckets, 1, f.name) + } else { + assert.NotEmpty(t, b.label, f.name) + } + + _, dup := bucketLabels[b.label] + assert.False(t, dup, "duplicate bucket %q in %q", b.label, f.name) + bucketLabels[b.label] = struct{}{} + + // every series reads, and no two read alike — a seed shared by + // accident would show up here as two identical lines. + v := b.value(1_000) + key := f.name + "/" + b.label + + for other, ov := range seen { + assert.NotEqual(t, ov, v, "%s and %s read alike", other, key) + } + + seen[key] = v + } + } + + assert.Len(t, seen, buckets) +} + +func Test_registry_isCachedPerClient(t *testing.T) { + t.Parallel() + + // the client holds one registry for its lifetime: the walks behind it + // cache the history they replay, and rebuilding it per query would + // walk every tick since the epoch again. + c := NewClient() + + first := c.registry + require.NotEmpty(t, first) + + assert.Equal(t, first[0].buckets[0].value(7), c.registry[0].buckets[0].value(7)) + + // a separate client is a separate cache, reading the same history. + other := NewClient() + require.Len(t, other.registry, len(first)) + + for i := range first { + assert.Equal(t, first[i].name, other.registry[i].name) + assert.Equal(t, first[i].buckets[0].value(7), other.registry[i].buckets[0].value(7)) + } +} + +// nameMatcher builds a matcher selecting one metric by name, failing +// the test if it cannot. +func nameMatcher(t *testing.T, name string) *labels.Matcher { + t.Helper() + + m, err := labels.NewMatcher(labels.MatchEqual, model.MetricNameLabel, name) + require.NoError(t, err) + + return m +} + +func Test_seriesLabels(t *testing.T) { + t.Parallel() + + // a labelled family carries its breakdown alongside the metric name. + lbls := seriesLabels( + family{name: "metric", label: "colour"}, + bucket{label: "red"}, + ) + + assert.Equal(t, "metric", lbls.Get(model.MetricNameLabel)) + assert.Equal(t, "red", lbls.Get("colour")) + assert.Equal(t, 2, lbls.Len()) + + // an unlabelled one carries nothing but the name — not an empty + // label, which would show up in autocomplete as a real dimension. + lbls = seriesLabels(family{name: "metric"}, bucket{}) + + assert.Equal(t, "metric", lbls.Get(model.MetricNameLabel)) + assert.Equal(t, 1, lbls.Len()) +} + +func Test_matches(t *testing.T) { + t.Parallel() + + lbls := labels.FromStrings(model.MetricNameLabel, "metric", "colour", "red") + + cc := map[string]struct { + Matchers []*labels.Matcher + Result bool + }{ + "No matchers at all": {Result: true}, + "Matching name": {Matchers: []*labels.Matcher{{Type: labels.MatchEqual, Name: model.MetricNameLabel, Value: "metric"}}, Result: true}, + "Other name": {Matchers: []*labels.Matcher{{Type: labels.MatchEqual, Name: model.MetricNameLabel, Value: "nope"}}}, + "Matching label": {Matchers: []*labels.Matcher{{Type: labels.MatchEqual, Name: "colour", Value: "red"}}, Result: true}, + "Other label value": {Matchers: []*labels.Matcher{{Type: labels.MatchEqual, Name: "colour", Value: "blue"}}}, + "Absent label matched empty": { + Matchers: []*labels.Matcher{{Type: labels.MatchEqual, Name: "size", Value: ""}}, + Result: true, + }, + "One of several fails": { + Matchers: []*labels.Matcher{ + {Type: labels.MatchEqual, Name: model.MetricNameLabel, Value: "metric"}, + {Type: labels.MatchEqual, Name: "colour", Value: "blue"}, + }, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, c.Result, matches(lbls, c.Matchers)) + }) + } +} + +func Test_registry_forEach(t *testing.T) { + t.Parallel() + + // no matchers reaches every series the registry declares. + var all int + + newRegistry().forEach(nil, func(labels.Labels, func(int64) float64) { all++ }) + + var buckets int + + for _, f := range newRegistry() { + buckets += len(f.buckets) + } + + assert.Equal(t, buckets, all) + + // a matcher narrows to the family it names, and every series handed + // over carries a reading. + var named int + + newRegistry().forEach( + []*labels.Matcher{nameMatcher(t, _namespace+"deploy_confidence_index")}, + func(lbls labels.Labels, value func(int64) float64) { + named++ + + assert.Equal(t, _namespace+"deploy_confidence_index", lbls.Get(model.MetricNameLabel)) + require.NotNil(t, value) + assert.NotZero(t, value(500)) + }, + ) + + assert.Equal(t, 3, named) + + // a matcher naming nothing reaches nothing. + newRegistry().forEach( + []*labels.Matcher{nameMatcher(t, "not_a_metric")}, + func(labels.Labels, func(int64) float64) { t.Fatal("matched a series that does not exist") }, + ) +} + +func Test_sortedKeys(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{"a", "b", "c"}, sortedKeys(map[string]struct{}{ + "c": {}, + "a": {}, + "b": {}, + })) + + // an empty set answers an empty slice rather than nil, so a JSON + // response carries [] instead of null. + assert.Equal(t, []string{}, sortedKeys(map[string]struct{}{})) +} + +func Test_registry_labelNames(t *testing.T) { + t.Parallel() + + names := newRegistry().labelNames([][]*labels.Matcher{nil}) + + assert.Contains(t, names, model.MetricNameLabel) + assert.Contains(t, names, "vibe") + assert.Contains(t, names, "severity") + assert.IsIncreasing(t, names) + + // narrowing to one family leaves only that family's dimension. + names = newRegistry().labelNames([][]*labels.Matcher{ + {nameMatcher(t, _namespace+"deploy_confidence_index")}, + }) + + assert.Equal(t, []string{model.MetricNameLabel, "vibe"}, names) + + // several selectors are unioned, the way the endpoint answers when + // handed more than one. + names = newRegistry().labelNames([][]*labels.Matcher{ + {nameMatcher(t, _namespace+"deploy_confidence_index")}, + {nameMatcher(t, _namespace+"secrets_detected_commits_count")}, + }) + + assert.Equal(t, []string{model.MetricNameLabel, "severity", "vibe"}, names) +} + +func Test_registry_labelValues(t *testing.T) { + t.Parallel() + + // the metric names are a label value like any other, which is what + // the editor's metric autocomplete reads. + names := newRegistry().labelValues(model.MetricNameLabel, [][]*labels.Matcher{nil}) + + assert.Len(t, names, 15) + assert.Contains(t, names, _namespace+"pizza_fridays_next_30_days") + assert.IsIncreasing(t, names) + + assert.Equal( + t, + []string{"feeling_lucky", "nervous", "oncall_already_paged"}, + newRegistry().labelValues("vibe", [][]*labels.Matcher{nil}), + ) + + // a label no series carries answers empty rather than a blank value. + assert.Empty(t, newRegistry().labelValues("nonexistent", [][]*labels.Matcher{nil})) +} diff --git a/server/core/internal/datasource/demo/series.go b/server/core/internal/datasource/demo/series.go new file mode 100644 index 00000000..36cb3549 --- /dev/null +++ b/server/core/internal/datasource/demo/series.go @@ -0,0 +1,215 @@ +package demo + +import ( + "math" + "math/rand" + "sync" + "time" + + "github.com/oxynote/oxynote/server/core/pkg/timeutil" +) + +// _tick is the interval between two demo samples. +const _tick = time.Minute + +// _tickMillis is that same interval in the milliseconds the storage layer +// counts in. +const _tickMillis = int64(_tick / time.Millisecond) + +// _segment is how many ticks one replay segment spans. A sample replays +// at most this many steps from the checkpoint before it, which is what +// keeps a query over a year from walking every tick since the epoch. +const _segment = 1440 + +// _seedStride separates the random streams of two series whose segment +// numbers differ by one, so no two streams overlap. +const _seedStride = 1_000_003 + +// _epoch is the instant the demo timeline starts. It is fixed so that a +// tick always denotes the same moment, on every install and every run. +var _epoch = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + +// tickAt reports which tick covers the given instant. +func tickAt(t time.Time) int64 { + return int64(t.Sub(_epoch) / _tick) +} + +// tickAtMillis reports which tick covers the given millisecond instant. +func tickAtMillis(ms int64) int64 { + return (ms - _epoch.UnixMilli()) / _tickMillis +} + +// timeAt reports the instant the given tick starts at. +func timeAt(tick int64) time.Time { + return _epoch.Add(time.Duration(tick) * _tick) +} + +// latestTick reports the last tick the timeline has reached. Nothing past +// it is ever sampled: the demo has no more happened in the future than a +// real deployment has. +func latestTick() int64 { + return tickAt(timeutil.Now()) +} + +// newRand returns the random stream the given seed and stream number +// name. Every segment of every series draws from its own stream, so a +// value never depends on which ticks were read before it. +func newRand(seed, stream int64) *rand.Rand { + //nolint:gosec // demo data wants a cheap reproducible source, not a cryptographic one + return rand.New(rand.NewSource(seed*_seedStride + stream)) +} + +// normal returns a sample from N(mean, stddev) via the Box-Muller +// transform. +func normal(r *rand.Rand, mean, stddev float64) float64 { + u1 := r.Float64() + u2 := r.Float64() + z := math.Sqrt(-2.0*math.Log(u1)) * math.Cos(2.0*math.Pi*u2) + + return mean + stddev*z +} + +// clamp returns x clamped to [lo, hi]. +func clamp(x, lo, hi float64) float64 { + if x < lo { + return lo + } + + if x > hi { + return hi + } + + return x +} + +// countAt returns the count the given bucket reports at the given tick. +// A count is resampled from scratch every tick, so it needs no history. +func countAt(seed, tick int64, mean, relStdDev float64) float64 { + v := math.Round(normal(newRand(seed, tick), mean, mean*relStdDev)) + if v < 0 { + return 0 + } + + return v +} + +// walkParams configures a gauge that evolves each tick via drift and mean +// reversion. +type walkParams struct { + // Min is the lower bound of the gauge value. + Min float64 + + // Max is the upper bound of the gauge value. + Max float64 + + // Start is the value the walk begins at, at the epoch. + Start float64 + + // DriftPerStep is a constant added each tick, modelling a slow + // linear trend. Mean reversion absorbs it, so the walk settles + // around Target+DriftPerStep/MeanReversion rather than running away. + DriftPerStep float64 + + // NoiseStdDev is the standard deviation of the Gaussian noise added + // each tick. + NoiseStdDev float64 + + // SpikeChance is the probability (0–1) of an additional spike + // occurring on any given tick. + SpikeChance float64 + + // SpikeStdDev is the standard deviation of the spike magnitude when + // one occurs. + SpikeStdDev float64 + + // MeanReversion is the fraction (0–1) of the distance to Target + // pulled back each tick. + MeanReversion float64 + + // Target is the attractor value for mean reversion. + Target float64 +} + +// walk replays a mean-reverting random walk. The value at a tick depends +// only on the walk's seed and the tick's distance from the epoch, never on +// which ticks were read before it — so every query sees the same history, +// and a line already drawn never rewrites itself. +type walk struct { + // params govern the walk's drift, noise and mean reversion. + params walkParams + + // seed distinguishes this walk's random streams from every other + // series'. + seed int64 + + // mu guards checkpoints. + mu sync.Mutex + + // checkpoints holds the walk's value at the start of each segment, + // grown on demand. Index i is the value at tick i*_segment. + checkpoints []float64 +} + +// newWalk creates a fresh instance of walk. +func newWalk(seed int64, params walkParams) *walk { + return &walk{ + params: params, + seed: seed, + } +} + +// at returns the walk's value at the given tick. +func (w *walk) at(tick int64) float64 { + if tick < 0 { + tick = 0 + } + + segment := tick / _segment + + return replay( + w.checkpoint(segment), + w.params, + newRand(w.seed, segment), + int(tick%_segment), + ) +} + +// checkpoint returns the walk's value at the start of the given segment, +// computing and caching every segment before it that is not cached yet. +func (w *walk) checkpoint(segment int64) float64 { + w.mu.Lock() + defer w.mu.Unlock() + + if len(w.checkpoints) == 0 { + w.checkpoints = []float64{clamp(w.params.Start, w.params.Min, w.params.Max)} + } + + for int64(len(w.checkpoints)) <= segment { + last := int64(len(w.checkpoints)) - 1 + + w.checkpoints = append(w.checkpoints, replay( + w.checkpoints[last], + w.params, + newRand(w.seed, last), + _segment, + )) + } + + return w.checkpoints[segment] +} + +// replay advances v by the given number of the walk's ticks. +func replay(v float64, p walkParams, r *rand.Rand, steps int) float64 { + for range steps { + v += p.MeanReversion * (p.Target - v) + v += p.DriftPerStep + normal(r, 0, p.NoiseStdDev) + + if r.Float64() < p.SpikeChance { + v += normal(r, 0, p.SpikeStdDev) + } + + v = clamp(v, p.Min, p.Max) + } + + return v +} diff --git a/server/core/internal/datasource/demo/series_test.go b/server/core/internal/datasource/demo/series_test.go new file mode 100644 index 00000000..3a71d132 --- /dev/null +++ b/server/core/internal/datasource/demo/series_test.go @@ -0,0 +1,283 @@ +package demo + +import ( + "math" + "testing" + "time" + + "github.com/oxynote/oxynote/server/core/pkg/timeutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// _testWalk is a walk whose bounds are wide enough that clamping never +// hides a mistake in the replay itself. +var _testWalk = walkParams{ + Min: 0, + Max: 1_000, + Start: 100, + Target: 120, + MeanReversion: 0.05, + NoiseStdDev: 5, + DriftPerStep: 0.1, + SpikeChance: 0.05, + SpikeStdDev: 20, +} + +func Test_tickAt(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Time time.Time + Result int64 + }{ + "The epoch itself": {Time: _epoch, Result: 0}, + "Part way into a tick": {Time: _epoch.Add(30 * time.Second), Result: 0}, + "The next tick": {Time: _epoch.Add(time.Minute), Result: 1}, + "An hour in": {Time: _epoch.Add(time.Hour), Result: 60}, + "A day in": {Time: _epoch.Add(24 * time.Hour), Result: _segment}, + "Before the epoch": { + Time: _epoch.Add(-time.Hour), + Result: -60, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, c.Result, tickAt(c.Time)) + }) + } +} + +func Test_tickAtMillis(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Millis int64 + Result int64 + }{ + "The epoch itself": {Millis: _epoch.UnixMilli(), Result: 0}, + "Part way into a tick": {Millis: _epoch.UnixMilli() + 30_000, Result: 0}, + "The next tick": {Millis: _epoch.UnixMilli() + 60_000, Result: 1}, + "A day in": {Millis: _epoch.Add(24 * time.Hour).UnixMilli(), Result: _segment}, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, c.Result, tickAtMillis(c.Millis)) + }) + } +} + +func Test_timeAt(t *testing.T) { + t.Parallel() + + assert.Equal(t, _epoch, timeAt(0)) + assert.Equal(t, _epoch.Add(time.Minute), timeAt(1)) + + // the two directions agree, which is what lets a sample timestamp be + // turned back into the tick that produced its value. + for _, tick := range []int64{0, 1, 59, _segment, 1_000_000} { + assert.Equal(t, tick, tickAt(timeAt(tick))) + } +} + +func Test_latestTick(t *testing.T) { + t.Parallel() + + got := latestTick() + + // nothing past the present is ever sampled, and the timeline has + // reached the present. + assert.Equal(t, tickAt(timeutil.Now()), got) + assert.Positive(t, got) + assert.False(t, timeAt(got).After(timeutil.Now())) +} + +func Test_newRand(t *testing.T) { + t.Parallel() + + // the same seed and stream replay the same numbers, which is the + // whole basis of a frozen history. + assert.Equal(t, newRand(1, 2).Float64(), newRand(1, 2).Float64()) + + // a different seed or a different stream is a different history. + assert.NotEqual(t, newRand(1, 2).Float64(), newRand(2, 2).Float64()) + assert.NotEqual(t, newRand(1, 2).Float64(), newRand(1, 3).Float64()) + + // neighbouring series do not overlap: seed 1's stream 0 is not seed + // 0's stream _seedStride read one segment later. + assert.NotEqual(t, newRand(1, 0).Float64(), newRand(0, 1).Float64()) +} + +func Test_normal(t *testing.T) { + t.Parallel() + + // with no spread every draw is the mean. + assert.InDelta(t, 7.0, normal(newRand(1, 1), 7, 0), 1e-9) + + // the same stream draws the same number twice. + assert.Equal(t, normal(newRand(1, 1), 0, 1), normal(newRand(1, 1), 0, 1)) + + // over many draws the sample mean lands near the stated mean. + r := newRand(3, 3) + sum := 0.0 + + for range 10_000 { + sum += normal(r, 5, 2) + } + + assert.InDelta(t, 5.0, sum/10_000, 0.1) +} + +func Test_clamp(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + X float64 + Lo float64 + Hi float64 + Result float64 + }{ + "Within the bounds": {X: 5, Lo: 0, Hi: 10, Result: 5}, + "Below the floor": {X: -1, Lo: 0, Hi: 10, Result: 0}, + "Above the ceiling": {X: 11, Lo: 0, Hi: 10, Result: 10}, + "On the floor": {X: 0, Lo: 0, Hi: 10, Result: 0}, + "On the ceiling": {X: 10, Lo: 0, Hi: 10, Result: 10}, + "Infinite": {X: math.Inf(1), Lo: 0, Hi: 10, Result: 10}, + "Negative infinite": {X: math.Inf(-1), Lo: 0, Hi: 10, Result: 0}, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, c.Result, clamp(c.X, c.Lo, c.Hi)) + }) + } +} + +func Test_countAt(t *testing.T) { + t.Parallel() + + // a count depends on its tick alone, so reading it again answers the + // same and reading a neighbour does not. + assert.Equal(t, countAt(1, 10, 1_000, 0.15), countAt(1, 10, 1_000, 0.15)) + assert.NotEqual(t, countAt(1, 10, 1_000, 0.15), countAt(1, 11, 1_000, 0.15)) + assert.NotEqual(t, countAt(1, 10, 1_000, 0.15), countAt(2, 10, 1_000, 0.15)) + + // counts are whole and never negative, however wide the spread. + for tick := range int64(500) { + v := countAt(9, tick, 4, 5) + + assert.GreaterOrEqual(t, v, 0.0) + assert.Equal(t, math.Trunc(v), v) + } + + // across many ticks the average lands near the stated mean. + sum := 0.0 + + for tick := range int64(5_000) { + sum += countAt(4, tick, 2_000, 0.15) + } + + assert.InDelta(t, 2_000.0, sum/5_000, 50) +} + +func Test_replay(t *testing.T) { + t.Parallel() + + // no steps is no movement. + assert.Equal(t, 100.0, replay(100, _testWalk, newRand(1, 1), 0)) + + // the same start and stream replay to the same value. + assert.Equal( + t, + replay(100, _testWalk, newRand(1, 1), 50), + replay(100, _testWalk, newRand(1, 1), 50), + ) + + // every step clamps, so a walk started far outside its bounds is + // inside them again after one. + p := _testWalk + p.Min = 10 + p.Max = 20 + + assert.LessOrEqual(t, replay(1_000, p, newRand(1, 1), 1), 20.0) + assert.GreaterOrEqual(t, replay(-1_000, p, newRand(1, 1), 1), 10.0) +} + +func Test_newWalk(t *testing.T) { + t.Parallel() + + w := newWalk(42, _testWalk) + require.NotNil(t, w) + + assert.Equal(t, int64(42), w.seed) + assert.Equal(t, _testWalk, w.params) + + // nothing is computed until something is asked for. + assert.Empty(t, w.checkpoints) +} + +func Test_walk_at(t *testing.T) { + t.Parallel() + + // two walks built from the same seed answer identically, which is + // what makes the history the same on every install rather than only + // within one process. + a, b := newWalk(5, _testWalk), newWalk(5, _testWalk) + + for _, tick := range []int64{0, 1, 100, _segment - 1, _segment, _segment + 1, 3*_segment + 7} { + assert.Equal(t, a.at(tick), b.at(tick), "tick %d", tick) + } + + // reading out of order answers the same as reading in order: a value + // depends on its tick, not on what was read before it. + c := newWalk(5, _testWalk) + assert.Equal(t, a.at(2*_segment+3), c.at(2*_segment+3)) + + // the walk starts where it was told to and stays inside its bounds. + assert.Equal(t, _testWalk.Start, a.at(0)) + + for _, tick := range []int64{1, 500, _segment, 5_000} { + assert.GreaterOrEqual(t, a.at(tick), _testWalk.Min) + assert.LessOrEqual(t, a.at(tick), _testWalk.Max) + } + + // a tick before the epoch reads as the epoch rather than running the + // walk backwards. + assert.Equal(t, a.at(0), a.at(-10)) + + // a segment boundary is continuous: the first tick of a segment is + // the value the previous segment ended on. + assert.Equal(t, a.checkpoint(1), a.at(_segment)) +} + +func Test_walk_checkpoint(t *testing.T) { + t.Parallel() + + w := newWalk(11, _testWalk) + + // the first checkpoint is the start value, clamped. + assert.Equal(t, _testWalk.Start, w.checkpoint(0)) + assert.Len(t, w.checkpoints, 1) + + // asking for a later one fills in every segment before it, once. + assert.NotZero(t, w.checkpoint(3)) + assert.Len(t, w.checkpoints, 4) + + cached := w.checkpoints[3] + assert.Equal(t, cached, w.checkpoint(3)) + assert.Len(t, w.checkpoints, 4) + + // a start outside the bounds is pulled inside before anything runs. + p := _testWalk + p.Start = 5_000 + + assert.Equal(t, p.Max, newWalk(12, p).checkpoint(0)) +} diff --git a/server/core/internal/datasource/processor/postgresql.go b/server/core/internal/datasource/processor/postgresql.go index 20c0e0c0..770c800b 100644 --- a/server/core/internal/datasource/processor/postgresql.go +++ b/server/core/internal/datasource/processor/postgresql.go @@ -27,6 +27,20 @@ const ( _queryPayloadLimit = 5 * 1024 * 1024 // 5 MB ) +// _pgAllowedConnKeys lists the connection-string keys a data-source URL may +// carry. The URL is organisation-supplied, and keys outside this list can +// reach the local filesystem (sslkey, passfile, servicefile, ...) or override +// session behavior (options), so parsing fails closed on anything else. +var _pgAllowedConnKeys = []string{ + "host", + "port", + "dbname", + "user", + "password", + "sslmode", + "connect_timeout", +} + // PostgreSQL represents a PostgreSQL data source processor. type PostgreSQL struct { inp Input @@ -261,7 +275,9 @@ func (p *PostgreSQL) connect(ctx context.Context) (*pgx.Conn, error) { return nil, err } - cfg, err := pgx.ParseConfig(connStr) + cfg, err := pgx.ParseConfigWithOptions(connStr, pgx.ParseConfigOptions{ + ConnStringAllowedKeys: _pgAllowedConnKeys, + }) if err != nil { return nil, fmt.Errorf("error parsing postgresql connection string: %w", err) } diff --git a/server/core/internal/datasource/processor/postgresql_test.go b/server/core/internal/datasource/processor/postgresql_test.go index d221f897..6a160a1a 100644 --- a/server/core/internal/datasource/processor/postgresql_test.go +++ b/server/core/internal/datasource/processor/postgresql_test.go @@ -693,9 +693,10 @@ func Test_PostgreSQL_connect(t *testing.T) { _, err = p.connect(context.Background()) assert.Error(t, err) - // the session refuses writes, and the simple protocol cannot be + // the session refuses writes, and connection-string keys outside the + // allowlist are rejected at parse, so the simple protocol cannot be // re-enabled through the data source URL to smuggle a second statement - // past the gate. + // past the gate, and the URL cannot reach the local filesystem. cc := map[string]struct { URL string Query string @@ -708,6 +709,18 @@ func Test_PostgreSQL_connect(t *testing.T) { URL: pgTestURL(_pgUser, _pgPass) + "&default_query_exec_mode=simple_protocol", Query: "SET transaction_read_only = off; DELETE FROM metrics", }, + "Disallowed sslkey key": { + URL: pgTestURL(_pgUser, _pgPass) + "&sslkey=/tmp/key", + Query: "SELECT time FROM metrics", + }, + "Disallowed servicefile key": { + URL: pgTestURL(_pgUser, _pgPass) + "&servicefile=/tmp/service", + Query: "SELECT time FROM metrics", + }, + "Disallowed options key": { + URL: pgTestURL(_pgUser, _pgPass) + "&options=-csearch_path%3Dpublic", + Query: "SELECT time FROM metrics", + }, } for cn, c := range cc { @@ -721,7 +734,12 @@ func Test_PostgreSQL_connect(t *testing.T) { _, err := p.Query(context.Background(), c.Query, _testTimeRange) assert.Error(t, err) - // the rows are still there. + // the rows are still there, checked over a clean URL since the + // case's own URL may be the thing being rejected. + p = NewPostgreSQL(&InputMock{ + URLFunc: func() string { return pgTestURL(_pgUser, _pgPass) }, + }) + res, err := p.Query(context.Background(), "SELECT time FROM metrics ORDER BY time", _testTimeRange) require.NoError(t, err) assert.Len(t, res.Rows, 2) diff --git a/server/core/internal/datasource/processor/prometheus.go b/server/core/internal/datasource/processor/prometheus.go index c88280f4..3504fc24 100644 --- a/server/core/internal/datasource/processor/prometheus.go +++ b/server/core/internal/datasource/processor/prometheus.go @@ -141,9 +141,14 @@ func (p *Prometheus) LabelNames(ctx context.Context, matchers []string, tr TimeR return nil, fmt.Errorf("error fetching label names: %w", err) } + result := make([]string, len(names)) + for i, n := range names { + result[i] = string(n) + } + return &PrometheusLabelNamesResult{ Warnings: warns, - Result: names, + Result: result, }, nil } diff --git a/server/core/internal/datasource/runner.go b/server/core/internal/datasource/runner.go index b797c79d..1cd7cec4 100644 --- a/server/core/internal/datasource/runner.go +++ b/server/core/internal/datasource/runner.go @@ -6,7 +6,9 @@ import ( "log/slog" "net/http" "slices" + "strings" + "github.com/oxynote/oxynote/server/core/internal/datasource/demo" "github.com/oxynote/oxynote/server/core/internal/datasource/processor" "github.com/oxynote/oxynote/server/core/pkg/errutil" "github.com/rs/xid" @@ -21,6 +23,7 @@ import ( type Manager struct { log *slog.Logger store StatusStore + demo *demo.Client } // NewManager creates a fresh instance of Manager. @@ -28,12 +31,13 @@ func NewManager(log *slog.Logger, store StatusStore) *Manager { return &Manager{ log: log.With("component", "datasource"), store: store, + demo: demo.NewClient(), } } // Runner returns the runner that operates the given data source. func (m *Manager) Runner(ds DataSource) Runner { - return &runner{ds: ds, log: m.log, store: m.store} + return &runner{ds: ds, log: m.log, store: m.store, demo: m.demo} } // runner operates one data source. @@ -54,6 +58,10 @@ type runner struct { // client is the processor this runner's type resolves to. client connectionTester + + // demo answers the data source this process synthesizes, shared with + // every other runner the manager hands out. + demo *demo.Client } // Type reports what the data source speaks. @@ -172,7 +180,12 @@ func (r *runner) ensurePrepared() error { switch r.ds.Type { case TypePrometheus: - r.client = processor.NewPrometheus(newStateInput(r.ds.URL, r.ds.Credentials)) + client, err := r.prometheusClient() + if err != nil { + return err + } + + r.client = client case TypePostgreSQL: r.client = processor.NewPostgreSQL(newStateInput(r.ds.URL, r.ds.Credentials)) case TypeMariaDB, TypeMySQL: @@ -186,6 +199,22 @@ func (r *runner) ensurePrepared() error { return nil } +// prometheusClient resolves which Prometheus a data source speaks to. The +// demo scheme names a source this process synthesizes rather than one it +// dials, and only the one demo source exists — a URL that merely looks +// like it is a mistake worth reporting, not a second demo. +func (r *runner) prometheusClient() (connectionTester, error) { + if r.ds.URL == demo.URL { + return r.demo, nil + } + + if strings.HasPrefix(r.ds.URL, demo.Scheme) { + return nil, demo.ErrUnknownSource + } + + return processor.NewPrometheus(newStateInput(r.ds.URL, r.ds.Credentials)), nil +} + // Runner operates one data source: it hands out the typed client for // the operation being performed, or the reason it cannot. // diff --git a/server/core/internal/datasource/runner_test.go b/server/core/internal/datasource/runner_test.go index 3e0cbfd9..6e02a8e6 100644 --- a/server/core/internal/datasource/runner_test.go +++ b/server/core/internal/datasource/runner_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/oxynote/oxynote/server/core/internal/datasource/demo" "github.com/oxynote/oxynote/server/core/internal/datasource/processor" "github.com/oxynote/oxynote/server/core/pkg/testutil" "github.com/prometheus/client_golang/api" @@ -394,20 +395,23 @@ func Test_runner_ensurePrepared(t *testing.T) { cc := map[string]struct { Type Type + URL string Err error }{ - "Prometheus": {Type: TypePrometheus}, - "PostgreSQL": {Type: TypePostgreSQL}, - "MySQL": {Type: TypeMySQL}, - "MariaDB": {Type: TypeMariaDB}, - "Unknown type": {Type: Type("bogus"), Err: assert.AnError}, + "Prometheus": {Type: TypePrometheus}, + "Demo Prometheus": {Type: TypePrometheus, URL: demo.URL}, + "Unknown demo source": {Type: TypePrometheus, URL: demo.Scheme + "nope", Err: demo.ErrUnknownSource}, + "PostgreSQL": {Type: TypePostgreSQL}, + "MySQL": {Type: TypeMySQL}, + "MariaDB": {Type: TypeMariaDB}, + "Unknown type": {Type: Type("bogus"), Err: assert.AnError}, } for cn, c := range cc { t.Run(cn, func(t *testing.T) { t.Parallel() - r := prepRunner(c.Type, "", nil) + r := prepRunner(c.Type, c.URL, nil) err := r.ensurePrepared() @@ -428,3 +432,46 @@ func Test_runner_ensurePrepared(t *testing.T) { }) } } + +func Test_runner_prometheusClient(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + URL string + Client connectionTester + Err error + }{ + "Demo data source": { + URL: demo.URL, + Client: &demo.Client{}, + }, + "Demo scheme naming a source that does not exist": { + URL: demo.Scheme + "marketing", + Err: demo.ErrUnknownSource, + }, + "Demo scheme with no source at all": { + URL: demo.Scheme, + Err: demo.ErrUnknownSource, + }, + "Real server": { + URL: "http://prom.test:9090", + Client: &processor.Prometheus{}, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + client, err := prepRunner(TypePrometheus, c.URL, nil).prometheusClient() + testutil.AssertEqualError(t, c.Err, err) + + if err != nil { + assert.Nil(t, client) + return + } + + assert.IsType(t, c.Client, client) + }) + } +} diff --git a/server/core/internal/server/internal/github/http.go b/server/core/internal/server/internal/github/http.go index 37582519..253a6f04 100644 --- a/server/core/internal/server/internal/github/http.go +++ b/server/core/internal/server/internal/github/http.go @@ -7,7 +7,7 @@ import ( "net/http" "strconv" - gogithub "github.com/google/go-github/v72/github" + gogithub "github.com/google/go-github/v88/github" "github.com/guregu/null/v5" "github.com/oxynote/oxynote/server/core/internal/apps/github" "github.com/oxynote/oxynote/server/core/internal/server/internal/apps" diff --git a/server/core/internal/server/internal/org/http.go b/server/core/internal/server/internal/org/http.go index 4baa9592..8f7df82b 100644 --- a/server/core/internal/server/internal/org/http.go +++ b/server/core/internal/server/internal/org/http.go @@ -12,6 +12,7 @@ import ( "github.com/oxynote/oxynote/server/core/internal/apps/github" "github.com/oxynote/oxynote/server/core/internal/apps/webchange" "github.com/oxynote/oxynote/server/core/internal/datasource" + "github.com/oxynote/oxynote/server/core/internal/datasource/demo" "github.com/oxynote/oxynote/server/core/internal/document" "github.com/oxynote/oxynote/server/core/internal/document/hook" "github.com/oxynote/oxynote/server/core/internal/search" @@ -32,14 +33,13 @@ var ErrNoOrganizationMembers = errutil.New(http.StatusBadRequest, "organization. // Handler holds dependencies required for organization-related operations. type Handler struct { - log *slog.Logger - db DB - storer Storer - githubMan *github.Manager - webchangeClient *webchange.Client - searchJobs *search.Jobs - logoLocation string - demoPrometheusURL string + log *slog.Logger + db DB + storer Storer + githubMan *github.Manager + webchangeClient *webchange.Client + searchJobs *search.Jobs + logoLocation string } // NewHandler creates a new handler instance with the provided logger and database. @@ -51,17 +51,15 @@ func NewHandler( webchangeClient *webchange.Client, searchJobs *search.Jobs, logoLocationFormat string, - demoPrometheusURL string, ) *Handler { return &Handler{ - log: log, - db: db, - storer: storer, - githubMan: githubMan, - webchangeClient: webchangeClient, - searchJobs: searchJobs, - logoLocation: logoLocationFormat, - demoPrometheusURL: demoPrometheusURL, + log: log, + db: db, + storer: storer, + githubMan: githubMan, + webchangeClient: webchangeClient, + searchJobs: searchJobs, + logoLocation: logoLocationFormat, } } @@ -73,19 +71,6 @@ func (h *Handler) InitializeOrganization(w http.ResponseWriter, r *http.Request) return } - if h.demoPrometheusURL == "" { - h.log.Warn("skipping initial demo data source insertion since no URL is configured") - - httpserver.Respond( - h.log, - w, - nil, - http.StatusOK, - ) - - return - } - members, err := h.db.FetchOrganizationMembers(r.Context(), id) if err != nil { httpserver.RespondError(h.log, w, err) @@ -318,7 +303,7 @@ func (h *Handler) insertDemoDataSource(ctx context.Context, organizationID strin ds := datasource.NewDataSource(datasource.CreateInput{ Type: datasource.TypePrometheus, Name: "Demo", - URL: h.demoPrometheusURL, + URL: demo.URL, Credentials: []byte(`{}`), }, organizationID) diff --git a/server/core/internal/server/internal/org/http_test.go b/server/core/internal/server/internal/org/http_test.go index b3949b1e..ce5dc102 100644 --- a/server/core/internal/server/internal/org/http_test.go +++ b/server/core/internal/server/internal/org/http_test.go @@ -16,6 +16,7 @@ import ( "github.com/oxynote/oxynote/server/core/internal/apps/github" "github.com/oxynote/oxynote/server/core/internal/apps/webchange" "github.com/oxynote/oxynote/server/core/internal/datasource" + "github.com/oxynote/oxynote/server/core/internal/datasource/demo" "github.com/oxynote/oxynote/server/core/internal/document" "github.com/oxynote/oxynote/server/core/internal/document/hook" "github.com/oxynote/oxynote/server/core/internal/document/hook/processor" @@ -93,7 +94,6 @@ func Test_NewHandler(t *testing.T) { webchangeClient, searchJobs, "loc", - "http://prom.test", ) require.NotNil(t, hdl) assert.NotNil(t, hdl.log) @@ -103,7 +103,6 @@ func Test_NewHandler(t *testing.T) { assert.Same(t, webchangeClient, hdl.webchangeClient) assert.Same(t, searchJobs, hdl.searchJobs) assert.Equal(t, "loc", hdl.logoLocation) - assert.Equal(t, "http://prom.test", hdl.demoPrometheusURL) } func Test_Handler_InitializeOrganization(t *testing.T) { @@ -135,7 +134,7 @@ func Test_Handler_InitializeOrganization(t *testing.T) { assert.Equal(t, "Demo", ff[0].Ds.Name) assert.Equal(t, datasource.TypePrometheus, ff[0].Ds.Type) - assert.Equal(t, "http://prom.test", ff[0].Ds.URL) + assert.Equal(t, demo.URL, ff[0].Ds.URL) assert.Equal(t, "org2", ff[0].Ds.OrganizationID) } } @@ -188,7 +187,6 @@ func Test_Handler_InitializeOrganization(t *testing.T) { Members []string MembersErr error BeginErr error - NoProm bool OmitID bool Checks []check }{ @@ -197,14 +195,6 @@ func Test_Handler_InitializeOrganization(t *testing.T) { OmitID: true, Checks: checks( hasResp(http.StatusNotFound, `{"code":"general","message":"not found"}`), - wasInsertDocumentCalled(0), - ), - }, - "No demo Prometheus URL configured": { - Tx: &TxMock{}, - NoProm: true, - Checks: checks( - hasResp(http.StatusOK, ""), wasInsertDataSourceCalled(0), wasInsertDocumentCalled(0), ), @@ -341,12 +331,6 @@ func Test_Handler_InitializeOrganization(t *testing.T) { t.Run(cn, func(t *testing.T) { t.Parallel() - promURL := "http://prom.test" - - if c.NoProm { - promURL = "" - } - db := c.DB if db == nil { db = &DBMock{} @@ -357,10 +341,9 @@ func Test_Handler_InitializeOrganization(t *testing.T) { } hdl := Handler{ - log: slog.New(slog.DiscardHandler), - db: withTx(db, c.Tx, c.BeginErr), - searchJobs: search.NewJobs(true), - demoPrometheusURL: promURL, + log: slog.New(slog.DiscardHandler), + db: withTx(db, c.Tx, c.BeginErr), + searchJobs: search.NewJobs(true), } req := httptest.NewRequest(http.MethodPost, "http://test.com/", http.NoBody) diff --git a/server/core/internal/server/internal/slack/event.go b/server/core/internal/server/internal/slack/event.go index 966bc7d7..789a7aeb 100644 --- a/server/core/internal/server/internal/slack/event.go +++ b/server/core/internal/server/internal/slack/event.go @@ -23,7 +23,7 @@ func (h *Handler) handleChannelOpenEvent(ctx context.Context, teamID, userID, ch return fmt.Errorf("failed to get slack client: %w", err) } - user, err := client.GetUserInfo(userID) + user, err := client.GetUserInfoContext(ctx, userID) if err != nil { return fmt.Errorf("failed to get user info: %w", err) } diff --git a/server/core/internal/server/internal/slack/http.go b/server/core/internal/server/internal/slack/http.go index db5d1c2e..98513bd1 100644 --- a/server/core/internal/server/internal/slack/http.go +++ b/server/core/internal/server/internal/slack/http.go @@ -2,7 +2,6 @@ package slack import ( - "bytes" "context" "encoding/json" "fmt" @@ -355,7 +354,7 @@ func (h *Handler) HandleCommand(w http.ResponseWriter, r *http.Request) { if !app.OrganizationID.Valid { //nolint:nestif // the branching is sequential and readable var user *goslack.User - user, err = client.GetUserInfo(payload.User.ID) + user, err = client.GetUserInfoContext(r.Context(), payload.User.ID) if err != nil { httpserver.RespondError(h.log, w, err) return @@ -429,35 +428,15 @@ func (h *Handler) HandleCommand(w http.ResponseWriter, r *http.Request) { // sendEphemeralResponse sends an ephemeral response to a Slack interaction. func (h *Handler) sendEphemeralResponse(ctx context.Context, responseURL, text string) error { - body := struct { - Text string `json:"text"` - }{ - Text: text, - } - - data, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("failed to marshal response body: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, responseURL, bytes.NewBuffer(data)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - - resp, err := h.client.Do(req) - if err != nil { - return fmt.Errorf("failed to send response: %w", err) - } - defer resp.Body.Close() //nolint:errcheck // error provides no meaningful info - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("received non-OK response: %s", resp.Status) - } - - return nil + return goslack.PostWebhookCustomHTTPContext( + ctx, + responseURL, + h.client, + &goslack.WebhookMessage{ + Text: text, + ResponseType: goslack.ResponseTypeEphemeral, + }, + ) } // DB is an interface that handles communication with the document database. diff --git a/server/core/internal/server/internal/slack/http_test.go b/server/core/internal/server/internal/slack/http_test.go index 4f1751a5..0849101f 100644 --- a/server/core/internal/server/internal/slack/http_test.go +++ b/server/core/internal/server/internal/slack/http_test.go @@ -17,6 +17,7 @@ import ( "github.com/oxynote/oxynote/server/core/internal/server/internal/auth" "github.com/oxynote/oxynote/server/core/pkg/errutil" "github.com/rs/xid" + goslack "github.com/slack-go/slack" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" @@ -124,6 +125,17 @@ func responseServer(t *testing.T) (*httptest.Server, *int) { return } + var body struct { + ResponseType string `json:"response_type"` //nolint:tagliatelle // slack wire format + } + + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || + body.ResponseType != goslack.ResponseTypeEphemeral { + w.WriteHeader(http.StatusBadRequest) + + return + } + hits++ w.WriteHeader(http.StatusOK) diff --git a/server/core/internal/server/router.go b/server/core/internal/server/router.go index ac8c26e8..837ed526 100644 --- a/server/core/internal/server/router.go +++ b/server/core/internal/server/router.go @@ -125,7 +125,7 @@ func (s *Server) internalRouter() chi.Router { r.Get("/version", s.fetchVersion) r.Mount("/debug", middleware.Profiler()) r.Handle("/metrics", promhttp.InstrumentMetricHandler( - s.fc, promhttp.HandlerFor(s.fc, promhttp.HandlerOpts{}), + s.fc, promhttp.HandlerFor(s.fc, promhttp.HandlerOpts{CoalesceGather: true}), )) r.Post("/organizations/{organizationId}/initialize", s.handlers.organization.InitializeOrganization) diff --git a/server/core/internal/server/server.go b/server/core/internal/server/server.go index b5d5e584..46054c84 100644 --- a/server/core/internal/server/server.go +++ b/server/core/internal/server/server.go @@ -48,9 +48,6 @@ type Options struct { // PublicURL is the public URL of the server. PublicURL string - // DemoPrometheusURL is the URL of the demo Prometheus instance. - DemoPrometheusURL string - // Port specifies which port should be used by the server. Port uint @@ -172,7 +169,6 @@ func NewServer( webchangeClient, searchJobs, opts.PublicURL+_organizationLogoLocation, - opts.DemoPrometheusURL, ) srv.handlers.document = document.NewHandler(log, db, githubMan, webchangeClient, searchGateway, searchJobs, notifier, storageClient) srv.handlers.comment = comment.NewHandler(log, db, notifier) diff --git a/server/core/internal/server/server_test.go b/server/core/internal/server/server_test.go index 961b9f56..0d3fa89b 100644 --- a/server/core/internal/server/server_test.go +++ b/server/core/internal/server/server_test.go @@ -155,11 +155,10 @@ func Test_NewServer(t *testing.T) { srv, err := NewServer( log, Options{ - PublicURL: "http://test.com", - DemoPrometheusURL: "http://prom.test", - Port: 8080, - Origins: []string{"http://localhost:3000"}, - Auth: AuthOptions{BetterAuthURL: "http://auth.test"}, + PublicURL: "http://test.com", + Port: 8080, + Origins: []string{"http://localhost:3000"}, + Auth: AuthOptions{BetterAuthURL: "http://auth.test"}, MCP: MCPOptions{ SessionURL: "http://auth.test/api/internal/mcp/session", ResourceURL: "http://test.com/core/api/mcp", diff --git a/server/core/internal/storage/client_test.go b/server/core/internal/storage/client_test.go index d5a8c3dc..3ebfcc9f 100644 --- a/server/core/internal/storage/client_test.go +++ b/server/core/internal/storage/client_test.go @@ -161,7 +161,7 @@ func newFakeS3Server(t *testing.T, f *fakeS3) string { // fakeS3 is a minimal in-memory S3 implementation covering only the // requests the minio client performs for the storage package: bucket -// location lookup, bucket creation, multipart upload, object stat, +// location lookup, bucket creation, object upload, object stat, // object retrieval and object removal. type fakeS3 struct { mu sync.Mutex @@ -176,16 +176,13 @@ type fakeS3 struct { // contentTypes maps completed object keys to their content type. contentTypes map[string]string - // parts accumulates uploaded multipart data per object key. - parts map[string][]byte - // failBucket forces the bucket existence probe to fail. failBucket bool // failMakeBucket forces bucket creation to fail. failMakeBucket bool - // failUpload forces multipart upload initiation to fail. + // failUpload forces object uploads to fail. failUpload bool // failStat forces object stat requests to fail. @@ -204,7 +201,7 @@ func (f *fakeS3) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer f.mu.Unlock() // the maps are initialized independently: a fixture that seeds only - // objects still needs the other two. + // objects still needs the other one. if f.objects == nil { f.objects = make(map[string][]byte) } @@ -213,10 +210,6 @@ func (f *fakeS3) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.contentTypes = make(map[string]string) } - if f.parts == nil { - f.parts = make(map[string][]byte) - } - segments := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/"), "/", 2) var key string @@ -234,14 +227,10 @@ func (f *fakeS3) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.handleHeadBucket(w) case key == "" && r.Method == http.MethodPut: f.handleMakeBucket(w) - case r.Method == http.MethodPost && query.Has("uploads"): - f.handleInitiateUpload(w, r, key) case r.Method == http.MethodPut && r.Header.Get("X-Amz-Copy-Source") != "": f.handleCopy(w, r, key) - case r.Method == http.MethodPut && query.Get("uploadId") != "": - f.handleUploadPart(w, r, key) - case r.Method == http.MethodPost && query.Get("uploadId") != "": - f.handleCompleteUpload(w, key) + case r.Method == http.MethodPut: + f.handlePut(w, r, key) case r.Method == http.MethodHead: f.handleStat(w, key) case r.Method == http.MethodGet: @@ -295,46 +284,22 @@ func (f *fakeS3) handleMakeBucket(w http.ResponseWriter) { w.WriteHeader(http.StatusOK) } -// handleInitiateUpload serves multipart upload initiation, recording -// the object's content type provided by the client. -func (f *fakeS3) handleInitiateUpload(w http.ResponseWriter, r *http.Request, key string) { +// handlePut stores an object uploaded through a single PUT request, +// recording the object's content type provided by the client. +func (f *fakeS3) handlePut(w http.ResponseWriter, r *http.Request, key string) { if f.failUpload { writeS3Error(w, http.StatusForbidden, "AccessDenied") return } + f.objects[key] = readS3Body(r) f.contentTypes[key] = r.Header.Get("Content-Type") - fmt.Fprintf( //nolint:errcheck,gosec // test server response errors and input taint are irrelevant - w, - `%stest-upload`, - key, - ) -} - -// handleUploadPart accumulates a single multipart upload part. -func (f *fakeS3) handleUploadPart(w http.ResponseWriter, r *http.Request, key string) { - f.parts[key] = append(f.parts[key], readS3Body(r)...) - - w.Header().Set("ETag", `"part-etag"`) + w.Header().Set("ETag", `"test-etag"`) w.WriteHeader(http.StatusOK) } -// handleCompleteUpload finalizes a multipart upload by promoting the -// accumulated parts to a stored object. -func (f *fakeS3) handleCompleteUpload(w http.ResponseWriter, key string) { - f.objects[key] = f.parts[key] - - delete(f.parts, key) - - fmt.Fprintf( //nolint:errcheck,gosec // test server response errors and input taint are irrelevant - w, - `test-bucket%s"test-etag"`, - key, - ) -} - // handleStat serves object metadata requests. func (f *fakeS3) handleStat(w http.ResponseWriter, key string) { if f.failStat { diff --git a/server/core/internal/storage/object.go b/server/core/internal/storage/object.go index 07141f8a..72bf7e99 100644 --- a/server/core/internal/storage/object.go +++ b/server/core/internal/storage/object.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "path" + "slices" "github.com/minio/minio-go/v7" "github.com/oxynote/oxynote/server/core/pkg/errutil" @@ -70,12 +71,23 @@ func (c *Client) Upload(ctx context.Context, folder, id string, r io.Reader) err key := path.Join(folder, id) + // the object is fully buffered rather than streamed: the read side is + // already capped at _maxObjectSize, and a seekable reader with an exact + // size keeps the client's transient-error retries (disabled for + // non-seekable streams) and a single-part upload. + rest, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("reading object: %w", err) + } + + data := slices.Concat(buf[:n], rest) + _, err = c.client.PutObject( ctx, c.bucket, key, - io.MultiReader(bytes.NewReader(buf[:n]), r), // Since we read some bytes for content detection, we need to prepend them back. - -1, + bytes.NewReader(data), + int64(len(data)), minio.PutObjectOptions{ ContentType: ct, }, diff --git a/server/core/pkg/sentryutil/sentryutil.go b/server/core/pkg/sentryutil/sentryutil.go index f73180a8..1ca9024d 100644 --- a/server/core/pkg/sentryutil/sentryutil.go +++ b/server/core/pkg/sentryutil/sentryutil.go @@ -63,6 +63,9 @@ func Setup(cfg Config) (func(), error) { Dsn: cfg.DSN, Release: cfg.Release, Environment: cfg.Environment, + // nothing in this codebase emits Sentry Logs, so keep the + // default-on telemetry machinery switched off. + DisableLogs: true, BeforeSend: func(e *sentry.Event, _ *sentry.EventHint) *sentry.Event { // NOTE: Remove default info. e.Modules = nil diff --git a/server/core/pkg/sentryutil/sentryutil_test.go b/server/core/pkg/sentryutil/sentryutil_test.go index 00e1de0f..6d9a87bb 100644 --- a/server/core/pkg/sentryutil/sentryutil_test.go +++ b/server/core/pkg/sentryutil/sentryutil_test.go @@ -21,6 +21,7 @@ func Test_Setup(t *testing.T) { closer, err = Setup(Config{}) require.NoError(t, err) require.NotNil(t, closer) + assert.True(t, sentry.CurrentHub().Client().Options().DisableLogs) assert.NotPanics(t, func() { closer() })