diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34b7e71..c9d6bda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,10 @@ jobs: go.mod - name: Test with coverage run: make test-coverage + - name: Example smoke (Postgres, SQLite) + run: | + go test ./examples/hello-postgres/... + go test ./examples/hello-sqlite/... - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: diff --git a/.golangci.yml b/.golangci.yml index 32060be..c117c63 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,3 +1,5 @@ +version: 2 + run: timeout: 5m tests: true @@ -19,8 +21,6 @@ linters: - gocritic # (extensive checks) - ineffassign - misspell - - gofmt - - goimports - unconvert - unparam - nakedret @@ -28,9 +28,22 @@ linters: # Complexity - gocyclo +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: github.com/go-modkit/modkit + linters-settings: - goimports: - local-prefixes: github.com/go-modkit/modkit + gosec: + excludes: + - G101 + - G302 + - G304 + - G306 + - G114 govet: enable: - nilness @@ -61,10 +74,9 @@ issues: exclude-rules: # Relax rules for tests - - path: _test\.go + - path: '.*_test\.go$' linters: - errcheck - - gosec - dupl - funlen - gocyclo diff --git a/Makefile b/Makefile index d57abd2..235e559 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ SHELL := /bin/sh GOPATH ?= $(shell go env GOPATH) GOIMPORTS ?= $(GOPATH)/bin/goimports GOLANGCI_LINT ?= $(GOPATH)/bin/golangci-lint +GOLANGCI_LINT_VERSION ?= v2.5.0 GOVULNCHECK ?= $(GOPATH)/bin/govulncheck GO_PATCH_COVER ?= $(GOPATH)/bin/go-patch-cover LEFTHOOK ?= $(GOPATH)/bin/lefthook @@ -53,7 +54,8 @@ test-patch-coverage: test-coverage # Install all development tools (tracked in tools/tools.go) tools: @echo "Installing development tools..." - @cat tools/tools.go | grep _ | awk '{print $$2}' | xargs -I {} sh -c 'go install {}' + @cat tools/tools.go | grep _ | awk '{print $$2}' | grep -v golangci-lint | xargs -I {} sh -c 'go install {}' + @go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) @echo "Done: All tools installed" # Install development tools and setup git hooks diff --git a/README.md b/README.md index a6469bf..3f79499 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,12 @@ func main() { | Validation | [Validation Guide](docs/guides/validation.md) | [`examples/hello-mysql/internal/validation/`](examples/hello-mysql/internal/validation/) + [`examples/hello-mysql/internal/modules/users/types.go`](examples/hello-mysql/internal/modules/users/types.go) | [`examples/hello-mysql/internal/modules/users/validation_test.go`](examples/hello-mysql/internal/modules/users/validation_test.go) | | Middleware | [Middleware Guide](docs/guides/middleware.md) | [`examples/hello-mysql/internal/middleware/`](examples/hello-mysql/internal/middleware/) + [`examples/hello-mysql/internal/httpserver/server.go`](examples/hello-mysql/internal/httpserver/server.go) | [`examples/hello-mysql/internal/middleware/middleware_test.go`](examples/hello-mysql/internal/middleware/middleware_test.go) | | Lifecycle and Cleanup | [Lifecycle Guide](docs/guides/lifecycle.md) | [`examples/hello-mysql/internal/lifecycle/cleanup.go`](examples/hello-mysql/internal/lifecycle/cleanup.go) + [`examples/hello-mysql/cmd/api/main.go`](examples/hello-mysql/cmd/api/main.go) | [`examples/hello-mysql/internal/lifecycle/lifecycle_test.go`](examples/hello-mysql/internal/lifecycle/lifecycle_test.go) | +| Database Providers | [Database Providers Guide](docs/guides/database-providers.md) | [`examples/hello-postgres/`](examples/hello-postgres/) + [`examples/hello-sqlite/`](examples/hello-sqlite/) | [`examples/hello-postgres/internal/smoke/smoke_test.go`](examples/hello-postgres/internal/smoke/smoke_test.go) + [`examples/hello-sqlite/internal/smoke/smoke_test.go`](examples/hello-sqlite/internal/smoke/smoke_test.go) | + +Migration note: if you used the MySQL example tokens (`database.TokenDB`), +prefer the shared SQL contract tokens (`sqlmodule.TokenDB`, +`sqlmodule.TokenDialect`) going forward. The MySQL example preserves backward +compatibility via token aliases. ## Packages @@ -232,6 +238,8 @@ See [Architecture Guide](docs/architecture.md) for details. **Examples:** - [hello-simple](examples/hello-simple/) — Minimal example, no Docker - [hello-mysql](examples/hello-mysql/) — Full CRUD API with MySQL +- [hello-postgres](examples/hello-postgres/) — Postgres provider + smoke tests +- [hello-sqlite](examples/hello-sqlite/) — SQLite provider + fast local smoke tests ## How It Compares to NestJS diff --git a/docs/guides/database-providers.md b/docs/guides/database-providers.md new file mode 100644 index 0000000..0993b91 --- /dev/null +++ b/docs/guides/database-providers.md @@ -0,0 +1,117 @@ +# Database Providers + +modkit ships a shared SQL contract plus provider modules for Postgres and +SQLite. The goal is to keep feature modules database-agnostic while still +making driver wiring explicit and deterministic. + +## Shared SQL Contract + +Use the shared contract tokens from `modkit/data/sqlmodule`: + +```go +import "github.com/go-modkit/modkit/modkit/data/sqlmodule" + +db, err := module.Get[*sql.DB](r, sqlmodule.TokenDB) +if err != nil { + return nil, err +} +dialect, err := module.Get[sqlmodule.Dialect](r, sqlmodule.TokenDialect) +if err != nil { + return nil, err +} +``` + +The contract exports two stable tokens: + +- `sqlmodule.TokenDB` -> `*sql.DB` +- `sqlmodule.TokenDialect` -> `sqlmodule.Dialect` + +For multi-instance apps, use `sqlmodule.NamedTokens(name)` and pass the same +name into the provider module options. + +## Postgres Provider + +Package: `modkit/data/postgres` + +```go +import "github.com/go-modkit/modkit/modkit/data/postgres" + +module.ModuleDef{ + Name: "app", + Imports: []module.Module{ + postgres.NewModule(postgres.Options{}), + }, +} +``` + +Configuration: + +- Required: `POSTGRES_DSN` +- Optional: `POSTGRES_MAX_OPEN_CONNS`, `POSTGRES_MAX_IDLE_CONNS`, + `POSTGRES_CONN_MAX_LIFETIME`, `POSTGRES_CONNECT_TIMEOUT` + +`POSTGRES_CONNECT_TIMEOUT=0` skips the startup ping. Any non-zero duration +enables a timeout-bound `PingContext` during provider build. + +The provider is driver-agnostic. Import a driver in your app (for example, +`_ "github.com/lib/pq"`). + +## SQLite Provider + +Package: `modkit/data/sqlite` + +```go +import "github.com/go-modkit/modkit/modkit/data/sqlite" + +module.ModuleDef{ + Name: "app", + Imports: []module.Module{ + sqlite.NewModule(sqlite.Options{}), + }, +} +``` + +Configuration: + +- Required: `SQLITE_PATH` (path or DSN) +- Optional: `SQLITE_BUSY_TIMEOUT`, `SQLITE_JOURNAL_MODE`, + `SQLITE_CONNECT_TIMEOUT` + +`SQLITE_CONNECT_TIMEOUT=0` skips the startup ping. Any non-zero duration +enables a timeout-bound `PingContext` during provider build. + +Like Postgres, the module is driver-agnostic. Import a driver in your app (for +example, `_ "github.com/mattn/go-sqlite3"`). + +## Named Instances + +For multiple databases in one app, supply a name and use `NamedTokens`: + +```go +tokens, err := sqlmodule.NamedTokens("analytics") +if err != nil { + return err +} + +module.ModuleDef{ + Name: "app", + Imports: []module.Module{ + postgres.NewModule(postgres.Options{Name: "analytics"}), + }, + Exports: []module.Token{tokens.DB, tokens.Dialect}, +} +``` + +## Migration Note (MySQL -> Shared SQL Contract) + +The `hello-mysql` example preserves backward compatibility: it still exports +`database.TokenDB` (value `"database.db"`) and adds `database.TokenDialect`. +For new code, prefer the shared contract tokens (`sqlmodule.TokenDB` and +`sqlmodule.TokenDialect`) and keep driver-specific modules out of feature +packages. + +## Examples + +- `examples/hello-postgres` — Postgres provider + smoke test +- `examples/hello-sqlite` — SQLite provider + file/in-memory smoke tests +- `examples/hello-mysql` — legacy MySQL example (still compatible) diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 240350c..cfa6abe 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -129,12 +129,29 @@ curl http://localhost:8080/greet # Hello, modkit! ``` +## SQLite Fast Start (No Docker) + +If you want a quick database-backed example without Docker, try the SQLite +example module: + +```bash +export SQLITE_PATH="/tmp/modkit.db" +go run ./examples/hello-sqlite/cmd/api/main.go +``` + +Then: + +```bash +curl http://localhost:8080/health +``` + ## Next Steps - [Modules Guide](modules.md) — Learn about imports, exports, and visibility - [Testing Guide](testing.md) — Testing patterns for modkit apps - [Architecture Guide](../architecture.md) — How modkit works under the hood - [Example App](../../examples/hello-mysql/) — Full CRUD API with MySQL, migrations, and Swagger +- [SQLite Example](../../examples/hello-sqlite/) — Fast local eval, no Docker required ## Troubleshooting Quickstart diff --git a/examples/hello-mysql/Dockerfile b/examples/hello-mysql/Dockerfile index a59d0b0..fbc7f37 100644 --- a/examples/hello-mysql/Dockerfile +++ b/examples/hello-mysql/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25 +FROM golang:1.25.7 WORKDIR /repo/examples/hello-mysql @@ -6,4 +6,11 @@ COPY . /repo RUN go mod download +RUN useradd -m -u 10001 appuser \ + && chown -R appuser:appuser /repo /go + +ENV GOCACHE=/tmp/go-cache + +USER appuser + CMD ["sh", "-c", "go run ./cmd/migrate && go run ./cmd/seed && go run ./cmd/api"] diff --git a/examples/hello-mysql/internal/modules/database/module.go b/examples/hello-mysql/internal/modules/database/module.go index 3d05ef8..5afc495 100644 --- a/examples/hello-mysql/internal/modules/database/module.go +++ b/examples/hello-mysql/internal/modules/database/module.go @@ -6,10 +6,17 @@ import ( configmodule "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/config" "github.com/go-modkit/modkit/examples/hello-mysql/internal/platform/mysql" + "github.com/go-modkit/modkit/modkit/data/sqlmodule" "github.com/go-modkit/modkit/modkit/module" ) -const TokenDB module.Token = "database.db" +const ( + // TokenDB is kept for backwards compatibility with the existing hello-mysql + // token path. + TokenDB module.Token = sqlmodule.TokenDB + + TokenDialect module.Token = sqlmodule.TokenDialect +) type Options struct { Config module.Module @@ -57,7 +64,13 @@ func (m Module) Definition() module.ModuleDef { return CleanupDB(ctx, db) }, }, + { + Token: TokenDialect, + Build: func(_ module.Resolver) (any, error) { + return sqlmodule.DialectMySQL, nil + }, + }, }, - Exports: []module.Token{TokenDB}, + Exports: []module.Token{TokenDB, TokenDialect}, } } diff --git a/examples/hello-mysql/internal/modules/database/module_test.go b/examples/hello-mysql/internal/modules/database/module_test.go index d652ec2..15f0f45 100644 --- a/examples/hello-mysql/internal/modules/database/module_test.go +++ b/examples/hello-mysql/internal/modules/database/module_test.go @@ -6,6 +6,7 @@ import ( "testing" configmodule "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/config" + "github.com/go-modkit/modkit/modkit/data/sqlmodule" "github.com/go-modkit/modkit/modkit/module" ) @@ -48,24 +49,78 @@ func TestDatabaseModule_Definition_ProvidesDB(t *testing.T) { if def.Name != "database" { t.Fatalf("expected name database, got %q", def.Name) } - if len(def.Providers) != 1 { - t.Fatalf("expected 1 provider, got %d", len(def.Providers)) + if len(def.Providers) != 2 { + t.Fatalf("expected 2 providers, got %d", len(def.Providers)) } if len(def.Imports) != 1 { t.Fatalf("expected 1 import, got %d", len(def.Imports)) } - if def.Providers[0].Token != TokenDB { - t.Fatalf("expected TokenDB, got %q", def.Providers[0].Token) + + var dbProvider, dialectProvider *module.ProviderDef + for i := range def.Providers { + p := &def.Providers[i] + switch p.Token { + case TokenDB: + dbProvider = p + case TokenDialect: + dialectProvider = p + } + } + if dbProvider == nil { + t.Fatalf("expected provider %q", TokenDB) } - if def.Providers[0].Cleanup == nil { + if dialectProvider == nil { + t.Fatalf("expected provider %q", TokenDialect) + } + if dbProvider.Cleanup == nil { t.Fatal("expected cleanup hook") } + + dialect, err := dialectProvider.Build(resolverMap{}) + if err != nil { + t.Fatalf("expected dialect build to succeed, got %v", err) + } + if dialect != sqlmodule.DialectMySQL { + t.Fatalf("expected dialect %q, got %v", sqlmodule.DialectMySQL, dialect) + } + + exports := map[module.Token]bool{} + for _, token := range def.Exports { + exports[token] = true + } + if !exports[TokenDB] { + t.Fatalf("expected export %q", TokenDB) + } + if !exports[TokenDialect] { + t.Fatalf("expected export %q", TokenDialect) + } +} + +func TestDatabaseModule_TokenDB_CompatibilityWithSQLContract(t *testing.T) { + if TokenDB != sqlmodule.TokenDB { + t.Fatalf("TokenDB = %q, want %q", TokenDB, sqlmodule.TokenDB) + } + if TokenDB != module.Token("database.db") { + t.Fatalf("TokenDB = %q, want %q", TokenDB, module.Token("database.db")) + } } func TestDatabaseModule_ProviderBuildError(t *testing.T) { mod := NewModule(Options{Config: configmodule.NewModule(configmodule.Options{})}) def := mod.(*Module).Definition() - provider := def.Providers[0] + + var provider module.ProviderDef + found := false + for _, p := range def.Providers { + if p.Token == TokenDB { + provider = p + found = true + break + } + } + if !found { + t.Fatalf("expected provider %q", TokenDB) + } _, err := provider.Build(resolverMap{configmodule.TokenMySQLDSN: ""}) if err == nil { diff --git a/examples/hello-mysql/internal/smoke/smoke_test.go b/examples/hello-mysql/internal/smoke/smoke_test.go index 57b591c..6d55748 100644 --- a/examples/hello-mysql/internal/smoke/smoke_test.go +++ b/examples/hello-mysql/internal/smoke/smoke_test.go @@ -5,9 +5,11 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" + "os/exec" "testing" "time" @@ -18,6 +20,8 @@ import ( ) func TestSmoke_HealthAndUsers(t *testing.T) { + requireDocker(t) + ctx := context.Background() container, dsn := startMySQL(t, ctx) defer func() { @@ -100,6 +104,24 @@ func TestSmoke_HealthAndUsers(t *testing.T) { } } +func requireDocker(t *testing.T) { + t.Helper() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker binary not found") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "docker", "info") + if err := cmd.Run(); err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Skip("docker info timed out") + } + t.Skipf("docker unavailable: %v", err) + } +} + func startMySQL(t *testing.T, ctx context.Context) (testcontainers.Container, string) { req := testcontainers.ContainerRequest{ Image: "mysql:8.0", diff --git a/examples/hello-postgres/Dockerfile b/examples/hello-postgres/Dockerfile new file mode 100644 index 0000000..49a55bd --- /dev/null +++ b/examples/hello-postgres/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:1.25.7 + +WORKDIR /repo/examples/hello-postgres + +COPY . /repo + +RUN go mod download + +CMD ["go", "run", "./cmd/api"] diff --git a/examples/hello-postgres/README.md b/examples/hello-postgres/README.md new file mode 100644 index 0000000..1834450 --- /dev/null +++ b/examples/hello-postgres/README.md @@ -0,0 +1,29 @@ +# hello-postgres + +Example consuming app for modkit using Postgres. + +## Run + +```bash +go run ./cmd/api +``` + +Then hit: + +```bash +curl http://localhost:8080/api/v1/health +``` + +## Run with Docker Compose + +```bash +docker compose up -d --build +curl http://localhost:8080/api/v1/health +docker compose down -v +``` + +## Configuration + +Environment variables: +- `HTTP_ADDR` (default `:8080`) +- `POSTGRES_DSN` (example `postgres://postgres:password@localhost:5432/app?sslmode=disable`) diff --git a/examples/hello-postgres/cmd/api/main.go b/examples/hello-postgres/cmd/api/main.go new file mode 100644 index 0000000..b5a82ec --- /dev/null +++ b/examples/hello-postgres/cmd/api/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "log" + + _ "github.com/lib/pq" + + "github.com/go-modkit/modkit/examples/hello-postgres/internal/httpserver" + mkhttp "github.com/go-modkit/modkit/modkit/http" +) + +func main() { + handler, err := httpserver.BuildHandler() + if err != nil { + log.Fatalf("build handler: %v", err) + } + + log.Println("Server starting on http://localhost:8080") + log.Println("Try: curl http://localhost:8080/api/v1/health") + if err := mkhttp.Serve(":8080", handler); err != nil { + log.Fatalf("serve: %v", err) + } +} diff --git a/examples/hello-postgres/docker-compose.yml b/examples/hello-postgres/docker-compose.yml new file mode 100644 index 0000000..2e4a276 --- /dev/null +++ b/examples/hello-postgres/docker-compose.yml @@ -0,0 +1,27 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: app + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d app"] + interval: 5s + timeout: 5s + retries: 10 + app: + build: + context: ../.. + dockerfile: examples/hello-postgres/Dockerfile + environment: + HTTP_ADDR: ":8080" + POSTGRES_DSN: "postgres://postgres:password@postgres:5432/app?sslmode=disable" + LOG_LEVEL: "debug" + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy diff --git a/examples/hello-postgres/go.mod b/examples/hello-postgres/go.mod new file mode 100644 index 0000000..be19128 --- /dev/null +++ b/examples/hello-postgres/go.mod @@ -0,0 +1,70 @@ +module github.com/go-modkit/modkit/examples/hello-postgres + +go 1.25.7 + +require ( + github.com/go-modkit/modkit v0.0.0 + github.com/lib/pq v1.10.9 + github.com/testcontainers/testcontainers-go v0.40.0 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.1+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.8.4 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-chi/chi/v5 v5.2.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/sys v0.40.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/go-modkit/modkit => ../.. diff --git a/examples/hello-postgres/go.sum b/examples/hello-postgres/go.sum new file mode 100644 index 0000000..79a0a9e --- /dev/null +++ b/examples/hello-postgres/go.sum @@ -0,0 +1,170 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/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/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/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.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +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.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= +github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +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.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/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.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +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.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/examples/hello-postgres/internal/app/module.go b/examples/hello-postgres/internal/app/module.go new file mode 100644 index 0000000..d4be1e0 --- /dev/null +++ b/examples/hello-postgres/internal/app/module.go @@ -0,0 +1,30 @@ +package app + +import ( + "github.com/go-modkit/modkit/modkit/data/postgres" + "github.com/go-modkit/modkit/modkit/data/sqlmodule" + "github.com/go-modkit/modkit/modkit/module" +) + +type Module struct { + postgres module.Module +} + +func NewModule() module.Module { + return &Module{ + postgres: postgres.NewModule(postgres.Options{}), + } +} + +func (m *Module) Definition() module.ModuleDef { + return module.ModuleDef{ + Name: "app", + Imports: []module.Module{ + m.postgres, + }, + Exports: []module.Token{ + sqlmodule.TokenDB, + sqlmodule.TokenDialect, + }, + } +} diff --git a/examples/hello-postgres/internal/app/module_test.go b/examples/hello-postgres/internal/app/module_test.go new file mode 100644 index 0000000..f617470 --- /dev/null +++ b/examples/hello-postgres/internal/app/module_test.go @@ -0,0 +1,35 @@ +package app + +import ( + "testing" + + "github.com/go-modkit/modkit/modkit/data/sqlmodule" +) + +func TestModuleDefinition(t *testing.T) { + def := NewModule().Definition() + + if def.Name != "app" { + t.Fatalf("expected name=app, got %q", def.Name) + } + if len(def.Imports) != 1 { + t.Fatalf("expected 1 import, got %d", len(def.Imports)) + } + if len(def.Exports) != 2 { + t.Fatalf("expected 2 exports, got %d", len(def.Exports)) + } + + foundDB := false + foundDialect := false + for _, token := range def.Exports { + switch token { + case sqlmodule.TokenDB: + foundDB = true + case sqlmodule.TokenDialect: + foundDialect = true + } + } + if !foundDB || !foundDialect { + t.Fatalf("expected db and dialect exports") + } +} diff --git a/examples/hello-postgres/internal/httpserver/server.go b/examples/hello-postgres/internal/httpserver/server.go new file mode 100644 index 0000000..9c70111 --- /dev/null +++ b/examples/hello-postgres/internal/httpserver/server.go @@ -0,0 +1,70 @@ +package httpserver + +import ( + "encoding/json" + "net/http" + + "github.com/go-modkit/modkit/examples/hello-postgres/internal/app" + modkithttp "github.com/go-modkit/modkit/modkit/http" + "github.com/go-modkit/modkit/modkit/kernel" + "github.com/go-modkit/modkit/modkit/module" +) + +type HealthController struct{} + +func (c *HealthController) RegisterRoutes(r modkithttp.Router) { + r.Handle(http.MethodGet, "/health", http.HandlerFunc(c.health)) +} + +func (c *HealthController) health(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]string{"status": "ok"}); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } +} + +type RootModule struct{} + +func (m *RootModule) Definition() module.ModuleDef { + return module.ModuleDef{ + Name: "api", + Imports: []module.Module{ + app.NewModule(), + }, + Controllers: []module.ControllerDef{{ + Name: "HealthController", + Build: func(_ module.Resolver) (any, error) { + return &HealthController{}, nil + }, + }}, + } +} + +var registerRoutes = modkithttp.RegisterRoutes + +func BuildAppHandler() (*kernel.App, http.Handler, error) { + boot, err := kernel.Bootstrap(&RootModule{}) + if err != nil { + return nil, nil, err + } + + router := modkithttp.NewRouter() + root := modkithttp.AsRouter(router) + + var registerErr error + root.Group("/api/v1", func(r modkithttp.Router) { + if err := registerRoutes(r, boot.Controllers); err != nil { + registerErr = err + } + }) + if registerErr != nil { + return boot, nil, registerErr + } + + return boot, router, nil +} + +func BuildHandler() (http.Handler, error) { + _, handler, err := BuildAppHandler() + return handler, err +} diff --git a/examples/hello-postgres/internal/httpserver/server_test.go b/examples/hello-postgres/internal/httpserver/server_test.go new file mode 100644 index 0000000..852e462 --- /dev/null +++ b/examples/hello-postgres/internal/httpserver/server_test.go @@ -0,0 +1,87 @@ +package httpserver + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + modkithttp "github.com/go-modkit/modkit/modkit/http" +) + +type errorResponseWriter struct { + header http.Header + status int +} + +func (w *errorResponseWriter) Header() http.Header { + if w.header == nil { + w.header = http.Header{} + } + return w.header +} + +func (w *errorResponseWriter) WriteHeader(status int) { + w.status = status +} + +func (w *errorResponseWriter) Write(_ []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + return 0, errors.New("write failed") +} + +func TestHealthEncodeErrorReturnsServerError(t *testing.T) { + w := &errorResponseWriter{} + req := httptest.NewRequest(http.MethodGet, "/health", nil) + + controller := &HealthController{} + controller.health(w, req) + + if w.status != http.StatusInternalServerError { + t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, w.status) + } +} + +func TestBuildHandlerServesHealth(t *testing.T) { + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "0") + + handler, err := BuildHandler() + if err != nil { + t.Fatalf("build handler: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code) + } + + var payload map[string]string + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["status"] != "ok" { + t.Fatalf("expected status=ok, got %q", payload["status"]) + } +} + +func TestBuildAppHandlerReturnsRegisterError(t *testing.T) { + oldRegister := registerRoutes + registerRoutes = func(_ modkithttp.Router, _ map[string]any) error { + return errors.New("register failed") + } + t.Cleanup(func() { + registerRoutes = oldRegister + }) + + _, _, err := BuildAppHandler() + if err == nil { + t.Fatal("expected error") + } +} diff --git a/examples/hello-postgres/internal/smoke/smoke_test.go b/examples/hello-postgres/internal/smoke/smoke_test.go new file mode 100644 index 0000000..559aa91 --- /dev/null +++ b/examples/hello-postgres/internal/smoke/smoke_test.go @@ -0,0 +1,143 @@ +package smoke + +import ( + "bytes" + "context" + "database/sql" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os/exec" + "testing" + "time" + + _ "github.com/lib/pq" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/go-modkit/modkit/examples/hello-postgres/internal/httpserver" + "github.com/go-modkit/modkit/modkit/data/sqlmodule" +) + +func TestSmoke_Postgres_ModuleBootsAndServes(t *testing.T) { + requireDocker(t) + + ctx := context.Background() + container, dsn := startPostgres(t, ctx) + defer func() { + _ = container.Terminate(ctx) + }() + + t.Setenv("POSTGRES_DSN", dsn) + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "2s") + + boot, handler, err := httpserver.BuildAppHandler() + if err != nil { + t.Fatalf("build handler failed: %v", err) + } + + srv := httptest.NewServer(handler) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/api/v1/health") + if err != nil { + t.Fatalf("health request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(resp.Body); err != nil { + t.Fatalf("read body: %v", err) + } + if got := bytes.TrimSpace(buf.Bytes()); len(got) == 0 { + t.Fatalf("expected non-empty body") + } + + dbAny, err := boot.Get(sqlmodule.TokenDB) + if err != nil { + t.Fatalf("resolve db: %v", err) + } + db, ok := dbAny.(*sql.DB) + if !ok { + t.Fatalf("unexpected db type: %T", dbAny) + } + + dialectAny, err := boot.Get(sqlmodule.TokenDialect) + if err != nil { + t.Fatalf("resolve dialect: %v", err) + } + dialect, ok := dialectAny.(sqlmodule.Dialect) + if !ok { + t.Fatalf("unexpected dialect type: %T", dialectAny) + } + if dialect != sqlmodule.DialectPostgres { + t.Fatalf("unexpected dialect: %q", dialect) + } + + qctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var one int + if err := db.QueryRowContext(qctx, "SELECT 1").Scan(&one); err != nil { + t.Fatalf("select failed: %v", err) + } + if one != 1 { + t.Fatalf("unexpected result: %d", one) + } +} + +func requireDocker(t *testing.T) { + t.Helper() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker binary not found") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "docker", "info") + if err := cmd.Run(); err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Skip("docker info timed out") + } + t.Skipf("docker unavailable: %v", err) + } +} + +func startPostgres(t *testing.T, ctx context.Context) (testcontainers.Container, string) { + t.Helper() + + req := testcontainers.ContainerRequest{ + Image: "postgres:16-alpine", + ExposedPorts: []string{"5432/tcp"}, + Env: map[string]string{ + "POSTGRES_PASSWORD": "password", + "POSTGRES_DB": "app", + }, + WaitingFor: wait.ForListeningPort("5432/tcp").WithStartupTimeout(2 * time.Minute), + } + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("container start failed: %v", err) + } + + host, err := container.Host(ctx) + if err != nil { + _ = container.Terminate(ctx) + t.Fatalf("container host failed: %v", err) + } + port, err := container.MappedPort(ctx, "5432") + if err != nil { + _ = container.Terminate(ctx) + t.Fatalf("container port failed: %v", err) + } + + dsn := fmt.Sprintf("postgres://postgres:password@%s:%s/app?sslmode=disable", host, port.Port()) + return container, dsn +} diff --git a/examples/hello-simple/Dockerfile b/examples/hello-simple/Dockerfile new file mode 100644 index 0000000..71f44b6 --- /dev/null +++ b/examples/hello-simple/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.25.7 + +WORKDIR /repo/examples/hello-simple + +COPY . /repo + +RUN go mod download + +RUN useradd -m -u 10001 appuser \ + && chown -R appuser:appuser /repo /go + +ENV GOCACHE=/tmp/go-cache + +USER appuser + +CMD ["go", "run", "main.go"] diff --git a/examples/hello-simple/README.md b/examples/hello-simple/README.md index 7a9c3ba..499297b 100644 --- a/examples/hello-simple/README.md +++ b/examples/hello-simple/README.md @@ -26,6 +26,15 @@ A minimal modkit example with no external dependencies (no Docker, no database). go run main.go ``` +## Run with Docker Compose + +```bash +docker compose up -d --build +curl http://localhost:8080/health +curl http://localhost:8080/greet +docker compose down -v +``` + Print the module graph while starting the server: ```bash diff --git a/examples/hello-simple/docker-compose.yml b/examples/hello-simple/docker-compose.yml new file mode 100644 index 0000000..a654857 --- /dev/null +++ b/examples/hello-simple/docker-compose.yml @@ -0,0 +1,7 @@ +services: + app: + build: + context: ../.. + dockerfile: examples/hello-simple/Dockerfile + ports: + - "8080:8080" diff --git a/examples/hello-sqlite/Dockerfile b/examples/hello-sqlite/Dockerfile new file mode 100644 index 0000000..eefab23 --- /dev/null +++ b/examples/hello-sqlite/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.25.7 + +WORKDIR /repo/examples/hello-sqlite + +COPY . /repo + +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc libsqlite3-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN go mod download + +RUN useradd -m -u 10001 appuser \ + && chown -R appuser:appuser /repo /go + +ENV GOCACHE=/tmp/go-cache + +USER appuser + +CMD ["go", "run", "./cmd/api"] diff --git a/examples/hello-sqlite/README.md b/examples/hello-sqlite/README.md new file mode 100644 index 0000000..3694777 --- /dev/null +++ b/examples/hello-sqlite/README.md @@ -0,0 +1,30 @@ +# hello-sqlite + +Example consuming app for modkit using SQLite. + +## Run + +```bash +go run ./cmd/api +``` + +Then hit: + +```bash +curl http://localhost:8080/health +``` + +## Run with Docker Compose + +```bash +docker compose up -d --build +curl http://localhost:8080/health +docker compose down -v +``` + +## Configuration + +Environment variables: +- `HTTP_ADDR` (default `:8080`) +- `SQLITE_PATH` (example `/tmp/app.db`) +- `SQLITE_CONNECT_TIMEOUT` (default `0`) diff --git a/examples/hello-sqlite/cmd/api/main.go b/examples/hello-sqlite/cmd/api/main.go new file mode 100644 index 0000000..e6c2cc7 --- /dev/null +++ b/examples/hello-sqlite/cmd/api/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + _ "github.com/mattn/go-sqlite3" + + "github.com/go-modkit/modkit/examples/hello-sqlite/internal/app" + mkhttp "github.com/go-modkit/modkit/modkit/http" + "github.com/go-modkit/modkit/modkit/kernel" + "github.com/go-modkit/modkit/modkit/module" +) + +type HealthController struct{} + +func (c *HealthController) RegisterRoutes(r mkhttp.Router) { + r.Handle(http.MethodGet, "/health", http.HandlerFunc(c.health)) +} + +func (c *HealthController) health(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} + +type RootModule struct{} + +func (m *RootModule) Definition() module.ModuleDef { + return module.ModuleDef{ + Name: "api", + Imports: []module.Module{ + app.NewModule(), + }, + Controllers: []module.ControllerDef{{ + Name: "HealthController", + Build: func(_ module.Resolver) (any, error) { + return &HealthController{}, nil + }, + }}, + } +} + +func main() { + app, err := kernel.Bootstrap(&RootModule{}) + if err != nil { + log.Fatalf("bootstrap: %v", err) + } + + router := mkhttp.NewRouter() + if err := mkhttp.RegisterRoutes(mkhttp.AsRouter(router), app.Controllers); err != nil { + log.Fatalf("routes: %v", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + addr := ":8080" + srv := &http.Server{Addr: addr, Handler: router} + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("shutdown: %v", err) + } + }() + + log.Printf("Server starting on http://localhost%s", addr) + log.Println("Try: curl http://localhost:8080/health") + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("serve: %v", err) + } +} diff --git a/examples/hello-sqlite/docker-compose.yml b/examples/hello-sqlite/docker-compose.yml new file mode 100644 index 0000000..ce3832f --- /dev/null +++ b/examples/hello-sqlite/docker-compose.yml @@ -0,0 +1,16 @@ +services: + app: + build: + context: ../.. + dockerfile: examples/hello-sqlite/Dockerfile + environment: + HTTP_ADDR: ":8080" + SQLITE_PATH: "/data/app.db" + SQLITE_CONNECT_TIMEOUT: "0" + ports: + - "8080:8080" + volumes: + - sqlite_data:/data + +volumes: + sqlite_data: diff --git a/examples/hello-sqlite/go.mod b/examples/hello-sqlite/go.mod new file mode 100644 index 0000000..ce961f2 --- /dev/null +++ b/examples/hello-sqlite/go.mod @@ -0,0 +1,12 @@ +module github.com/go-modkit/modkit/examples/hello-sqlite + +go 1.25.7 + +require ( + github.com/go-modkit/modkit v0.0.0 + github.com/mattn/go-sqlite3 v1.14.22 +) + +require github.com/go-chi/chi/v5 v5.2.4 // indirect + +replace github.com/go-modkit/modkit => ../.. diff --git a/examples/hello-sqlite/go.sum b/examples/hello-sqlite/go.sum new file mode 100644 index 0000000..487fcd1 --- /dev/null +++ b/examples/hello-sqlite/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= +github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/hello-sqlite/internal/app/module.go b/examples/hello-sqlite/internal/app/module.go new file mode 100644 index 0000000..1a37f5c --- /dev/null +++ b/examples/hello-sqlite/internal/app/module.go @@ -0,0 +1,21 @@ +package app + +import ( + "github.com/go-modkit/modkit/modkit/data/sqlite" + "github.com/go-modkit/modkit/modkit/module" +) + +type Module struct{} + +func NewModule() module.Module { + return &Module{} +} + +func (m *Module) Definition() module.ModuleDef { + return module.ModuleDef{ + Name: "app", + Imports: []module.Module{ + sqlite.NewModule(sqlite.Options{}), + }, + } +} diff --git a/examples/hello-sqlite/internal/app/module_test.go b/examples/hello-sqlite/internal/app/module_test.go new file mode 100644 index 0000000..e9b3113 --- /dev/null +++ b/examples/hello-sqlite/internal/app/module_test.go @@ -0,0 +1,14 @@ +package app + +import "testing" + +func TestModuleDefinition(t *testing.T) { + def := NewModule().Definition() + + if def.Name != "app" { + t.Fatalf("expected name=app, got %q", def.Name) + } + if len(def.Imports) != 1 { + t.Fatalf("expected 1 import, got %d", len(def.Imports)) + } +} diff --git a/examples/hello-sqlite/internal/smoke/smoke_test.go b/examples/hello-sqlite/internal/smoke/smoke_test.go new file mode 100644 index 0000000..2907649 --- /dev/null +++ b/examples/hello-sqlite/internal/smoke/smoke_test.go @@ -0,0 +1,61 @@ +package smoke + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" + + "github.com/go-modkit/modkit/examples/hello-sqlite/internal/app" + "github.com/go-modkit/modkit/modkit/data/sqlmodule" + "github.com/go-modkit/modkit/modkit/testkit" +) + +func TestSmoke_SQLite_FileBacked(t *testing.T) { + path := filepath.Join(t.TempDir(), "app.db") + t.Setenv("SQLITE_PATH", path) + t.Setenv("SQLITE_CONNECT_TIMEOUT", "2s") + + h := testkit.New(t, app.NewModule()) + db := testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + dialect := testkit.Get[sqlmodule.Dialect](t, h, sqlmodule.TokenDialect) + if dialect != sqlmodule.DialectSQLite { + t.Fatalf("unexpected dialect: %q", dialect) + } + + roundTripSQLite(t, db) +} + +func TestSmoke_SQLite_InMemory(t *testing.T) { + t.Setenv("SQLITE_PATH", "file:memdb1?mode=memory&cache=shared") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "2s") + + h := testkit.New(t, app.NewModule()) + db := testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + + roundTripSQLite(t, db) +} + +func roundTripSQLite(t *testing.T, db *sql.DB) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`); err != nil { + t.Fatalf("create table: %v", err) + } + if _, err := db.ExecContext(ctx, `INSERT OR REPLACE INTO users (id, name) VALUES (1, 'Ada')`); err != nil { + t.Fatalf("insert: %v", err) + } + var name string + if err := db.QueryRowContext(ctx, `SELECT name FROM users WHERE id = 1`).Scan(&name); err != nil { + t.Fatalf("select: %v", err) + } + if name != "Ada" { + t.Fatalf("unexpected name: %q", name) + } +} diff --git a/go.work b/go.work index b250d8c..8ae9271 100644 --- a/go.work +++ b/go.work @@ -2,6 +2,8 @@ go 1.25.7 use ( . + ./examples/hello-postgres + ./examples/hello-sqlite ./examples/hello-mysql ./examples/hello-simple ) diff --git a/go.work.sum b/go.work.sum index 310fbcf..afc72cf 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,3 +1,4 @@ +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w= @@ -11,6 +12,7 @@ cloud.google.com/go/compute v1.6.1 h1:2sMmt8prCn7DPaG4Pmh0N3Inmc8cT8ae5k1M6VJ9Wq cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= cloud.google.com/go/datastore v1.1.0 h1:/May9ojXjRkPBNVrq+oWLqmWCkr4OU5uRY29bu0mRyQ= cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw= cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= @@ -22,6 +24,7 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9 h1:VpgP7xuJadIUu github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 h1:59MxjQVfjXsBpLy+dbd2/ELV5ofnUkUZBvWSC85sheA= github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0/go.mod h1:OahwfttHWG6eJ0clwcfBAHoDI6X/LV/15hx/wlMZSrU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= github.com/Masterminds/goutils v1.1.0 h1:zukEsf/1JZwCMgHiK3GZftabmxiCw4apj3a28RPBiVg= github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -38,6 +41,7 @@ github.com/Rhymond/go-money v1.0.15 h1:rdcIcO8FxCqEwBSt5VZf4hLMfovtcDIiY5/cQWE+7 github.com/Rhymond/go-money v1.0.15/go.mod h1:iHvCuIvitxu2JIlAlhF0g9jHqjRSr+rpdOs7Omqlupg= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/go-metrics v0.3.10 h1:FR+drcQStOe+32sYyJYyZ7FIdgoGGBnwLl+flodp8Uo= github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310 h1:BUAU3CGlLvorLI26FmByPp2eC2qla6E1Tw+scpcg/to= @@ -64,6 +68,7 @@ github.com/cilium/ebpf v0.9.1 h1:64sn2K3UKw8NbP/blsixRpF3nXuyhz/VjRlRzvlBRu4= github.com/cilium/ebpf v0.9.1/go.mod h1:+OhNOIXx/Fnu1IE8bJz2dzOA+VSfyTfdNUVdlQnxUFY= github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f h1:WBZRG4aNOuI15bLRrCgN8fCq8E5Xuty6jGbmSNEvSsU= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/containerd/aufs v1.0.0 h1:2oeJiwX5HstO7shSrPZjrohJZLzK36wvpdmzDRkL/LY= github.com/containerd/aufs v1.0.0/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU= github.com/containerd/btrfs/v2 v2.0.0 h1:FN4wsx7KQrYoLXN7uLP0vBV4oVWHOIKDRQ1G2Z0oL5M= @@ -125,7 +130,11 @@ github.com/dromara/carbon/v2 v2.6.15/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5 github.com/emicklei/go-restful/v3 v3.10.1 h1:rc42Y5YTp7Am7CS630D7JmhRjq4UlEUuEKfrDac4bSQ= github.com/emicklei/go-restful/v3 v3.10.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.4 h1:rEvIZUSZ3fx39WIi3JkQqQBitGwpELBIYWeBVh6wn+E= +github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0 h1:EQciDnbrYxy13PgWoY8AqoxGiPrpgBZ1R8UNe3ddc+A= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= @@ -134,6 +143,7 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I= github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= github.com/go-kit/kit v0.9.0 h1:wDJmvq38kDhkVxi50ni9ykkdUr1PKgqKOoi01fa0Mdk= github.com/go-kit/log v0.1.0 h1:DGJh0Sm43HbOeYDNnVZFl8BvcYVvjD5bqYJvp0REbwQ= github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4= @@ -145,6 +155,7 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= @@ -226,9 +237,8 @@ github.com/klauspost/cpuid/v2 v2.0.4 h1:g0I61F2K2DjRHz1cnxlkNSBIaePVoJIjjnHui8QH github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 h1:T+h1c/A9Gawja4Y9mFVWj2vyii2bbUNDw3kt9VxK2EY= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -277,6 +287,8 @@ github.com/otiai10/curr v1.0.0 h1:TJIWdbX0B+kpNagQrjgq8bCMrbhiuX73M2XwgtDMoOI= github.com/otiai10/mint v1.3.1 h1:BCmzIS3n71sGfHB5NMNDB3lHYPz8fWSkCAErHed//qc= github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d h1:CdDQnGF8Nq9ocOS/xlSptM1N3BbrA6/kmaep5ggwaIA= github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d/go.mod h1:3OzsM7FXDQlpCiw2j81fOmAwQLnZnLGXVKUzeKQXIAw= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/posener/complete v1.1.1 h1:ccV59UEOTzVDnDUEFdT95ZzHVZ+5+158q8+SJb2QV5w= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw= @@ -289,6 +301,8 @@ github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5 github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= github.com/quasilyte/go-ruleguard/rules v0.0.0-20211022131956-028d6511ab71 h1:CNooiryw5aisadVfzneSZPswRWvnVW8hF1bS/vo8ReI= github.com/quasilyte/go-ruleguard/rules v0.0.0-20211022131956-028d6511ab71/go.mod h1:4cgAphtvu7Ftv7vOT2ZOYhC6CvBxZixcasr8qIOTA50= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= @@ -302,6 +316,7 @@ github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041 h1:llrF3Fs4018ePo github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 h1:pnnLyeX7o/5aX8qUQ69P/mLojDqwda8hFOCBTmP/6hw= github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6/go.mod h1:39R/xuhNgVhi+K0/zst4TLrJrVmbm6LVgl4A0+ZFS5M= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= @@ -332,6 +347,7 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/yuin/goldmark v1.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ= go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= go.etcd.io/etcd/api/v3 v3.5.4 h1:OHVyt3TopwtUQ2GKdd5wu3PmmipR4FTwCqoEjSyRdIc= @@ -348,6 +364,7 @@ go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.45.0 h1:RsQi0qJ2imFfCvZabqzM9cNXBG8k6gXMv1A0cXRmH6A= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.45.0/go.mod h1:vsh3ySueQCiKPxFLvjWC4Z135gIa34TQ/NSqkDTZYUM= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= @@ -355,10 +372,14 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.5 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 h1:3d+S281UTjM+AbF31XSOYn1qXn3BgIdWl8HNEpx08Jk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0/go.mod h1:0+KuTDyKL4gjKCF75pHOX4wuzYDUZYfAQdSu43o+Z2I= go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= @@ -366,13 +387,21 @@ golang.org/x/lint v0.0.0-20200302205851-738671d3881b h1:Wh+f8QHJXR411sJR8/vRBTZ7 golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU= golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk= golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= @@ -380,6 +409,7 @@ golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df h1:5Pf6pFKu98ODmgnpvkJ3kFUOQGGLIzLIkbzUHp47618= golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.223.0 h1:JUTaWEriXmEy5AhvdMgksGGPEFsYfUKaPEYXd4c3Wvc= google.golang.org/api v0.223.0/go.mod h1:C+RS7Z+dDwds2b+zoAk5hN/eSfsiCn0UDrYof/M4d2M= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= @@ -393,6 +423,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= diff --git a/internal/cli/ast/modify.go b/internal/cli/ast/modify.go index b5a9be8..1807914 100644 --- a/internal/cli/ast/modify.go +++ b/internal/cli/ast/modify.go @@ -35,8 +35,8 @@ func (e *ProviderError) Unwrap() error { // Common errors var ( - ErrDefinitionNotFound = errors.New("Definition method not found") - ErrProvidersNotFound = errors.New("Providers field not found in Definition") + ErrDefinitionNotFound = errors.New("definition method not found") + ErrProvidersNotFound = errors.New("providers field not found in definition") ErrTokenExists = errors.New("provider token already exists") ) @@ -263,7 +263,7 @@ func (e *ControllerError) Unwrap() error { } // ErrControllersNotFound is returned when Controllers field is not found in Definition -var ErrControllersNotFound = errors.New("Controllers field not found in Definition") +var ErrControllersNotFound = errors.New("controllers field not found in definition") // AddController registers a new controller in the module definition func AddController(filePath, controllerName, buildFunc string) error { diff --git a/internal/cli/ast/modify_test.go b/internal/cli/ast/modify_test.go index c5caab9..5710001 100644 --- a/internal/cli/ast/modify_test.go +++ b/internal/cli/ast/modify_test.go @@ -38,7 +38,7 @@ func (m *Module) Definition() module.ModuleDef { t.Fatalf("AddProvider failed: %v", err) } - b, err := os.ReadFile(file) + b, err := os.ReadFile(file) //nolint:gosec if err != nil { t.Fatal(err) } @@ -178,7 +178,7 @@ func (m *Module) Definition() module.ModuleDef { t.Fatalf("Duplicate AddProvider should succeed idempotently: %v", err) } - b, err := os.ReadFile(file) + b, err := os.ReadFile(file) //nolint:gosec if err != nil { t.Fatal(err) } @@ -220,7 +220,7 @@ func (m *Module) Definition() module.ModuleDef { t.Fatalf("AddController failed: %v", err) } - b, err := os.ReadFile(file) + b, err := os.ReadFile(file) //nolint:gosec if err != nil { t.Fatal(err) } @@ -266,7 +266,7 @@ func (m *Module) Definition() module.ModuleDef { t.Fatalf("Duplicate AddController should succeed idempotently: %v", err) } - b, err := os.ReadFile(file) + b, err := os.ReadFile(file) //nolint:gosec if err != nil { t.Fatal(err) } @@ -555,10 +555,10 @@ func (m *Module) Definition() module.ModuleDef { t.Fatal(err) } - if err := os.Chmod(moduleDir, 0o500); err != nil { + if err := os.Chmod(moduleDir, 0o500); err != nil { //nolint:gosec t.Fatal(err) } - t.Cleanup(func() { _ = os.Chmod(moduleDir, 0o750) }) + t.Cleanup(func() { _ = os.Chmod(moduleDir, 0o750) }) //nolint:gosec err := AddProvider(file, "users.auth", "buildAuth") if err == nil { @@ -601,10 +601,10 @@ func (m *Module) Definition() module.ModuleDef { t.Fatal(err) } - if err := os.Chmod(moduleDir, 0o500); err != nil { + if err := os.Chmod(moduleDir, 0o500); err != nil { //nolint:gosec t.Fatal(err) } - t.Cleanup(func() { _ = os.Chmod(moduleDir, 0o750) }) + t.Cleanup(func() { _ = os.Chmod(moduleDir, 0o750) }) //nolint:gosec err := AddController(file, "UsersController", "NewUsersController") if err == nil { diff --git a/internal/cli/cmd/naming.go b/internal/cli/cmd/naming.go index c80bfda..41ea71d 100644 --- a/internal/cli/cmd/naming.go +++ b/internal/cli/cmd/naming.go @@ -66,7 +66,7 @@ func validateScaffoldName(value, label string) error { return fmt.Errorf("invalid %s: %q", label, value) } for _, r := range value { - if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_') { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' { return fmt.Errorf("invalid %s: %q", label, value) } } diff --git a/internal/cli/cmd/new_app_test.go b/internal/cli/cmd/new_app_test.go index 4e4afe6..5453c3b 100644 --- a/internal/cli/cmd/new_app_test.go +++ b/internal/cli/cmd/new_app_test.go @@ -61,7 +61,7 @@ func TestCreateNewApp(t *testing.T) { shim = filepath.Join(binDir, "go.bat") content = "@echo off\r\nexit /b 0\r\n" } - if err := os.WriteFile(shim, []byte(content), 0o755); err != nil { + if err := os.WriteFile(shim, []byte(content), 0o755); err != nil { //nolint:gosec t.Fatal(err) } @@ -79,7 +79,7 @@ func TestCreateNewApp(t *testing.T) { t.Fatalf("expected go.mod, got %v", err) } - modBytes, err := os.ReadFile(filepath.Join(tmp, "demo", "go.mod")) + modBytes, err := os.ReadFile(filepath.Join(tmp, "demo", "go.mod")) //nolint:gosec if err != nil { t.Fatal(err) } @@ -194,7 +194,7 @@ func TestCreateNewAppExistingEmptyDirectory(t *testing.T) { shim = filepath.Join(binDir, "go.bat") content = "@echo off\r\nexit /b 0\r\n" } - if err := os.WriteFile(shim, []byte(content), 0o755); err != nil { + if err := os.WriteFile(shim, []byte(content), 0o755); err != nil { //nolint:gosec t.Fatal(err) } oldPath := os.Getenv("PATH") @@ -262,7 +262,7 @@ func TestCreateNewAppRunE(t *testing.T) { shim = filepath.Join(binDir, "go.bat") content = "@echo off\r\nexit /b 0\r\n" } - if err := os.WriteFile(shim, []byte(content), 0o755); err != nil { + if err := os.WriteFile(shim, []byte(content), 0o755); err != nil { //nolint:gosec t.Fatal(err) } oldPath := os.Getenv("PATH") diff --git a/internal/cli/cmd/new_controller_test.go b/internal/cli/cmd/new_controller_test.go index 87ddff6..f495606 100644 --- a/internal/cli/cmd/new_controller_test.go +++ b/internal/cli/cmd/new_controller_test.go @@ -43,7 +43,7 @@ func (m *UserServiceModule) Definition() module.ModuleDef { t.Fatalf("createNewController failed: %v", err) } - b, err := os.ReadFile(filepath.Join(moduleDir, "auth_controller.go")) + b, err := os.ReadFile(filepath.Join(moduleDir, "auth_controller.go")) //nolint:gosec if err != nil { t.Fatal(err) } @@ -252,10 +252,10 @@ func TestCreateNewControllerCreateFileFailure(t *testing.T) { if err := os.WriteFile(filepath.Join(moduleDir, "module.go"), []byte("package users\n"), 0o600); err != nil { t.Fatal(err) } - if err := os.Chmod(moduleDir, 0o500); err != nil { + if err := os.Chmod(moduleDir, 0o500); err != nil { //nolint:gosec t.Fatal(err) } - t.Cleanup(func() { _ = os.Chmod(moduleDir, 0o750) }) + t.Cleanup(func() { _ = os.Chmod(moduleDir, 0o750) }) //nolint:gosec if err := createNewController("auth", "users"); err == nil { t.Fatal("expected error when controller file cannot be created") diff --git a/internal/cli/cmd/new_module_test.go b/internal/cli/cmd/new_module_test.go index aabf190..7244857 100644 --- a/internal/cli/cmd/new_module_test.go +++ b/internal/cli/cmd/new_module_test.go @@ -22,7 +22,7 @@ func TestCreateNewModule(t *testing.T) { t.Fatalf("createNewModule failed: %v", err) } - b, err := os.ReadFile(filepath.Join(tmp, "internal", "modules", "user-service", "module.go")) + b, err := os.ReadFile(filepath.Join(tmp, "internal", "modules", "user-service", "module.go")) //nolint:gosec if err != nil { t.Fatal(err) } diff --git a/internal/cli/cmd/new_provider_test.go b/internal/cli/cmd/new_provider_test.go index 543b32d..3045115 100644 --- a/internal/cli/cmd/new_provider_test.go +++ b/internal/cli/cmd/new_provider_test.go @@ -84,7 +84,7 @@ func (m *UserServiceModule) Definition() module.ModuleDef { t.Fatalf("createNewProvider failed: %v", err) } - b, err := os.ReadFile(filepath.Join(moduleDir, "auth.go")) + b, err := os.ReadFile(filepath.Join(moduleDir, "auth.go")) //nolint:gosec if err != nil { t.Fatal(err) } @@ -312,7 +312,7 @@ func (m *UsersModule) Definition() module.ModuleDef { t.Fatalf("createNewProvider failed: %v", err) } - b, err := os.ReadFile(modulePath) + b, err := os.ReadFile(modulePath) //nolint:gosec if err != nil { t.Fatal(err) } @@ -340,10 +340,10 @@ func TestCreateNewProviderCreateFileFailure(t *testing.T) { if err := os.WriteFile(filepath.Join(moduleDir, "module.go"), []byte("package users\n"), 0o600); err != nil { t.Fatal(err) } - if err := os.Chmod(moduleDir, 0o500); err != nil { + if err := os.Chmod(moduleDir, 0o500); err != nil { //nolint:gosec t.Fatal(err) } - t.Cleanup(func() { _ = os.Chmod(moduleDir, 0o750) }) + t.Cleanup(func() { _ = os.Chmod(moduleDir, 0o750) }) //nolint:gosec if err := createNewProvider("auth", "users"); err == nil { t.Fatal("expected error when provider file cannot be created") diff --git a/modkit/config/module_test.go b/modkit/config/module_test.go index 0784110..019f927 100644 --- a/modkit/config/module_test.go +++ b/modkit/config/module_test.go @@ -42,7 +42,7 @@ func mod( } func TestWithTyped_DefaultAndParse(t *testing.T) { - const token module.Token = "config.jwt_ttl" + const token module.Token = "config.jwt_ttl" //nolint:gosec def := 1 * time.Hour cfgModule := config.NewModule( @@ -76,7 +76,7 @@ func TestWithTyped_DefaultAndParse(t *testing.T) { } func TestWithTyped_UsesDefaultWhenUnset(t *testing.T) { - const token module.Token = "config.http_addr" + const token module.Token = "config.http_addr" //nolint:gosec def := ":8080" cfgModule := config.NewModule( @@ -105,7 +105,7 @@ func TestWithTyped_UsesDefaultWhenUnset(t *testing.T) { } func TestWithTyped_OptionalUnsetReturnsZeroWithoutParsing(t *testing.T) { - const token module.Token = "config.optional_int" + const token module.Token = "config.optional_int" //nolint:gosec called := false cfgModule := config.NewModule( @@ -145,7 +145,7 @@ func TestWithTyped_OptionalUnsetReturnsZeroWithoutParsing(t *testing.T) { } func TestWithTyped_MissingRequired(t *testing.T) { - const token module.Token = "config.jwt_secret" + const token module.Token = "config.jwt_secret" //nolint:gosec cfgModule := config.NewModule( config.WithSource(mapSource{}), @@ -187,7 +187,7 @@ func TestWithTyped_MissingRequired(t *testing.T) { } func TestWithTyped_ParseError(t *testing.T) { - const token module.Token = "config.rate_limit_burst" + const token module.Token = "config.rate_limit_burst" //nolint:gosec cfgModule := config.NewModule( config.WithSource(mapSource{"RATE_LIMIT_BURST": "NaN"}), @@ -223,7 +223,7 @@ func TestWithTyped_ParseError(t *testing.T) { func TestWithTyped_InvalidSpec(t *testing.T) { t.Run("empty key", func(t *testing.T) { - const token module.Token = "config.foo" + const token module.Token = "config.foo" //nolint:gosec cfgModule := config.NewModule( config.WithSource(mapSource{"X": "1"}), config.WithTyped(token, config.ValueSpec[int]{ @@ -276,7 +276,7 @@ func TestWithTyped_InvalidSpec(t *testing.T) { } func TestWithTyped_SensitiveErrorDoesNotLeakValue(t *testing.T) { - const token module.Token = "config.jwt_secret" + const token module.Token = "config.jwt_secret" //nolint:gosec cfgModule := config.NewModule( config.WithSource(mapSource{"JWT_SECRET": "super-secret-value"}), @@ -305,7 +305,7 @@ func TestWithTyped_SensitiveErrorDoesNotLeakValue(t *testing.T) { } func TestWithSourceNil(t *testing.T) { - const token module.Token = "config.foo" + const token module.Token = "config.foo" //nolint:gosec cfgModule := config.NewModule( config.WithSource(nil), @@ -334,7 +334,7 @@ func TestWithSourceNil(t *testing.T) { } func TestNoReflectionMagic_CustomParser(t *testing.T) { - const token module.Token = "config.custom" + const token module.Token = "config.custom" //nolint:gosec parseCustom := func(raw string) (string, error) { if raw != "expected" { diff --git a/modkit/data/postgres/cleanup.go b/modkit/data/postgres/cleanup.go new file mode 100644 index 0000000..90456cd --- /dev/null +++ b/modkit/data/postgres/cleanup.go @@ -0,0 +1,21 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" +) + +// CleanupDB closes a DB handle if present. +func CleanupDB(ctx context.Context, db *sql.DB) error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("cleanup db: %w", err) + } + if db == nil { + return nil + } + if err := db.Close(); err != nil { + return fmt.Errorf("cleanup db: %w", err) + } + return nil +} diff --git a/modkit/data/postgres/cleanup_test.go b/modkit/data/postgres/cleanup_test.go new file mode 100644 index 0000000..144a471 --- /dev/null +++ b/modkit/data/postgres/cleanup_test.go @@ -0,0 +1,106 @@ +package postgres + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "strings" + "sync" + "testing" +) + +const cleanupDriverName = "postgres-cleanup-driver" + +var ( + cleanupOnce sync.Once + cleanupDrv = &cleanupDriver{} +) + +type cleanupDriver struct { + mu sync.Mutex + closeErr error +} + +func (d *cleanupDriver) SetCloseErr(err error) { + d.mu.Lock() + defer d.mu.Unlock() + d.closeErr = err +} + +func (d *cleanupDriver) getCloseErr() error { + d.mu.Lock() + defer d.mu.Unlock() + return d.closeErr +} + +func (d *cleanupDriver) Open(_ string) (driver.Conn, error) { + return &cleanupConn{d: d}, nil +} + +type cleanupConn struct { + d *cleanupDriver +} + +func (c *cleanupConn) Prepare(_ string) (driver.Stmt, error) { + return nil, errors.New("not implemented") +} + +func (c *cleanupConn) Close() error { + return c.d.getCloseErr() +} + +func (c *cleanupConn) Begin() (driver.Tx, error) { + return nil, errors.New("not implemented") +} + +func (c *cleanupConn) Ping(_ context.Context) error { + return nil +} + +func registerCleanupDriver() { + cleanupOnce.Do(func() { + sql.Register(cleanupDriverName, cleanupDrv) + }) +} + +func TestCleanupDBWrapsContextError(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := CleanupDB(ctx, nil) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected wrapped context error") + } + if !strings.Contains(err.Error(), "cleanup") { + t.Fatalf("expected cleanup context in error, got %q", err.Error()) + } +} + +func TestCleanupDBWrapsCloseError(t *testing.T) { + registerCleanupDriver() + closeErr := errors.New("close failed") + cleanupDrv.SetCloseErr(closeErr) + + db, err := sql.Open(cleanupDriverName, "test") + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.PingContext(context.Background()); err != nil { + t.Fatalf("ping db: %v", err) + } + + err = CleanupDB(context.Background(), db) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, closeErr) { + t.Fatalf("expected wrapped close error") + } + if !strings.Contains(err.Error(), "cleanup") { + t.Fatalf("expected cleanup context in error, got %q", err.Error()) + } +} diff --git a/modkit/data/postgres/config.go b/modkit/data/postgres/config.go new file mode 100644 index 0000000..9193b59 --- /dev/null +++ b/modkit/data/postgres/config.go @@ -0,0 +1,62 @@ +package postgres + +import ( + "time" + + "github.com/go-modkit/modkit/modkit/config" + "github.com/go-modkit/modkit/modkit/module" +) + +// DefaultConfigModule provides Postgres configuration from environment variables. +// +// Required: +// - POSTGRES_DSN +// +// Optional: +// - POSTGRES_MAX_OPEN_CONNS +// - POSTGRES_MAX_IDLE_CONNS +// - POSTGRES_CONN_MAX_LIFETIME +// - POSTGRES_CONNECT_TIMEOUT (default 0; disables provider ping) +func DefaultConfigModule() module.Module { + return configModule("") +} + +func configModule(name string) module.Module { + return config.NewModule( + config.WithModuleName(moduleName(name)+".config"), + config.WithTyped(TokenDSN, config.ValueSpec[string]{ + Key: "POSTGRES_DSN", + Required: true, + Sensitive: true, + Description: "Postgres DSN.", + Parse: config.ParseString, + }, true), + config.WithTyped(TokenMaxOpenConns, config.ValueSpec[int]{ + Key: "POSTGRES_MAX_OPEN_CONNS", + Description: "Maximum open connections for the DB pool.", + Parse: config.ParseInt, + }, true), + config.WithTyped(TokenMaxIdleConns, config.ValueSpec[int]{ + Key: "POSTGRES_MAX_IDLE_CONNS", + Description: "Maximum idle connections for the DB pool.", + Parse: config.ParseInt, + }, true), + config.WithTyped(tokenMaxIdleConnsSet, config.ValueSpec[bool]{ + Key: "POSTGRES_MAX_IDLE_CONNS", + Description: "Whether POSTGRES_MAX_IDLE_CONNS is explicitly set.", + Parse: func(string) (bool, error) { + return true, nil + }, + }, true), + config.WithTyped(TokenConnMaxLifetime, config.ValueSpec[time.Duration]{ + Key: "POSTGRES_CONN_MAX_LIFETIME", + Description: "Maximum amount of time a connection may be reused.", + Parse: config.ParseDuration, + }, true), + config.WithTyped(TokenConnectTimeout, config.ValueSpec[time.Duration]{ + Key: "POSTGRES_CONNECT_TIMEOUT", + Description: "Optional ping timeout on provider build. 0 disables ping.", + Parse: config.ParseDuration, + }, true), + ) +} diff --git a/modkit/data/postgres/config_test.go b/modkit/data/postgres/config_test.go new file mode 100644 index 0000000..b3b06b6 --- /dev/null +++ b/modkit/data/postgres/config_test.go @@ -0,0 +1,11 @@ +package postgres + +import "testing" + +func TestDefaultConfigModuleReturnsNewInstance(t *testing.T) { + first := DefaultConfigModule() + second := DefaultConfigModule() + if first == second { + t.Fatal("expected DefaultConfigModule to return a new module instance") + } +} diff --git a/modkit/data/postgres/doc.go b/modkit/data/postgres/doc.go new file mode 100644 index 0000000..405fbd0 --- /dev/null +++ b/modkit/data/postgres/doc.go @@ -0,0 +1,6 @@ +// Package postgres provides a Postgres-backed SQL module. +// +// It exports shared SQL contract tokens from modkit/data/sqlmodule: +// - sqlmodule.TokenDB (*sql.DB) +// - sqlmodule.TokenDialect (sqlmodule.Dialect) +package postgres diff --git a/modkit/data/postgres/errors.go b/modkit/data/postgres/errors.go new file mode 100644 index 0000000..c76a843 --- /dev/null +++ b/modkit/data/postgres/errors.go @@ -0,0 +1,20 @@ +package postgres + +import "github.com/go-modkit/modkit/modkit/data/sqlmodule" + +// BuildStage identifies the provider build step. +type BuildStage = sqlmodule.BuildStage + +const ( + // StageResolveConfig indicates a failure resolving config tokens. + StageResolveConfig = sqlmodule.StageResolveConfig + // StageInvalidConfig indicates invalid config values (e.g. negative settings). + StageInvalidConfig = sqlmodule.StageInvalidConfig + // StageOpen indicates a failure opening the database handle. + StageOpen = sqlmodule.StageOpen + // StagePing indicates a failure pinging the database. + StagePing = sqlmodule.StagePing +) + +// BuildError is returned when the Postgres provider fails to build. +type BuildError = sqlmodule.BuildError diff --git a/modkit/data/postgres/module.go b/modkit/data/postgres/module.go new file mode 100644 index 0000000..71a2447 --- /dev/null +++ b/modkit/data/postgres/module.go @@ -0,0 +1,166 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/go-modkit/modkit/modkit/data/sqlmodule" + "github.com/go-modkit/modkit/modkit/module" +) + +const ( + driverName = "postgres" + moduleNameBase = "data.postgres" +) + +// Options configures a Postgres provider module. +type Options struct { + // Config provides Postgres configuration tokens (DSN, pool settings, ping timeout). + Config module.Module + // Name namespaces exported SQL contract tokens via sqlmodule.NamedTokens. + Name string +} + +// Module provides a Postgres-backed *sql.DB and dialect token. +type Module struct { + opts Options +} + +// NewModule constructs a Postgres provider module. +func NewModule(opts Options) module.Module { + if opts.Config == nil { + opts.Config = configModule(opts.Name) + } + return &Module{opts: opts} +} + +// Definition returns the module definition for graph construction. +func (m *Module) Definition() module.ModuleDef { + configMod := m.opts.Config + if configMod == nil { + configMod = configModule(m.opts.Name) + } + + toks, err := sqlmodule.NamedTokens(m.opts.Name) + if err != nil { + return invalidModuleDef(err) + } + + var db *sql.DB + return module.ModuleDef{ + Name: moduleName(m.opts.Name), + Imports: []module.Module{configMod}, + Providers: []module.ProviderDef{ + { + Token: toks.DB, + Build: func(r module.Resolver) (any, error) { + built, buildErr := buildDB(r, toks.DB) + if buildErr != nil { + return nil, buildErr + } + db = built + return db, nil + }, + Cleanup: func(ctx context.Context) error { + return CleanupDB(ctx, db) + }, + }, + { + Token: toks.Dialect, + Build: func(_ module.Resolver) (any, error) { + return sqlmodule.DialectPostgres, nil + }, + }, + }, + Exports: []module.Token{toks.DB, toks.Dialect}, + } +} + +func moduleName(name string) string { + if name == "" { + return moduleNameBase + } + return moduleNameBase + "." + name +} + +func invalidModuleDef(err error) module.ModuleDef { + return module.ModuleDef{ + Name: moduleNameBase + ".invalid", + Controllers: []module.ControllerDef{{ + Name: "InvalidPostgresModule", + Build: func(_ module.Resolver) (any, error) { + return nil, err + }, + }}, + } +} + +func buildDB(r module.Resolver, dbToken module.Token) (*sql.DB, error) { + dsn, err := module.Get[string](r, TokenDSN) + if err != nil { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("dsn: %w", err)} + } + maxOpen, err := module.Get[int](r, TokenMaxOpenConns) + if err != nil { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("max_open_conns: %w", err)} + } + maxIdle, err := module.Get[int](r, TokenMaxIdleConns) + if err != nil { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("max_idle_conns: %w", err)} + } + maxIdleSet, err := module.Get[bool](r, tokenMaxIdleConnsSet) + if err != nil { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("max_idle_conns_set: %w", err)} + } + maxLifetime, err := module.Get[time.Duration](r, TokenConnMaxLifetime) + if err != nil { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("conn_max_lifetime: %w", err)} + } + connectTimeout, err := module.Get[time.Duration](r, TokenConnectTimeout) + if err != nil { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("connect_timeout: %w", err)} + } + + if maxOpen < 0 { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("max_open_conns must be >= 0")} + } + if maxIdle < 0 { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("max_idle_conns must be >= 0")} + } + if maxLifetime < 0 { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("conn_max_lifetime must be >= 0")} + } + if connectTimeout < 0 { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("connect_timeout must be >= 0")} + } + + db, err := sql.Open(driverName, dsn) + if err != nil { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageOpen, Err: err} + } + + if maxOpen > 0 { + db.SetMaxOpenConns(maxOpen) + } + if maxIdleSet { + db.SetMaxIdleConns(maxIdle) + } + if maxLifetime > 0 { + db.SetConnMaxLifetime(maxLifetime) + } + + if connectTimeout == 0 { + return db, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), connectTimeout) + defer cancel() + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StagePing, Err: err} + } + + return db, nil +} diff --git a/modkit/data/postgres/module_test.go b/modkit/data/postgres/module_test.go new file mode 100644 index 0000000..aabe380 --- /dev/null +++ b/modkit/data/postgres/module_test.go @@ -0,0 +1,387 @@ +package postgres + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "sync" + "testing" + + "github.com/go-modkit/modkit/modkit/data/sqlmodule" + "github.com/go-modkit/modkit/modkit/kernel" + "github.com/go-modkit/modkit/modkit/module" + "github.com/go-modkit/modkit/modkit/testkit" +) + +var testDrv = &countingDriver{} + +func init() { + sql.Register(driverName, testDrv) +} + +type countingDriver struct { + mu sync.Mutex + openCount int + pingCount int + closeCount int + pingErr error + sawDeadline bool +} + +func (d *countingDriver) Reset() { + d.mu.Lock() + defer d.mu.Unlock() + c := countingDriver{} + d.openCount = c.openCount + d.pingCount = c.pingCount + d.closeCount = c.closeCount + d.pingErr = nil + d.sawDeadline = false +} + +func (d *countingDriver) SetPingErr(err error) { + d.mu.Lock() + defer d.mu.Unlock() + d.pingErr = err +} + +func (d *countingDriver) Snapshot() (open, ping, closed int, sawDeadline bool) { + d.mu.Lock() + defer d.mu.Unlock() + return d.openCount, d.pingCount, d.closeCount, d.sawDeadline +} + +func (d *countingDriver) Open(_ string) (driver.Conn, error) { + d.mu.Lock() + d.openCount++ + d.mu.Unlock() + return &countingConn{d: d}, nil +} + +type countingConn struct { + d *countingDriver +} + +func (c *countingConn) Prepare(_ string) (driver.Stmt, error) { + return nil, errors.New("not implemented") +} + +func (c *countingConn) Close() error { + c.d.mu.Lock() + c.d.closeCount++ + c.d.mu.Unlock() + return nil +} + +func (c *countingConn) Begin() (driver.Tx, error) { + return nil, errors.New("not implemented") +} + +func (c *countingConn) Ping(ctx context.Context) error { + c.d.mu.Lock() + c.d.pingCount++ + if _, ok := ctx.Deadline(); ok { + c.d.sawDeadline = true + } + err := c.d.pingErr + c.d.mu.Unlock() + return err +} + +func TestModuleExportsDialectAndDBTokens(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + dialect := testkit.Get[sqlmodule.Dialect](t, h, sqlmodule.TokenDialect) + if dialect != sqlmodule.DialectPostgres { + t.Fatalf("unexpected dialect: %q", dialect) + } +} + +func TestConnectTimeoutPingBehavior(t *testing.T) { + cases := []struct { + name string + timeout string + wantOpen int + wantOpenNonZero bool + wantPing int + wantDeadline bool + }{ + { + name: "zero timeout skips ping", + timeout: "0", + wantOpen: 0, + wantPing: 0, + }, + { + name: "non-zero timeout pings with deadline", + timeout: "25ms", + wantOpenNonZero: true, + wantPing: 1, + wantDeadline: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", tc.timeout) + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + + open, ping, _, sawDeadline := testDrv.Snapshot() + if tc.wantOpenNonZero { + if open == 0 { + t.Fatalf("expected open>0, got %d", open) + } + } else if open != tc.wantOpen { + t.Fatalf("expected open=%d, got %d", tc.wantOpen, open) + } + if ping != tc.wantPing { + t.Fatalf("expected ping=%d, got %d", tc.wantPing, ping) + } + if tc.wantDeadline != sawDeadline { + t.Fatalf("expected deadline=%v, got %v", tc.wantDeadline, sawDeadline) + } + }) + } +} + +func TestResolveConfigErrorReturnsBuildError(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_MAX_OPEN_CONNS", "nope") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatal("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageResolveConfig { + t.Fatalf("expected stage=%s, got %s", StageResolveConfig, be.Stage) + } +} + +func TestNegativeMaxOpenConnsReturnsInvalidConfig(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_MAX_OPEN_CONNS", "-1") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatal("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageInvalidConfig { + t.Fatalf("expected stage=%s, got %s", StageInvalidConfig, be.Stage) + } +} + +func TestNegativeMaxIdleConnsReturnsInvalidConfig(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_MAX_IDLE_CONNS", "-1") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatal("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageInvalidConfig { + t.Fatalf("expected stage=%s, got %s", StageInvalidConfig, be.Stage) + } +} + +func TestNegativeConnMaxLifetimeReturnsInvalidConfig(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONN_MAX_LIFETIME", "-1s") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatal("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageInvalidConfig { + t.Fatalf("expected stage=%s, got %s", StageInvalidConfig, be.Stage) + } +} + +func TestNegativeConnectTimeoutReturnsInvalidConfig(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "-1s") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatal("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageInvalidConfig { + t.Fatalf("expected stage=%s, got %s", StageInvalidConfig, be.Stage) + } +} + +func TestModuleNameUsesSuffixWhenProvided(t *testing.T) { + if moduleName("") != moduleNameBase { + t.Fatalf("expected base module name") + } + if moduleName("analytics") != moduleNameBase+".analytics" { + t.Fatalf("expected named module suffix") + } +} + +func TestPingFailureReturnsTypedBuildErrorAndClosesDB(t *testing.T) { + testDrv.Reset() + pingErr := errors.New("ping failed") + testDrv.SetPingErr(pingErr) + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "25ms") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatalf("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StagePing { + t.Fatalf("expected stage=%s, got %s", StagePing, be.Stage) + } + if be.Provider != driverName { + t.Fatalf("expected provider=%q, got %q", driverName, be.Provider) + } + if be.Token != sqlmodule.TokenDB { + t.Fatalf("expected token=%q, got %q", sqlmodule.TokenDB, be.Token) + } + if !errors.Is(err, pingErr) { + t.Fatalf("expected error to wrap ping error") + } + + _, _, closed, _ := testDrv.Snapshot() + if closed == 0 { + t.Fatalf("expected ping failure path to close the DB") + } +} + +func TestMaxIdleConnsZeroDisablesIdlePoolWhenExplicit(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "25ms") + t.Setenv("POSTGRES_MAX_IDLE_CONNS", "0") + + h := testkit.New(t, NewModule(Options{})) + db := testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + + stats := db.Stats() + if stats.Idle != 0 { + t.Fatalf("expected idle=0, got %d", stats.Idle) + } +} + +func TestMultiplePostgresInstancesBootstrap(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "0") + + primaryTokens, err := sqlmodule.NamedTokens("primary") + if err != nil { + t.Fatalf("primary tokens: %v", err) + } + analyticsTokens, err := sqlmodule.NamedTokens("analytics") + if err != nil { + t.Fatalf("analytics tokens: %v", err) + } + configMod := DefaultConfigModule() + + root := &multiInstanceRootModule{ + imports: []module.Module{ + NewModule(Options{Name: "primary", Config: configMod}), + NewModule(Options{Name: "analytics", Config: configMod}), + }, + exports: []module.Token{ + primaryTokens.DB, + primaryTokens.Dialect, + analyticsTokens.DB, + analyticsTokens.Dialect, + }, + } + app, err := kernel.Bootstrap(root) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + + if _, err := app.Get(primaryTokens.DB); err != nil { + t.Fatalf("primary db: %v", err) + } + if _, err := app.Get(analyticsTokens.DB); err != nil { + t.Fatalf("analytics db: %v", err) + } +} + +func TestInvalidNameFailsAtBootstrap(t *testing.T) { + root := &multiInstanceRootModule{ + imports: []module.Module{ + NewModule(Options{Name: "bad name"}), + }, + } + _, err := kernel.Bootstrap(root) + if err == nil { + t.Fatal("expected bootstrap error") + } + var invalidNameErr *sqlmodule.InvalidNameError + if !errors.As(err, &invalidNameErr) { + t.Fatalf("expected InvalidNameError, got %T", err) + } +} + +type multiInstanceRootModule struct { + imports []module.Module + exports []module.Token +} + +func (m *multiInstanceRootModule) Definition() module.ModuleDef { + return module.ModuleDef{ + Name: "root", + Imports: m.imports, + Exports: m.exports, + } +} diff --git a/modkit/data/postgres/tokens.go b/modkit/data/postgres/tokens.go new file mode 100644 index 0000000..6ea189f --- /dev/null +++ b/modkit/data/postgres/tokens.go @@ -0,0 +1,17 @@ +package postgres + +import "github.com/go-modkit/modkit/modkit/module" + +const ( + // TokenDSN resolves the Postgres DSN. + TokenDSN module.Token = "postgres.dsn" //nolint:gosec // token name, not credential + // TokenMaxOpenConns resolves the max open connections pool setting. + TokenMaxOpenConns module.Token = "postgres.max_open_conns" //nolint:gosec // token name, not credential + // TokenMaxIdleConns resolves the max idle connections pool setting. + TokenMaxIdleConns module.Token = "postgres.max_idle_conns" //nolint:gosec // token name, not credential + tokenMaxIdleConnsSet module.Token = "postgres.max_idle_conns_set" //nolint:gosec // token name, not credential + // TokenConnMaxLifetime resolves the connection max lifetime pool setting. + TokenConnMaxLifetime module.Token = "postgres.conn_max_lifetime" //nolint:gosec // token name, not credential + // TokenConnectTimeout resolves the optional provider ping timeout. + TokenConnectTimeout module.Token = "postgres.connect_timeout" //nolint:gosec // token name, not credential +) diff --git a/modkit/data/sqlite/cleanup.go b/modkit/data/sqlite/cleanup.go new file mode 100644 index 0000000..992705e --- /dev/null +++ b/modkit/data/sqlite/cleanup.go @@ -0,0 +1,21 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" +) + +// CleanupDB closes a DB handle if present. +func CleanupDB(ctx context.Context, db *sql.DB) error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("cleanup db: %w", err) + } + if db == nil { + return nil + } + if err := db.Close(); err != nil { + return fmt.Errorf("cleanup db: %w", err) + } + return nil +} diff --git a/modkit/data/sqlite/cleanup_test.go b/modkit/data/sqlite/cleanup_test.go new file mode 100644 index 0000000..9014687 --- /dev/null +++ b/modkit/data/sqlite/cleanup_test.go @@ -0,0 +1,106 @@ +package sqlite + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "strings" + "sync" + "testing" +) + +const cleanupDriverName = "sqlite-cleanup-driver" + +var ( + cleanupOnce sync.Once + cleanupDrv = &cleanupDriver{} +) + +type cleanupDriver struct { + mu sync.Mutex + closeErr error +} + +func (d *cleanupDriver) SetCloseErr(err error) { + d.mu.Lock() + defer d.mu.Unlock() + d.closeErr = err +} + +func (d *cleanupDriver) getCloseErr() error { + d.mu.Lock() + defer d.mu.Unlock() + return d.closeErr +} + +func (d *cleanupDriver) Open(_ string) (driver.Conn, error) { + return &cleanupConn{d: d}, nil +} + +type cleanupConn struct { + d *cleanupDriver +} + +func (c *cleanupConn) Prepare(_ string) (driver.Stmt, error) { + return nil, errors.New("not implemented") +} + +func (c *cleanupConn) Close() error { + return c.d.getCloseErr() +} + +func (c *cleanupConn) Begin() (driver.Tx, error) { + return nil, errors.New("not implemented") +} + +func (c *cleanupConn) Ping(_ context.Context) error { + return nil +} + +func registerCleanupDriver() { + cleanupOnce.Do(func() { + sql.Register(cleanupDriverName, cleanupDrv) + }) +} + +func TestCleanupDBWrapsContextError(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := CleanupDB(ctx, nil) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected wrapped context error") + } + if !strings.Contains(err.Error(), "cleanup") { + t.Fatalf("expected cleanup context in error, got %q", err.Error()) + } +} + +func TestCleanupDBWrapsCloseError(t *testing.T) { + registerCleanupDriver() + closeErr := errors.New("close failed") + cleanupDrv.SetCloseErr(closeErr) + + db, err := sql.Open(cleanupDriverName, "test") + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.PingContext(context.Background()); err != nil { + t.Fatalf("ping db: %v", err) + } + + err = CleanupDB(context.Background(), db) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, closeErr) { + t.Fatalf("expected wrapped close error") + } + if !strings.Contains(err.Error(), "cleanup") { + t.Fatalf("expected cleanup context in error, got %q", err.Error()) + } +} diff --git a/modkit/data/sqlite/config.go b/modkit/data/sqlite/config.go new file mode 100644 index 0000000..6ae9237 --- /dev/null +++ b/modkit/data/sqlite/config.go @@ -0,0 +1,48 @@ +package sqlite + +import ( + "time" + + "github.com/go-modkit/modkit/modkit/config" + "github.com/go-modkit/modkit/modkit/module" +) + +// DefaultConfigModule provides SQLite configuration from environment variables. +// +// Required: +// - SQLITE_PATH +// +// Optional: +// - SQLITE_BUSY_TIMEOUT +// - SQLITE_JOURNAL_MODE +// - SQLITE_CONNECT_TIMEOUT (default 0; disables provider ping) +func DefaultConfigModule() module.Module { + return configModule("") +} + +func configModule(name string) module.Module { + return config.NewModule( + config.WithModuleName(moduleName(name)+".config"), + config.WithTyped(TokenPath, config.ValueSpec[string]{ + Key: "SQLITE_PATH", + Required: true, + Description: "SQLite database path or DSN.", + Parse: config.ParseString, + }, true), + config.WithTyped(TokenBusyTimeout, config.ValueSpec[time.Duration]{ + Key: "SQLITE_BUSY_TIMEOUT", + Description: "Optional busy timeout to apply to the DSN.", + Parse: config.ParseDuration, + }, true), + config.WithTyped(TokenJournalMode, config.ValueSpec[string]{ + Key: "SQLITE_JOURNAL_MODE", + Description: "Optional journal mode to apply to the DSN.", + Parse: config.ParseString, + }, true), + config.WithTyped(TokenConnectTimeout, config.ValueSpec[time.Duration]{ + Key: "SQLITE_CONNECT_TIMEOUT", + Description: "Optional ping timeout on provider build. 0 disables ping.", + Parse: config.ParseDuration, + }, true), + ) +} diff --git a/modkit/data/sqlite/config_test.go b/modkit/data/sqlite/config_test.go new file mode 100644 index 0000000..32f7443 --- /dev/null +++ b/modkit/data/sqlite/config_test.go @@ -0,0 +1,11 @@ +package sqlite + +import "testing" + +func TestDefaultConfigModuleReturnsNewInstance(t *testing.T) { + first := DefaultConfigModule() + second := DefaultConfigModule() + if first == second { + t.Fatal("expected DefaultConfigModule to return a new module instance") + } +} diff --git a/modkit/data/sqlite/doc.go b/modkit/data/sqlite/doc.go new file mode 100644 index 0000000..3eef020 --- /dev/null +++ b/modkit/data/sqlite/doc.go @@ -0,0 +1,6 @@ +// Package sqlite provides a SQLite-backed SQL module. +// +// It exports shared SQL contract tokens from modkit/data/sqlmodule: +// - sqlmodule.TokenDB (*sql.DB) +// - sqlmodule.TokenDialect (sqlmodule.Dialect) +package sqlite diff --git a/modkit/data/sqlite/errors.go b/modkit/data/sqlite/errors.go new file mode 100644 index 0000000..744fa30 --- /dev/null +++ b/modkit/data/sqlite/errors.go @@ -0,0 +1,20 @@ +package sqlite + +import "github.com/go-modkit/modkit/modkit/data/sqlmodule" + +// BuildStage identifies the provider build step. +type BuildStage = sqlmodule.BuildStage + +const ( + // StageResolveConfig indicates a failure resolving config tokens. + StageResolveConfig = sqlmodule.StageResolveConfig + // StageInvalidConfig indicates invalid config values (e.g. negative settings). + StageInvalidConfig = sqlmodule.StageInvalidConfig + // StageOpen indicates a failure opening the database handle. + StageOpen = sqlmodule.StageOpen + // StagePing indicates a failure pinging the database. + StagePing = sqlmodule.StagePing +) + +// BuildError is returned when the SQLite provider fails to build. +type BuildError = sqlmodule.BuildError diff --git a/modkit/data/sqlite/module.go b/modkit/data/sqlite/module.go new file mode 100644 index 0000000..3d8128a --- /dev/null +++ b/modkit/data/sqlite/module.go @@ -0,0 +1,184 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "github.com/go-modkit/modkit/modkit/data/sqlmodule" + "github.com/go-modkit/modkit/modkit/module" +) + +const ( + driverName = "sqlite3" + moduleNameBase = "data.sqlite" +) + +var listDrivers = sql.Drivers + +// Options configures a SQLite provider module. +type Options struct { + // Config provides SQLite configuration tokens (path/DSN, DSN options, ping timeout). + Config module.Module + // Name namespaces exported SQL contract tokens via sqlmodule.NamedTokens. + Name string +} + +// Module provides a SQLite-backed *sql.DB and dialect token. +type Module struct { + opts Options +} + +// NewModule constructs a SQLite provider module. +func NewModule(opts Options) module.Module { + if opts.Config == nil { + opts.Config = configModule(opts.Name) + } + return &Module{opts: opts} +} + +// Definition returns the module definition for graph construction. +func (m *Module) Definition() module.ModuleDef { + configMod := m.opts.Config + if configMod == nil { + configMod = configModule(m.opts.Name) + } + + toks, err := sqlmodule.NamedTokens(m.opts.Name) + if err != nil { + return invalidModuleDef(err) + } + + var db *sql.DB + return module.ModuleDef{ + Name: moduleName(m.opts.Name), + Imports: []module.Module{configMod}, + Providers: []module.ProviderDef{ + { + Token: toks.DB, + Build: func(r module.Resolver) (any, error) { + built, buildErr := buildDB(r, toks.DB) + if buildErr != nil { + return nil, buildErr + } + db = built + return db, nil + }, + Cleanup: func(ctx context.Context) error { + return CleanupDB(ctx, db) + }, + }, + { + Token: toks.Dialect, + Build: func(_ module.Resolver) (any, error) { + return sqlmodule.DialectSQLite, nil + }, + }, + }, + Exports: []module.Token{toks.DB, toks.Dialect}, + } +} + +func moduleName(name string) string { + if name == "" { + return moduleNameBase + } + return moduleNameBase + "." + name +} + +func invalidModuleDef(err error) module.ModuleDef { + return module.ModuleDef{ + Name: moduleNameBase + ".invalid", + Controllers: []module.ControllerDef{{ + Name: "InvalidSQLiteModule", + Build: func(_ module.Resolver) (any, error) { + return nil, err + }, + }}, + } +} + +func buildDB(r module.Resolver, dbToken module.Token) (*sql.DB, error) { + path, err := module.Get[string](r, TokenPath) + if err != nil { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("path: %w", err)} + } + busyTimeout, err := module.Get[time.Duration](r, TokenBusyTimeout) + if err != nil { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("busy_timeout: %w", err)} + } + journalMode, err := module.Get[string](r, TokenJournalMode) + if err != nil { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("journal_mode: %w", err)} + } + connectTimeout, err := module.Get[time.Duration](r, TokenConnectTimeout) + if err != nil { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("connect_timeout: %w", err)} + } + + if busyTimeout < 0 { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("busy_timeout must be >= 0")} + } + if connectTimeout < 0 { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("connect_timeout must be >= 0")} + } + + dsn := buildDSN(path, busyTimeout, journalMode) + if !driverRegistered(driverName) { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageOpen, Err: fmt.Errorf("driver %q is not registered", driverName)} + } + db, err := sql.Open(driverName, dsn) + if err != nil { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageOpen, Err: err} + } + + if connectTimeout == 0 { + return db, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), connectTimeout) + defer cancel() + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StagePing, Err: err} + } + + return db, nil +} + +func driverRegistered(name string) bool { + for _, driver := range listDrivers() { + if driver == name { + return true + } + } + return false +} + +func buildDSN(base string, busyTimeout time.Duration, journalMode string) string { + journalMode = strings.TrimSpace(journalMode) + + params := url.Values{} + if busyTimeout > 0 { + params.Set("_busy_timeout", strconv.FormatInt(int64(busyTimeout/time.Millisecond), 10)) + } + if journalMode != "" { + params.Set("_journal_mode", journalMode) + } + if len(params) == 0 { + return base + } + + enc := params.Encode() + if strings.Contains(base, "?") { + if strings.HasSuffix(base, "?") || strings.HasSuffix(base, "&") { + return base + enc + } + return base + "&" + enc + } + return base + "?" + enc +} diff --git a/modkit/data/sqlite/module_test.go b/modkit/data/sqlite/module_test.go new file mode 100644 index 0000000..ae17fab --- /dev/null +++ b/modkit/data/sqlite/module_test.go @@ -0,0 +1,434 @@ +package sqlite + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "net/url" + "strings" + "sync" + "testing" + + "github.com/go-modkit/modkit/modkit/data/sqlmodule" + "github.com/go-modkit/modkit/modkit/kernel" + "github.com/go-modkit/modkit/modkit/module" + "github.com/go-modkit/modkit/modkit/testkit" +) + +var testDrv = &countingDriver{} + +func init() { + sql.Register(driverName, testDrv) +} + +type countingDriver struct { + mu sync.Mutex + openCount int + pingCount int + closeCount int + pingErr error + sawDeadline bool + lastOpenDSN string +} + +func (d *countingDriver) Reset() { + d.mu.Lock() + defer d.mu.Unlock() + c := countingDriver{} + d.openCount = c.openCount + d.pingCount = c.pingCount + d.closeCount = c.closeCount + d.pingErr = nil + d.sawDeadline = false + d.lastOpenDSN = "" +} + +func (d *countingDriver) SetPingErr(err error) { + d.mu.Lock() + defer d.mu.Unlock() + d.pingErr = err +} + +func (d *countingDriver) Snapshot() (open, ping, closed int, sawDeadline bool, lastOpenDSN string) { + d.mu.Lock() + defer d.mu.Unlock() + return d.openCount, d.pingCount, d.closeCount, d.sawDeadline, d.lastOpenDSN +} + +func (d *countingDriver) Open(name string) (driver.Conn, error) { + d.mu.Lock() + d.openCount++ + d.lastOpenDSN = name + d.mu.Unlock() + return &countingConn{d: d}, nil +} + +type countingConn struct { + d *countingDriver +} + +func (c *countingConn) Prepare(_ string) (driver.Stmt, error) { + return nil, errors.New("not implemented") +} + +func (c *countingConn) Close() error { + c.d.mu.Lock() + c.d.closeCount++ + c.d.mu.Unlock() + return nil +} + +func (c *countingConn) Begin() (driver.Tx, error) { + return nil, errors.New("not implemented") +} + +func (c *countingConn) Ping(ctx context.Context) error { + c.d.mu.Lock() + c.d.pingCount++ + if _, ok := ctx.Deadline(); ok { + c.d.sawDeadline = true + } + err := c.d.pingErr + c.d.mu.Unlock() + return err +} + +func TestModuleExportsDialectAndDBTokens(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + dialect := testkit.Get[sqlmodule.Dialect](t, h, sqlmodule.TokenDialect) + if dialect != sqlmodule.DialectSQLite { + t.Fatalf("unexpected dialect: %q", dialect) + } +} + +func TestConnectTimeoutPingBehavior(t *testing.T) { + cases := []struct { + name string + timeout string + wantOpen int + wantOpenNonZero bool + wantPing int + wantDeadline bool + }{ + { + name: "zero timeout skips ping", + timeout: "0", + wantOpen: 0, + wantPing: 0, + }, + { + name: "non-zero timeout pings with deadline", + timeout: "25ms", + wantOpenNonZero: true, + wantPing: 1, + wantDeadline: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_CONNECT_TIMEOUT", tc.timeout) + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + + open, ping, _, sawDeadline, _ := testDrv.Snapshot() + if tc.wantOpenNonZero { + if open == 0 { + t.Fatalf("expected open>0, got %d", open) + } + } else if open != tc.wantOpen { + t.Fatalf("expected open=%d, got %d", tc.wantOpen, open) + } + if ping != tc.wantPing { + t.Fatalf("expected ping=%d, got %d", tc.wantPing, ping) + } + if tc.wantDeadline != sawDeadline { + t.Fatalf("expected deadline=%v, got %v", tc.wantDeadline, sawDeadline) + } + }) + } +} + +func TestResolveConfigErrorReturnsBuildError(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_BUSY_TIMEOUT", "nope") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatal("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageResolveConfig { + t.Fatalf("expected stage=%s, got %s", StageResolveConfig, be.Stage) + } +} + +func TestNegativeBusyTimeoutReturnsInvalidConfig(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_BUSY_TIMEOUT", "-1ms") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatal("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageInvalidConfig { + t.Fatalf("expected stage=%s, got %s", StageInvalidConfig, be.Stage) + } +} + +func TestMissingDriverReturnsBuildError(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "0") + + origDrivers := listDrivers + listDrivers = func() []string { return []string{} } + t.Cleanup(func() { + listDrivers = origDrivers + }) + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatal("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageOpen { + t.Fatalf("expected stage=%s, got %s", StageOpen, be.Stage) + } + if !strings.Contains(err.Error(), driverName) { + t.Fatalf("expected driver name in error") + } +} + +func TestPingFailureReturnsTypedBuildErrorAndClosesDB(t *testing.T) { + testDrv.Reset() + pingErr := errors.New("ping failed") + testDrv.SetPingErr(pingErr) + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "25ms") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatalf("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StagePing { + t.Fatalf("expected stage=%s, got %s", StagePing, be.Stage) + } + if be.Provider != driverName { + t.Fatalf("expected provider=%q, got %q", driverName, be.Provider) + } + if be.Token != sqlmodule.TokenDB { + t.Fatalf("expected token=%q, got %q", sqlmodule.TokenDB, be.Token) + } + if !errors.Is(err, pingErr) { + t.Fatalf("expected error to wrap ping error") + } + + _, _, closed, _, _ := testDrv.Snapshot() + if closed == 0 { + t.Fatalf("expected ping failure path to close the DB") + } +} + +func TestPathConfigBuildsDSNWithSQLiteOptions(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_BUSY_TIMEOUT", "150ms") + t.Setenv("SQLITE_JOURNAL_MODE", "wal") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "25ms") + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + + _, _, _, _, openDSN := testDrv.Snapshot() + parts := strings.SplitN(openDSN, "?", 2) + if len(parts) != 2 { + t.Fatalf("expected DSN to contain query, got %q", openDSN) + } + if parts[0] != "test.db" { + t.Fatalf("expected base path %q, got %q", "test.db", parts[0]) + } + q, err := url.ParseQuery(parts[1]) + if err != nil { + t.Fatalf("parse query: %v", err) + } + if got := q.Get("_busy_timeout"); got != "150" { + t.Fatalf("expected _busy_timeout=150, got %q", got) + } + if got := q.Get("_journal_mode"); got != "wal" { + t.Fatalf("expected _journal_mode=wal, got %q", got) + } +} + +func TestDSNConfigAppendsSQLiteOptionsToExistingQuery(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "file:test.db?cache=shared") + t.Setenv("SQLITE_BUSY_TIMEOUT", "200ms") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "25ms") + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + + _, _, _, _, openDSN := testDrv.Snapshot() + parts := strings.SplitN(openDSN, "?", 2) + if len(parts) != 2 { + t.Fatalf("expected DSN to contain query, got %q", openDSN) + } + if parts[0] != "file:test.db" { + t.Fatalf("expected base DSN %q, got %q", "file:test.db", parts[0]) + } + q, err := url.ParseQuery(parts[1]) + if err != nil { + t.Fatalf("parse query: %v", err) + } + if got := q.Get("cache"); got != "shared" { + t.Fatalf("expected cache=shared, got %q", got) + } + if got := q.Get("_busy_timeout"); got != "200" { + t.Fatalf("expected _busy_timeout=200, got %q", got) + } +} + +func TestNegativeConnectTimeoutFailsWithInvalidConfig(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "-1ms") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatalf("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageInvalidConfig { + t.Fatalf("expected stage=%s, got %s", StageInvalidConfig, be.Stage) + } + if be.Provider != driverName { + t.Fatalf("expected provider=%q, got %q", driverName, be.Provider) + } +} + +func TestCleanupClosesDB(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "25ms") + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + if err := h.Close(); err != nil { + t.Fatalf("close harness: %v", err) + } + + _, _, closed, _, _ := testDrv.Snapshot() + if closed == 0 { + t.Fatalf("expected cleanup to close a DB connection") + } +} + +func TestMultipleSQLiteInstancesBootstrap(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "file:memdb1?mode=memory&cache=shared") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "0") + + primaryTokens, err := sqlmodule.NamedTokens("primary") + if err != nil { + t.Fatalf("primary tokens: %v", err) + } + analyticsTokens, err := sqlmodule.NamedTokens("analytics") + if err != nil { + t.Fatalf("analytics tokens: %v", err) + } + configMod := DefaultConfigModule() + + root := &multiInstanceRootModule{ + imports: []module.Module{ + NewModule(Options{Name: "primary", Config: configMod}), + NewModule(Options{Name: "analytics", Config: configMod}), + }, + exports: []module.Token{ + primaryTokens.DB, + primaryTokens.Dialect, + analyticsTokens.DB, + analyticsTokens.Dialect, + }, + } + app, err := kernel.Bootstrap(root) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + + if _, err := app.Get(primaryTokens.DB); err != nil { + t.Fatalf("primary db: %v", err) + } + if _, err := app.Get(analyticsTokens.DB); err != nil { + t.Fatalf("analytics db: %v", err) + } +} + +func TestInvalidNameFailsAtBootstrap(t *testing.T) { + root := &multiInstanceRootModule{ + imports: []module.Module{ + NewModule(Options{Name: "bad name"}), + }, + } + _, err := kernel.Bootstrap(root) + if err == nil { + t.Fatal("expected bootstrap error") + } + var invalidNameErr *sqlmodule.InvalidNameError + if !errors.As(err, &invalidNameErr) { + t.Fatalf("expected InvalidNameError, got %T", err) + } +} + +type multiInstanceRootModule struct { + imports []module.Module + exports []module.Token +} + +func (m *multiInstanceRootModule) Definition() module.ModuleDef { + return module.ModuleDef{ + Name: "root", + Imports: m.imports, + Exports: m.exports, + } +} diff --git a/modkit/data/sqlite/tokens.go b/modkit/data/sqlite/tokens.go new file mode 100644 index 0000000..3ddc1a5 --- /dev/null +++ b/modkit/data/sqlite/tokens.go @@ -0,0 +1,14 @@ +package sqlite + +import "github.com/go-modkit/modkit/modkit/module" + +const ( + // TokenPath resolves the SQLite database path or DSN. + TokenPath module.Token = "sqlite.path" //nolint:gosec // token name, not credential + // TokenBusyTimeout resolves the optional busy timeout setting. + TokenBusyTimeout module.Token = "sqlite.busy_timeout" //nolint:gosec // token name, not credential + // TokenJournalMode resolves the optional journal mode setting. + TokenJournalMode module.Token = "sqlite.journal_mode" //nolint:gosec // token name, not credential + // TokenConnectTimeout resolves the optional provider ping timeout. + TokenConnectTimeout module.Token = "sqlite.connect_timeout" //nolint:gosec // token name, not credential +) diff --git a/modkit/data/sqlmodule/errors.go b/modkit/data/sqlmodule/errors.go new file mode 100644 index 0000000..fd83f41 --- /dev/null +++ b/modkit/data/sqlmodule/errors.go @@ -0,0 +1,40 @@ +package sqlmodule + +import ( + "fmt" + + "github.com/go-modkit/modkit/modkit/module" +) + +// BuildStage identifies the provider build step. +type BuildStage string + +const ( + // StageResolveConfig indicates a failure resolving config tokens. + StageResolveConfig BuildStage = "resolve_config" + // StageInvalidConfig indicates invalid config values (e.g. negative settings). + StageInvalidConfig BuildStage = "invalid_config" + // StageOpen indicates a failure opening the database handle. + StageOpen BuildStage = "open" + // StagePing indicates a failure pinging the database. + StagePing BuildStage = "ping" +) + +// BuildError is returned when a SQL provider fails to build. +type BuildError struct { + Provider string + Token module.Token + Stage BuildStage + Err error +} + +func (e *BuildError) Error() string { + if e.Provider == "" { + return fmt.Sprintf("sql provider build failed: token=%q stage=%s: %v", e.Token, e.Stage, e.Err) + } + return fmt.Sprintf("%s provider build failed: token=%q stage=%s: %v", e.Provider, e.Token, e.Stage, e.Err) +} + +func (e *BuildError) Unwrap() error { + return e.Err +} diff --git a/modkit/data/sqlmodule/errors_test.go b/modkit/data/sqlmodule/errors_test.go new file mode 100644 index 0000000..37db3aa --- /dev/null +++ b/modkit/data/sqlmodule/errors_test.go @@ -0,0 +1,36 @@ +package sqlmodule + +import ( + "errors" + "strings" + "testing" +) + +func TestBuildErrorIncludesProvider(t *testing.T) { + inner := errors.New("boom") + be := &BuildError{Provider: "postgres", Token: TokenDB, Stage: StageOpen, Err: inner} + msg := be.Error() + + if !strings.Contains(msg, "postgres provider build failed") { + t.Fatalf("expected provider in message, got %q", msg) + } + if !strings.Contains(msg, "token=\"database.db\"") { + t.Fatalf("expected token in message, got %q", msg) + } + if !strings.Contains(msg, "stage=open") { + t.Fatalf("expected stage in message, got %q", msg) + } + if !errors.Is(be, inner) { + t.Fatalf("expected error to unwrap") + } +} + +func TestBuildErrorWithEmptyProviderUsesGenericPrefix(t *testing.T) { + inner := errors.New("boom") + be := &BuildError{Token: TokenDB, Stage: StageOpen, Err: inner} + msg := be.Error() + + if !strings.Contains(msg, "sql provider build failed") { + t.Fatalf("expected generic prefix, got %q", msg) + } +} diff --git a/modkit/data/sqlmodule/tokens.go b/modkit/data/sqlmodule/tokens.go new file mode 100644 index 0000000..cf26d2b --- /dev/null +++ b/modkit/data/sqlmodule/tokens.go @@ -0,0 +1,65 @@ +// Package sqlmodule defines shared SQL contract tokens and dialect values. +package sqlmodule + +import ( + "fmt" + "strings" + "unicode" + + "github.com/go-modkit/modkit/modkit/module" +) + +const ( + // TokenDB resolves the shared SQL database handle provider. + TokenDB module.Token = "database.db" + // TokenDialect resolves the SQL dialect provider. + TokenDialect module.Token = "database.dialect" +) + +// Dialect identifies the SQL engine family for a database provider. +type Dialect string + +const ( + // DialectPostgres identifies PostgreSQL providers. + DialectPostgres Dialect = "postgres" + // DialectSQLite identifies SQLite providers. + DialectSQLite Dialect = "sqlite" + // DialectMySQL identifies MySQL providers. + DialectMySQL Dialect = "mysql" +) + +// Tokens contains provider tokens for a SQL module instance. +type Tokens struct { + DB module.Token + Dialect module.Token +} + +// InvalidNameError reports invalid SQL module instance names. +type InvalidNameError struct { + Name string + Reason string +} + +func (e *InvalidNameError) Error() string { + return fmt.Sprintf("invalid sql module name: %q reason=%s", e.Name, e.Reason) +} + +// NamedTokens returns deterministic tokens for a SQL module instance name. +func NamedTokens(name string) (Tokens, error) { + if name == "" { + return Tokens{DB: TokenDB, Dialect: TokenDialect}, nil + } + + if strings.TrimSpace(name) == "" { + return Tokens{}, &InvalidNameError{Name: name, Reason: "name is empty after trim"} + } + + if strings.IndexFunc(name, unicode.IsSpace) >= 0 { + return Tokens{}, &InvalidNameError{Name: name, Reason: "name must not contain spaces"} + } + + return Tokens{ + DB: module.Token("database." + name + ".db"), + Dialect: module.Token("database." + name + ".dialect"), + }, nil +} diff --git a/modkit/data/sqlmodule/tokens_test.go b/modkit/data/sqlmodule/tokens_test.go new file mode 100644 index 0000000..35c951b --- /dev/null +++ b/modkit/data/sqlmodule/tokens_test.go @@ -0,0 +1,64 @@ +package sqlmodule + +import ( + "errors" + "strings" + "testing" +) + +func TestNamedTokens_DefaultName(t *testing.T) { + tokens, err := NamedTokens("") + if err != nil { + t.Fatalf("NamedTokens(\"\") error = %v", err) + } + + if tokens.DB != TokenDB { + t.Fatalf("DB token = %q, want %q", tokens.DB, TokenDB) + } + if tokens.Dialect != TokenDialect { + t.Fatalf("dialect token = %q, want %q", tokens.Dialect, TokenDialect) + } +} + +func TestNamedTokens_Namespace(t *testing.T) { + tokens, err := NamedTokens("analytics") + if err != nil { + t.Fatalf("NamedTokens(\"analytics\") error = %v", err) + } + + if tokens.DB != "database.analytics.db" { + t.Fatalf("DB token = %q, want %q", tokens.DB, "database.analytics.db") + } + if tokens.Dialect != "database.analytics.dialect" { + t.Fatalf("dialect token = %q, want %q", tokens.Dialect, "database.analytics.dialect") + } +} + +func TestNamedTokens_InvalidName(t *testing.T) { + testCases := []string{" ", "analytics reporting"} + + for _, name := range testCases { + t.Run(name, func(t *testing.T) { + _, err := NamedTokens(name) + if err == nil { + t.Fatalf("NamedTokens(%q) expected error", name) + } + + var invalidNameErr *InvalidNameError + if !errors.As(err, &invalidNameErr) { + t.Fatalf("error %T is not *InvalidNameError", err) + } + }) + } +} + +func TestInvalidNameErrorMessage(t *testing.T) { + err := &InvalidNameError{Name: "bad name", Reason: "name must not contain spaces"} + msg := err.Error() + if !strings.Contains(msg, "bad name") { + t.Fatalf("expected name in error message, got %q", msg) + } + if !strings.Contains(msg, "name must not contain spaces") { + t.Fatalf("expected reason in error message, got %q", msg) + } +} diff --git a/modkit/http/server_test.go b/modkit/http/server_test.go index af894fe..97260d1 100644 --- a/modkit/http/server_test.go +++ b/modkit/http/server_test.go @@ -33,7 +33,7 @@ func TestServe_ReturnsErrorWhenServerFailsToStart(t *testing.T) { } router := NewRouter() - err := Serve("127.0.0.1:12345", router) + err := Serve("127.0.0.1:12345", router) //nolint:gosec if gotAddr != "127.0.0.1:12345" { t.Fatalf("expected addr %q, got %q", "127.0.0.1:12345", gotAddr) @@ -77,7 +77,7 @@ func TestServe_HandlesSignals_ReturnsNil(t *testing.T) { errCh := make(chan error, 1) go func() { - errCh <- Serve("127.0.0.1:12345", NewRouter()) + errCh <- Serve("127.0.0.1:12345", NewRouter()) //nolint:gosec }() <-serveStarted @@ -135,7 +135,7 @@ func TestServe_ShutdownWaitsForInFlightRequest(t *testing.T) { serveErrCh := make(chan error, 1) go func() { - serveErrCh <- Serve(addr, handler) + serveErrCh <- Serve(addr, handler) //nolint:gosec }() clientErrCh := make(chan error, 1)