From 8c796f40874377b64769e8eac724ef64f2da0c97 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Wed, 2 Sep 2026 00:16:58 +1000 Subject: [PATCH] Adopt golib/pg for migrations and pool construction forgebot applied its schema by executing an inline DDL string on every boot, with no version tracking and no lock, so two API replicas starting together raced and the SQL had nowhere to grow. golib/pg already owns that mechanic for the estate; take it and keep owning the SQL. - Move the schema into migrations/0001_init.sql, embedded via migrations.FS. The DDL is verbatim. - The four legacy-status UPDATEs move into 0001 unchanged. Each reads only retired statuses (pending/failed/running/succeeded/cancelled) and writes only current ones, and no current status is a source, so replaying 0001 once against the live database is a no-op. A test pins that property. - Build the pool with pg.NewMigrated, LockName "forgebot-migrations": the advisory lock serializes replicas, schema_migrations records what ran, and a migration failure fails startup instead of half-migrating. database.New and apiserver.New take a context and logger for it. - Render the DSN with pg.DSN, which percent-escapes the credentials the fmt.Sprintf builder pasted in raw. LoadConfig still reads the environment itself: pg.DSNFromEnv has no defaults for user and database name, where forgebot defaults both to "forgebot", and would newly honour DATABASE_URL and PG*. The deployed DBHOST/DBPORT/DBUSER/DBPASS/DBNAME/DBSSL contract and its defaults are unchanged, and pinned by a test. - Plumb GOPRIVATE=git.unkin.net for the first cross-repo Go dependency: exported by the Makefile, set in both Dockerfiles and the woodpecker Go steps, documented in the README. - gofmt the four files that were already unformatted on main, so the pre-commit step can pass. --- .woodpecker/pre-commit.yaml | 3 + .woodpecker/test.yaml | 3 + Dockerfile.api | 4 + Dockerfile.operator | 4 + Makefile | 5 + README.md | 36 +++++ api/v1alpha1/agentpool_types.go | 12 +- api/v1alpha1/providerqueue_types.go | 6 +- cmd/api/main.go | 2 +- go.mod | 17 +-- go.sum | 130 +++++++++++++++--- internal/apiserver/config.go | 13 +- internal/apiserver/config_test.go | 55 ++++++++ internal/apiserver/server.go | 6 +- internal/database/migrations.go | 41 ------ internal/database/migrations_test.go | 195 +++++++++++++++++++++++++++ internal/database/postgres.go | 33 +++-- internal/provider/gitea/webhook.go | 14 +- internal/tui/app.go | 1 - migrations/0001_init.sql | 34 +++++ migrations/embed.go | 11 ++ 21 files changed, 518 insertions(+), 107 deletions(-) create mode 100644 internal/apiserver/config_test.go delete mode 100644 internal/database/migrations.go create mode 100644 internal/database/migrations_test.go create mode 100644 migrations/0001_init.sql create mode 100644 migrations/embed.go 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