diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml index 2dd88b8..714a817 100644 --- a/.woodpecker/pre-commit.yaml +++ b/.woodpecker/pre-commit.yaml @@ -7,3 +7,6 @@ steps: commands: - test -z "$(gofmt -l .)" - go vet ./... + environment: + # golib lives on Gitea; skip the public proxy/sum db. + GOPRIVATE: git.unkin.net diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml index 3faea75..187ccbd 100644 --- a/.woodpecker/test.yaml +++ b/.woodpecker/test.yaml @@ -6,3 +6,6 @@ steps: image: golang:1.25 commands: - go test -race -count=1 ./pkg/... ./internal/... ./api/... + environment: + # golib lives on Gitea; skip the public proxy/sum db. + GOPRIVATE: git.unkin.net diff --git a/Dockerfile.api b/Dockerfile.api index 6ca6af3..806db53 100644 --- a/Dockerfile.api +++ b/Dockerfile.api @@ -4,6 +4,10 @@ RUN apk add --no-cache git WORKDIR /build +# golib is fetched straight from Gitea; the public proxy and sum db have no +# view of git.unkin.net modules. +ENV GOPRIVATE=git.unkin.net + COPY go.mod go.sum ./ RUN go mod download diff --git a/Dockerfile.operator b/Dockerfile.operator index 86feaba..4e367ca 100644 --- a/Dockerfile.operator +++ b/Dockerfile.operator @@ -4,6 +4,10 @@ RUN apk add --no-cache git WORKDIR /build +# golib is fetched straight from Gitea; the public proxy and sum db have no +# view of git.unkin.net modules. +ENV GOPRIVATE=git.unkin.net + COPY go.mod go.sum ./ RUN go mod download diff --git a/Makefile b/Makefile index f876a6e..50a9b21 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,11 @@ BINARY_OP := bin/forgebot-operator BINARY_TUI := bin/forgebot-tui VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev") +# git.unkin.net modules (golib) are fetched straight from Gitea, never via the +# public proxy or sum db, which have no view of them. Exported here rather than +# written with `go env -w`, so a fresh checkout needs no machine-local setup. +export GOPRIVATE := git.unkin.net + build: tidy go build -ldflags="-s -w" -o $(BINARY_API) ./cmd/api go build -ldflags="-s -w" -o $(BINARY_OP) ./cmd/operator diff --git a/README.md b/README.md index 1d86856..9b416eb 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,26 @@ FORGEBOT_API_URL=http://forgebot-api:8000 ./bin/forgebot-tui | `POST` | `/api/v1/tasks/{id}/comment` | Post comment to forge | | `POST` | `/api/v1/webhook/gitea` | Gitea webhook receiver | +## Schema + +The SQL lives in `migrations/`, is embedded in the API binary and applied at +startup before the server listens, so there is no mirrored copy in the +deployment to drift out of sync. The runner is +[`golib/pg`](https://git.unkin.net/unkin/golib)'s `pg.NewMigrated` — forgebot +owns the SQL, the shared library owns the mechanics. + +Each start takes `pg_advisory_lock` on a fixed key (FNV-1a/64 of the lock name +`forgebot-migrations`), creates `schema_migrations` (`version`, `applied_at`) if +missing, and applies every embedded file whose filename is not yet recorded — in +lexical (version) order, each file's SQL and its tracking row in one transaction +— then unlocks. Replicas starting together queue on the lock and then find +nothing to do. + +A file absent from `schema_migrations` is re-run even where the live database +already has the schema, which is how a database created by the pre-`golib` +runner is adopted. Migrations are therefore `IF NOT EXISTS`-guarded and their +data fixups re-runnable. + ## CRDs - **AgentPool** — Configuration for a pool of AI agents (model, concurrency, image, resources) @@ -121,3 +141,19 @@ make generate # regenerate CRDs and RBAC make docker-api # build API container image make docker-operator # build operator container image ``` + +### `GOPRIVATE` + +forgebot depends on `git.unkin.net/unkin/golib`, which is served by Gitea and is +unknown to `proxy.golang.org` / `sum.golang.org`. Module resolution therefore +needs: + +``` +export GOPRIVATE=git.unkin.net +``` + +The `Makefile` exports it for every target, and both Dockerfiles and the +woodpecker Go steps set it themselves, so `make build|test|lint` and CI work on +a clean checkout. Only bare `go` commands run outside `make` need it in your +shell — set it there rather than with `go env -w`, which is machine state this +repo cannot carry. diff --git a/api/v1alpha1/agentpool_types.go b/api/v1alpha1/agentpool_types.go index d3a33bf..35d7d46 100644 --- a/api/v1alpha1/agentpool_types.go +++ b/api/v1alpha1/agentpool_types.go @@ -6,13 +6,13 @@ import ( ) type AgentPoolSpec struct { - Model string `json:"model"` - Endpoint string `json:"endpoint"` - MaxConcurrent int `json:"maxConcurrent"` - Image string `json:"image"` - Resources corev1.ResourceRequirements `json:"resources,omitempty"` + Model string `json:"model"` + Endpoint string `json:"endpoint"` + MaxConcurrent int `json:"maxConcurrent"` + Image string `json:"image"` + Resources corev1.ResourceRequirements `json:"resources,omitempty"` CredentialSecretRef corev1.LocalObjectReference `json:"credentialSecretRef"` - ServiceAccountName string `json:"serviceAccountName,omitempty"` + ServiceAccountName string `json:"serviceAccountName,omitempty"` } type AgentPoolStatus struct { diff --git a/api/v1alpha1/providerqueue_types.go b/api/v1alpha1/providerqueue_types.go index c27002a..691503b 100644 --- a/api/v1alpha1/providerqueue_types.go +++ b/api/v1alpha1/providerqueue_types.go @@ -6,9 +6,9 @@ import ( ) type ProviderQueueSpec struct { - Provider string `json:"provider"` - Endpoint string `json:"endpoint"` - PollInterval string `json:"pollInterval"` + Provider string `json:"provider"` + Endpoint string `json:"endpoint"` + PollInterval string `json:"pollInterval"` CredentialSecretRef corev1.LocalObjectReference `json:"credentialSecretRef"` } diff --git a/cmd/api/main.go b/cmd/api/main.go index 9dddc54..1a2f46e 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -20,7 +20,7 @@ func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() - srv, err := apiserver.New(cfg) + srv, err := apiserver.New(ctx, cfg) if err != nil { slog.Error("failed to create server", "error", err) os.Exit(1) diff --git a/go.mod b/go.mod index d2260e9..77f8b45 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,12 @@ go 1.25.9 require ( code.gitea.io/sdk/gitea v0.19.0 + git.unkin.net/unkin/golib v0.1.0 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/go-chi/chi/v5 v5.2.1 - github.com/jackc/pgx/v5 v5.7.4 + github.com/jackc/pgx/v5 v5.9.2 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.34.4 k8s.io/apimachinery v0.34.4 @@ -36,7 +37,7 @@ require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect @@ -77,13 +78,13 @@ require ( go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/net v0.38.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.30.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.9.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/protobuf v1.36.5 // indirect diff --git a/go.sum b/go.sum index 5a6d756..a94f2fb 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,13 @@ code.gitea.io/sdk/gitea v0.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y= code.gitea.io/sdk/gitea v0.19.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +git.unkin.net/unkin/golib v0.1.0 h1:OjKT5TO7PuXiYQ/1A+I4S2VZ/IsAxXY1reGWwasDMqE= +git.unkin.net/unkin/golib v0.1.0/go.mod h1:1e3PpMLEfa03Re/6tlk+I2XPWzBQMSpw2MB+Aw2lkX4= +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/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -8,6 +16,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/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/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= @@ -32,12 +42,30 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +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/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +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/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/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -46,6 +74,8 @@ github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8 github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -54,10 +84,14 @@ github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +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-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= @@ -89,8 +123,8 @@ 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.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= -github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +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/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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -99,8 +133,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +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/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -112,6 +146,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -120,6 +158,24 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= 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= @@ -138,10 +194,16 @@ github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= @@ -154,6 +216,10 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -166,14 +232,34 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -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/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +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.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/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +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.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= @@ -188,8 +274,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +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-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -199,38 +285,38 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +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.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +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/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= -golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/apiserver/config.go b/internal/apiserver/config.go index 698e93f..4d58247 100644 --- a/internal/apiserver/config.go +++ b/internal/apiserver/config.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "strconv" + + "git.unkin.net/unkin/golib/pg" ) type Config struct { @@ -19,9 +21,16 @@ type Config struct { GiteaToken string } +// DatabaseDSN renders the connection string with golib's builder, which +// percent-escapes the credentials — byte-identical to the fmt.Sprintf form it +// replaces for values without reserved characters. +// +// The environment is still read by LoadConfig rather than pg.DSNFromEnv: the +// library has no defaults for the user and database name, where forgebot +// defaults both to "forgebot", and it would newly honour DATABASE_URL and the +// PG* variables. Deployments set the DB* names below and nothing else. func (c *Config) DatabaseDSN() string { - return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s", - c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName, c.DBSSL) + return pg.DSN(c.DBHost, c.DBPort, c.DBUser, c.DBPass, c.DBName, c.DBSSL) } func LoadConfig() (*Config, error) { diff --git a/internal/apiserver/config_test.go b/internal/apiserver/config_test.go new file mode 100644 index 0000000..7076acb --- /dev/null +++ b/internal/apiserver/config_test.go @@ -0,0 +1,55 @@ +package apiserver + +import ( + "testing" +) + +// The deployed contract is these six variables and these defaults; nothing else +// is consulted for the database connection. +func TestLoadConfig_DatabaseEnvContract(t *testing.T) { + // Set by a deployment that would confuse a DATABASE_URL/PG*-aware loader. + t.Setenv("DATABASE_URL", "postgres://someone@elsewhere:5432/other") + t.Setenv("PGHOST", "elsewhere") + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if got, want := cfg.DatabaseDSN(), "postgres://forgebot:@localhost:5432/forgebot?sslmode=disable"; got != want { + t.Fatalf("default DSN = %q, want %q", got, want) + } + + t.Setenv("DBHOST", "db.example") + t.Setenv("DBPORT", "6432") + t.Setenv("DBUSER", "bot") + t.Setenv("DBPASS", "hunter2") + t.Setenv("DBNAME", "tasks") + t.Setenv("DBSSL", "require") + + cfg, err = LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if got, want := cfg.DatabaseDSN(), "postgres://bot:hunter2@db.example:6432/tasks?sslmode=require"; got != want { + t.Fatalf("DSN = %q, want %q", got, want) + } +} + +func TestLoadConfig_RejectsBadPort(t *testing.T) { + t.Setenv("DBPORT", "not-a-port") + if _, err := LoadConfig(); err == nil { + t.Fatal("expected an error for a non-numeric DBPORT") + } +} + +// A password with reserved characters used to truncate the DSN; the builder +// percent-escapes the credentials so it round-trips through pgx intact. +func TestDatabaseDSN_EscapesCredentials(t *testing.T) { + cfg := &Config{ + DBHost: "db.example", DBPort: 5432, DBUser: "bo/t", + DBPass: "p@ss/word", DBName: "tasks", DBSSL: "disable", + } + if got, want := cfg.DatabaseDSN(), "postgres://bo%2Ft:p%40ss%2Fword@db.example:5432/tasks?sslmode=disable"; got != want { + t.Fatalf("DSN = %q, want %q", got, want) + } +} diff --git a/internal/apiserver/server.go b/internal/apiserver/server.go index 6dee9f1..7d37064 100644 --- a/internal/apiserver/server.go +++ b/internal/apiserver/server.go @@ -22,8 +22,10 @@ type Server struct { provider *gitea.Client } -func New(cfg *Config) (*Server, error) { - db, err := database.New(cfg.DatabaseDSN()) +// New connects to Postgres and migrates the schema before wiring the routes, +// so a failed migration is a failed startup rather than a broken server. +func New(ctx context.Context, cfg *Config) (*Server, error) { + db, err := database.New(ctx, cfg.DatabaseDSN(), slog.Default()) if err != nil { return nil, err } diff --git a/internal/database/migrations.go b/internal/database/migrations.go deleted file mode 100644 index 05bac53..0000000 --- a/internal/database/migrations.go +++ /dev/null @@ -1,41 +0,0 @@ -package database - -import "context" - -func (db *DB) migrate() error { - _, err := db.Pool.Exec(context.Background(), ` - CREATE TABLE IF NOT EXISTS tasks ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - parent_task_id UUID REFERENCES tasks(id), - command TEXT NOT NULL, - skill TEXT NOT NULL DEFAULT '', - repository TEXT NOT NULL, - ref TEXT NOT NULL, - issue_number INTEGER NOT NULL DEFAULT 0, - pr_number INTEGER NOT NULL DEFAULT 0, - comment_id BIGINT NOT NULL DEFAULT 0, - body TEXT NOT NULL DEFAULT '', - author TEXT NOT NULL, - extra_tools TEXT[] NOT NULL DEFAULT '{}', - status TEXT NOT NULL DEFAULT 'todo', - pool_ref TEXT NOT NULL DEFAULT '', - job_name TEXT NOT NULL DEFAULT '', - result TEXT NOT NULL DEFAULT '', - error_message TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - started_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ - ); - - CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); - CREATE INDEX IF NOT EXISTS idx_tasks_repository ON tasks(repository); - CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id); - - -- migrate legacy statuses - UPDATE tasks SET status = 'todo' WHERE status IN ('pending', 'failed'); - UPDATE tasks SET status = 'in_progress' WHERE status = 'running'; - UPDATE tasks SET status = 'done' WHERE status = 'succeeded'; - UPDATE tasks SET status = 'wontdo' WHERE status = 'cancelled'; - `) - return err -} diff --git a/internal/database/migrations_test.go b/internal/database/migrations_test.go new file mode 100644 index 0000000..a5fb567 --- /dev/null +++ b/internal/database/migrations_test.go @@ -0,0 +1,195 @@ +package database + +import ( + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "git.unkin.net/unkin/golib/pg" + + "git.unkin.net/unkin/forgebot/migrations" + "git.unkin.net/unkin/forgebot/pkg/models" +) + +// migrationsDir is the repo's migrations/ directory, relative to this package. +const migrationsDir = "../../migrations" + +func readMigrations(t *testing.T) map[string]string { + t.Helper() + entries, err := os.ReadDir(migrationsDir) + if err != nil { + t.Fatalf("read migrations dir: %v", err) + } + out := map[string]string{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + b, err := os.ReadFile(filepath.Join(migrationsDir, e.Name())) + if err != nil { + t.Fatalf("read %s: %v", e.Name(), err) + } + out[e.Name()] = string(b) + } + if len(out) == 0 { + t.Fatal("no migrations found") + } + return out +} + +// The embedded set is the shipped schema, so it must match the migrations/ +// directory exactly — a file added on disk but not embedded would never run. +func TestEmbeddedMigrationsMatchDirectory(t *testing.T) { + onDisk := readMigrations(t) + entries, err := fs.ReadDir(migrations.FS, ".") + if err != nil { + t.Fatalf("read embedded migrations: %v", err) + } + embedded := map[string]string{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + b, err := migrations.FS.ReadFile(e.Name()) + if err != nil { + t.Fatalf("read embedded %s: %v", e.Name(), err) + } + embedded[e.Name()] = string(b) + } + if len(embedded) != len(onDisk) { + t.Fatalf("embedded %d files, migrations/ has %d", len(embedded), len(onDisk)) + } + for name, body := range embedded { + want, ok := onDisk[name] + if !ok { + t.Errorf("%s is embedded but not in migrations/", name) + continue + } + if body != want { + t.Errorf("%s: embedded body differs from migrations/%s", name, name) + } + } +} + +// The advisory lock key is derived from migrationLockName by golib. Every +// forgebot-api replica must contend on the same key, so renaming the lock would +// silently let two versions migrate at once during a rolling deploy. +func TestMigrationLockKeyIsStable(t *testing.T) { + const deployedKey int64 = -570492662391362667 + if got := pg.LockKey(migrationLockName); got != deployedKey { + t.Fatalf("LockKey(%q) = %d, want %d", migrationLockName, got, deployedKey) + } +} + +// Migrations run against a live database with existing rows, and a file absent +// from schema_migrations is re-run even where the schema is already present, so +// every statement must be guarded and re-runnable. +func TestMigrations_AreAdditiveAndIdempotent(t *testing.T) { + for name, body := range readMigrations(t) { + upper := strings.ToUpper(body) + for _, forbidden := range []string{"DROP TABLE", "DROP COLUMN", "ALTER COLUMN", "TRUNCATE", "DELETE FROM"} { + if strings.Contains(upper, forbidden) { + t.Errorf("%s contains destructive statement %q", name, forbidden) + } + } + for _, stmt := range strings.Split(upper, ";") { + stmt = strings.TrimSpace(stmt) + switch { + case strings.HasPrefix(stmt, "CREATE TABLE"), strings.HasPrefix(stmt, "CREATE INDEX"): + if !strings.Contains(stmt, "IF NOT EXISTS") { + t.Errorf("%s: %q is not guarded with IF NOT EXISTS", name, firstLine(stmt)) + } + case strings.HasPrefix(stmt, "ALTER TABLE"): + if !strings.Contains(stmt, "ADD COLUMN IF NOT EXISTS") { + t.Errorf("%s: %q is not an idempotent ADD COLUMN IF NOT EXISTS", name, firstLine(stmt)) + } + } + } + } +} + +// The legacy-status rewrites are data fixups carried over from the pre-golib +// runner, which re-ran them on every boot. Under versioned migrations 0001 is +// replayed once against the live database, so each rewrite must read only +// retired statuses and write only current ones: a rewrite whose target is also +// one of its sources would cascade rows on that replay. +func TestMigration0001_StatusRewritesAreIdempotent(t *testing.T) { + body, ok := readMigrations(t)["0001_init.sql"] + if !ok { + t.Fatal("0001_init.sql missing") + } + + current := map[string]bool{} + for _, s := range []models.TaskStatus{ + models.StatusTodo, models.StatusInProgress, models.StatusInReview, + models.StatusDone, models.StatusWontdo, + } { + current[string(s)] = true + } + + rewrite := regexp.MustCompile(`(?i)UPDATE tasks SET status = '(\w+)' WHERE status (?:=|IN) \(?([^)\n;]+)\)?`) + matches := rewrite.FindAllStringSubmatch(body, -1) + if len(matches) == 0 { + t.Fatal("no status rewrites found in 0001_init.sql") + } + for _, m := range matches { + target := m[1] + if !current[target] { + t.Errorf("rewrite targets %q, which is not a current status", target) + } + for _, raw := range strings.Split(m[2], ",") { + source := strings.Trim(strings.TrimSpace(raw), "'") + if current[source] { + t.Errorf("rewrite reads current status %q as a legacy source, so a replay would cascade", source) + } + } + } +} + +// Every column 0001 creates must be read back by the queries in tasks.go, so a +// schema change can never silently stop being scanned. +func TestMigrations_ColumnsAreSelected(t *testing.T) { + body := readMigrations(t)["0001_init.sql"] + create := regexp.MustCompile(`(?s)CREATE TABLE IF NOT EXISTS tasks \((.*?)\n\);`) + m := create.FindStringSubmatch(body) + if m == nil { + t.Fatal("could not parse the tasks CREATE TABLE in 0001_init.sql") + } + cols := map[string]bool{} + for _, line := range strings.Split(m[1], "\n") { + if f := strings.Fields(strings.TrimSpace(line)); len(f) > 0 { + cols[f[0]] = true + } + } + + src, err := os.ReadFile("tasks.go") + if err != nil { + t.Fatalf("read tasks.go: %v", err) + } + // The task queries all select the full column list; find it and check it + // covers the table. + selected := map[string]bool{} + for _, sel := range regexp.MustCompile(`(?s)SELECT (id, parent_task_id.*?)\n\s*FROM tasks`).FindAllStringSubmatch(string(src), -1) { + for _, c := range strings.Split(sel[1], ",") { + selected[strings.TrimSpace(c)] = true + } + } + if len(selected) == 0 { + t.Fatal("no task SELECT found in tasks.go") + } + for c := range cols { + if !selected[c] { + t.Errorf("column %q is never read back by the task queries", c) + } + } +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/internal/database/postgres.go b/internal/database/postgres.go index d5902de..ba75dde 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -2,30 +2,35 @@ package database import ( "context" - "fmt" + "log/slog" "github.com/jackc/pgx/v5/pgxpool" + + "git.unkin.net/unkin/golib/pg" + + "git.unkin.net/unkin/forgebot/migrations" ) +// migrationLockName names the cluster-wide advisory lock the migration run +// contends for. golib derives the key as FNV-1a/64 of this name, so every +// replica must pass the same string to stay mutually exclusive. +const migrationLockName = "forgebot-migrations" + type DB struct { Pool *pgxpool.Pool } -func New(dsn string) (*DB, error) { - pool, err := pgxpool.New(context.Background(), dsn) +// New opens the pool and brings the schema up to date before returning, so the +// API never serves against a half-migrated database. log may be nil. +func New(ctx context.Context, dsn string, log *slog.Logger) (*DB, error) { + pool, err := pg.NewMigrated(ctx, dsn, migrations.FS, pg.MigrateOptions{ + LockName: migrationLockName, + Logger: log, + }) if err != nil { - return nil, fmt.Errorf("connect to postgres: %w", err) + return nil, err } - if err := pool.Ping(context.Background()); err != nil { - pool.Close() - return nil, fmt.Errorf("ping postgres: %w", err) - } - db := &DB{Pool: pool} - if err := db.migrate(); err != nil { - pool.Close() - return nil, fmt.Errorf("run migrations: %w", err) - } - return db, nil + return &DB{Pool: pool}, nil } func (db *DB) Close() { diff --git a/internal/provider/gitea/webhook.go b/internal/provider/gitea/webhook.go index df6c66a..4b953cb 100644 --- a/internal/provider/gitea/webhook.go +++ b/internal/provider/gitea/webhook.go @@ -11,11 +11,11 @@ import ( ) type webhookPayload struct { - Action string `json:"action"` - Comment *commentPayload `json:"comment,omitempty"` - Issue *issuePayload `json:"issue,omitempty"` - Repository *repoPayload `json:"repository"` - PullRequest *prPayload `json:"pull_request,omitempty"` + Action string `json:"action"` + Comment *commentPayload `json:"comment,omitempty"` + Issue *issuePayload `json:"issue,omitempty"` + Repository *repoPayload `json:"repository"` + PullRequest *prPayload `json:"pull_request,omitempty"` } type commentPayload struct { @@ -27,7 +27,7 @@ type commentPayload struct { } type issuePayload struct { - Number int `json:"number"` + Number int `json:"number"` PullRequest *struct{} `json:"pull_request,omitempty"` } @@ -39,7 +39,7 @@ type prPayload struct { } type repoPayload struct { - FullName string `json:"full_name"` + FullName string `json:"full_name"` DefaultBranch string `json:"default_branch"` } diff --git a/internal/tui/app.go b/internal/tui/app.go index 71e4df3..41d50a9 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -267,4 +267,3 @@ func tickCmd() tea.Cmd { return tickMsg(t) }) } - diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..b1b9da2 --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + parent_task_id UUID REFERENCES tasks(id), + command TEXT NOT NULL, + skill TEXT NOT NULL DEFAULT '', + repository TEXT NOT NULL, + ref TEXT NOT NULL, + issue_number INTEGER NOT NULL DEFAULT 0, + pr_number INTEGER NOT NULL DEFAULT 0, + comment_id BIGINT NOT NULL DEFAULT 0, + body TEXT NOT NULL DEFAULT '', + author TEXT NOT NULL, + extra_tools TEXT[] NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'todo', + pool_ref TEXT NOT NULL DEFAULT '', + job_name TEXT NOT NULL DEFAULT '', + result TEXT NOT NULL DEFAULT '', + error_message TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); +CREATE INDEX IF NOT EXISTS idx_tasks_repository ON tasks(repository); +CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id); + +-- Rename the pre-kanban statuses onto the current set. Each rewrite reads only +-- retired values and writes only current ones, so it is a no-op on its second +-- and later runs and on a fresh database. +UPDATE tasks SET status = 'todo' WHERE status IN ('pending', 'failed'); +UPDATE tasks SET status = 'in_progress' WHERE status = 'running'; +UPDATE tasks SET status = 'done' WHERE status = 'succeeded'; +UPDATE tasks SET status = 'wontdo' WHERE status = 'cancelled'; diff --git a/migrations/embed.go b/migrations/embed.go new file mode 100644 index 0000000..c65e5d7 --- /dev/null +++ b/migrations/embed.go @@ -0,0 +1,11 @@ +// Package migrations embeds the SQL schema files so forgebot-api carries its +// own schema and applies it at startup, with no externally mirrored copy to +// drift out of sync. +package migrations + +import "embed" + +// FS holds every numbered migration; lexical filename order is version order. +// +//go:embed *.sql +var FS embed.FS