diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 507881ce..ae8aae5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,7 @@ jobs: working-directory: control-plane run: | make fmt-check + GOWORK=off make lint GOWORK=off go vet ./... GOWORK=off go test ./... GOWORK=off go test -tags=integration -count=1 ./internal/integration diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff210176..29fa97a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,7 +64,7 @@ jobs: merge-multiple: true - uses: goreleaser/goreleaser-action@v7.2.3 with: - version: v2.18.0 + version: v2.18.2 args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.toml b/Cargo.toml index cd44121a..d6995bfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ version = "0.0.0" # the release tag is the version: release.yml stamps it before edition = "2024" license = "AGPL-3.0-only" repository = "https://github.com/cocoonstack/gateway" -rust-version = "1.98" +rust-version = "1.98.1" publish = false [profile.release] diff --git a/Dockerfile b/Dockerfile index 84f139fb..f1e16d1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Pinned: successive image builds must embed the same toolchain, not whatever # `rust:1` floats to on the day of the build. -FROM rust:1.98.0@sha256:620dbcd124499c59e2406d3741574b5c5838cf9eb9656f0c3a03948f79b02959 AS builder +FROM rust:1.98.1@sha256:a8a5f0a1e5fe7dfe1d352591e4a1c7dd2c08fd70475cae872cf3458ba0df0546 AS builder WORKDIR /app COPY . . RUN cargo build --release -p gw-server --locked diff --git a/control-plane/.golangci.yml b/control-plane/.golangci.yml new file mode 100644 index 00000000..e1f93b65 --- /dev/null +++ b/control-plane/.golangci.yml @@ -0,0 +1,90 @@ +version: "2" + +run: + timeout: 5m + modules-download-mode: readonly + +linters: + default: none + enable: + - asciicheck + - bodyclose + - dogsled + - errcheck + - gochecknoinits + - goconst + - gocritic + - gocyclo + - goprintffuncname + - gosec + - govet + - ineffassign + - misspell + - modernize + - nakedret + - nestif + - nolintlint + - revive + - staticcheck + - unconvert + - unparam + - unused + settings: + gocritic: + disabled-checks: + - captLocal + govet: + enable-all: true + disable: + - fieldalignment + revive: + enable-default-rules: true + rules: + - name: exported + disabled: true + nolintlint: + allow-unused: false + misspell: + locale: US + goconst: + min-len: 3 + min-occurrences: 3 + gocyclo: + min-complexity: 30 + nakedret: + max-func-lines: 30 + nestif: + min-complexity: 5 + exclusions: + generated: strict + paths: + - vendor + - mocks + - dist + rules: + - path: _test\.go + linters: + - errcheck + - gosec + - unparam + - goconst + - gocyclo + - nestif + # Table-driven subtests re-declare err per case; the outer one is fixture setup. + - path: _test\.go + linters: + - govet + text: "shadow" + # Fixture strings in the dev-mode mock client; naming them is churn. + - path: internal/gateway/mock/ + linters: + - goconst + +formatters: + enable: + - gofumpt + - goimports + settings: + goimports: + local-prefixes: + - github.com/cocoonstack/ diff --git a/control-plane/Makefile b/control-plane/Makefile index 40b61338..525ad7d4 100644 --- a/control-plane/Makefile +++ b/control-plane/Makefile @@ -1,8 +1,11 @@ -.PHONY: test test-integration web web-test build fmt fmt-check gofumpt goimports +.PHONY: test test-integration web web-test build fmt fmt-check gofumpt goimports lint golangci-lint RUST_CARGO ?= cargo LOCALBIN ?= $(shell pwd)/bin +GOLANGCILINT_VERSION ?= v2.13.2 +GOLANGCILINT_ROOT := $(LOCALBIN)/golangci-lint-$(GOLANGCILINT_VERSION) +GOLANGCILINT := $(GOLANGCILINT_ROOT)/golangci-lint GOFUMPT_VERSION ?= v0.11.0 GOFUMPT_ROOT := $(LOCALBIN)/gofumpt-$(GOFUMPT_VERSION) GOFMT := $(GOFUMPT_ROOT)/gofumpt @@ -11,6 +14,13 @@ GOIMPORTS_ROOT := $(LOCALBIN)/goimports-$(GOIMPORTS_VERSION) GOIMPORTS := $(GOIMPORTS_ROOT)/goimports GOIMPORTS_LOCAL_PREFIXES := github.com/cocoonstack/ +## Target OSes for lint +GOOSES ?= linux darwin + +golangci-lint: $(GOLANGCILINT) +$(GOLANGCILINT): + GOBIN=$(GOLANGCILINT_ROOT) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCILINT_VERSION) + gofumpt: $(GOFMT) $(GOFMT): GOBIN=$(GOFUMPT_ROOT) go install mvdan.cc/gofumpt@$(GOFUMPT_VERSION) @@ -27,6 +37,14 @@ fmt-check: gofumpt goimports @test -z "$$($(GOFMT) -l .)" || { echo "Files need formatting (gofumpt):"; $(GOFMT) -l .; exit 1; } @test -z "$$($(GOIMPORTS) -l .)" || { echo "Files need formatting (goimports):"; $(GOIMPORTS) -l .; exit 1; } +lint: golangci-lint ## Run golangci-lint on every target OS, integration harness included + @for goos in $(GOOSES); do \ + echo "==> golangci-lint GOOS=$$goos"; \ + GOOS=$$goos $(GOLANGCILINT) run ./... || exit 1; \ + echo "==> golangci-lint GOOS=$$goos -tags integration ./internal/integration"; \ + GOOS=$$goos $(GOLANGCILINT) run --build-tags integration ./internal/integration || exit 1; \ + done + test: GOWORK=off go test ./... diff --git a/control-plane/cmd/control-plane/main.go b/control-plane/cmd/control-plane/main.go index 96f11c6f..778c4053 100644 --- a/control-plane/cmd/control-plane/main.go +++ b/control-plane/cmd/control-plane/main.go @@ -1,3 +1,4 @@ +// Package main is the gateway control-plane entry point. package main import ( @@ -39,13 +40,14 @@ func main() { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - if err := log.SetupLog(ctx, &types.ServerLogConfig{Level: cfg.LogLevel}, ""); err != nil { + if err = log.SetupLog(ctx, &types.ServerLogConfig{Level: cfg.LogLevel, UseJSON: !stderrIsTerminal()}, ""); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) - defer stop() - if err := run(ctx, cfg); err != nil { + err = run(ctx, cfg) + stop() + if err != nil { log.WithFunc("main").Error(ctx, err, "control plane stopped") os.Exit(1) } @@ -66,7 +68,7 @@ func run(ctx context.Context, cfg config.Config) (err error) { if err != nil { return err } - if err := seedUsers(ctx, users, cfg); err != nil { + if err = seedUsers(ctx, users, cfg); err != nil { return err } @@ -91,8 +93,8 @@ func run(ctx context.Context, cfg config.Config) (err error) { defer close(done) shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) defer cancel() - if err := server.Shutdown(shutdownCtx); err != nil { - log.WithFunc("main.run").Error(shutdownCtx, err, "drain HTTP server") + if shutdownErr := server.Shutdown(shutdownCtx); shutdownErr != nil { + log.WithFunc("main.run").Error(shutdownCtx, shutdownErr, "drain HTTP server") } }) log.WithFunc("main.run").Infof(ctx, "control plane listening on http://%s", cfg.ListenAddr) @@ -171,3 +173,8 @@ func ensureUser(ctx context.Context, store user.Store, seed userSeed) error { } return nil } + +func stderrIsTerminal() bool { + fi, err := os.Stderr.Stat() + return err == nil && fi.Mode()&os.ModeCharDevice != 0 +} diff --git a/control-plane/go.mod b/control-plane/go.mod index 4ea24b4f..61d48a45 100644 --- a/control-plane/go.mod +++ b/control-plane/go.mod @@ -1,12 +1,12 @@ module github.com/cocoonstack/gateway/control-plane -go 1.27.0 +go 1.27.1 require ( github.com/jackc/pgx/v5 v5.10.0 - github.com/projecteru2/core v0.1.3 + github.com/projecteru2/core v0.1.5 github.com/redis/go-redis/v9 v9.22.0 - golang.org/x/crypto v0.55.0 + golang.org/x/crypto v0.56.0 golang.org/x/sync v0.22.0 ) @@ -16,7 +16,7 @@ require ( github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 // indirect github.com/cockroachdb/redact v1.1.8 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/getsentry/sentry-go v0.48.0 // indirect + github.com/getsentry/sentry-go v0.49.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -31,7 +31,7 @@ require ( go.uber.org/atomic v1.11.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260918162117-cecb64721679 // indirect google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.12 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect diff --git a/control-plane/go.sum b/control-plane/go.sum index 0114d2c2..f7626b20 100644 --- a/control-plane/go.sum +++ b/control-plane/go.sum @@ -14,8 +14,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/getsentry/sentry-go v0.48.0 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY= -github.com/getsentry/sentry-go v0.48.0/go.mod h1:E5UkA5wp1qR2+MDydNYlVeUiNN2xEdjYMidkgf0Qoss= +github.com/getsentry/sentry-go v0.49.0 h1:Ehejknu1l023Ub7QoRBVLAI7g3Jnhqku4oWx4B4Sh5s= +github.com/getsentry/sentry-go v0.49.0/go.mod h1:nuMJAoCfe1u0Bts2ocyNI+TW8HT84vRMqwA5Qq/SKUI= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -48,8 +48,8 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK 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/projecteru2/core v0.1.3 h1:0odzNdtDKRvbMir3xKpqCPJaThHdzfzvou/vGFWquZY= -github.com/projecteru2/core v0.1.3/go.mod h1:4WGV1OlU8TY0hra+N8Ua2s8hbX1AeJmcjnj3PcztnI4= +github.com/projecteru2/core v0.1.5 h1:cS3xfqa6I1bMXtGdbu2A2UzhjiHaP2mogjQWFTkSEF0= +github.com/projecteru2/core v0.1.5/go.mod h1:MDMJmmlJ3ZVvFQl5BnRcKV1/AVCHyiWoI4BI+sCnuYM= github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= @@ -79,8 +79,8 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -111,8 +111,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260918162117-cecb64721679 h1:KmqdJU4vrNcxy/6qdg3JduZtalEXrJLspVltnR1cE+8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260918162117-cecb64721679/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= diff --git a/control-plane/internal/auth/password.go b/control-plane/internal/auth/password.go index ce9bc081..ae9f8185 100644 --- a/control-plane/internal/auth/password.go +++ b/control-plane/internal/auth/password.go @@ -63,7 +63,7 @@ func VerifyPassword(encoded, password string) bool { if err != nil || len(want) == 0 || len(want) > 64 { return false } - got := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(want))) + got := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(want))) //nolint:gosec // len(want) is at most 64 return subtle.ConstantTimeCompare(got, want) == 1 } diff --git a/control-plane/internal/httpapi/admin.go b/control-plane/internal/httpapi/admin.go index 266f303d..0f9bc535 100644 --- a/control-plane/internal/httpapi/admin.go +++ b/control-plane/internal/httpapi/admin.go @@ -153,7 +153,7 @@ func (s *Server) createKey(w http.ResponseWriter, r *http.Request) { return } s.auditLog(r, "key_create", key.AK) - writeJSON(w, http.StatusCreated, map[string]string{"status": "created", "ak": key.AK}) + writeJSON(w, http.StatusCreated, map[string]string{statusField: "created", "ak": key.AK}) } func (s *Server) patchKey(w http.ResponseWriter, r *http.Request) { @@ -220,7 +220,7 @@ func (s *Server) publishConfig(w http.ResponseWriter, r *http.Request) { return } s.auditLog(r, "config_publish", strconv.FormatInt(version, 10)) - writeJSON(w, http.StatusOK, map[string]any{"status": "published", "version": version}) + writeJSON(w, http.StatusOK, map[string]any{statusField: "published", "version": version}) } func (s *Server) configVersions(w http.ResponseWriter, r *http.Request) { @@ -244,7 +244,7 @@ func (s *Server) rollbackConfig(w http.ResponseWriter, r *http.Request) { return } s.auditLog(r, "config_rollback", strconv.FormatInt(version, 10)) - writeJSON(w, http.StatusOK, map[string]any{"status": "rolled_back", "version": version}) + writeJSON(w, http.StatusOK, map[string]any{statusField: "rolled_back", "version": version}) } func (s *Server) audit(w http.ResponseWriter, r *http.Request) { diff --git a/control-plane/internal/httpapi/auth.go b/control-plane/internal/httpapi/auth.go index b7e5d928..52f5fa1d 100644 --- a/control-plane/internal/httpapi/auth.go +++ b/control-plane/internal/httpapi/auth.go @@ -21,7 +21,7 @@ var dummyHash = sync.OnceValue(func() string { }) func (s *Server) health(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "service": "gateway-control-plane"}) + writeJSON(w, http.StatusOK, map[string]string{statusField: "ok", "service": "gateway-control-plane"}) } func (s *Server) login(w http.ResponseWriter, r *http.Request) { @@ -71,7 +71,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { loginError(r.Context(), w, err) return } - http.SetCookie(w, &http.Cookie{ + http.SetCookie(w, &http.Cookie{ //nolint:gosec // Secure follows CP_COOKIE_SECURE Name: sessionCookie, Value: session.ID, Path: "/", HttpOnly: true, Secure: s.cookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: int(s.sessionTTL.Seconds()), Expires: time.Unix(session.ExpiresAt, 0), @@ -85,7 +85,7 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) { mapError(r.Context(), w, err) return } - http.SetCookie(w, &http.Cookie{ + http.SetCookie(w, &http.Cookie{ //nolint:gosec // Secure follows CP_COOKIE_SECURE Name: sessionCookie, Path: "/", HttpOnly: true, Secure: s.cookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(1, 0), }) diff --git a/control-plane/internal/httpapi/server.go b/control-plane/internal/httpapi/server.go index 600eb2e8..4d623778 100644 --- a/control-plane/internal/httpapi/server.go +++ b/control-plane/internal/httpapi/server.go @@ -27,6 +27,7 @@ const ( sessionCookie = "cp_session" maxJSONBody = 1 << 20 maxConfigBody = 4 << 20 + statusField = "status" ) type principal struct { diff --git a/control-plane/internal/user/postgres/store.go b/control-plane/internal/user/postgres/store.go index 8f60980e..bcbb007d 100644 --- a/control-plane/internal/user/postgres/store.go +++ b/control-plane/internal/user/postgres/store.go @@ -125,10 +125,10 @@ func (s *Store) migrate(ctx context.Context) error { return fmt.Errorf("begin migrations: %w", err) } defer func() { _ = tx.Rollback(ctx) }() - if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", migrationLockKey); err != nil { + if _, err = tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", migrationLockKey); err != nil { return fmt.Errorf("lock migrations: %w", err) } - if _, err := tx.Exec(ctx, "CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())"); err != nil { + if _, err = tx.Exec(ctx, "CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())"); err != nil { return fmt.Errorf("create schema migrations: %w", err) } entries, err := migrationFiles.ReadDir("migrations") diff --git a/rust-toolchain.toml b/rust-toolchain.toml index aa20bc99..68c0ea29 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.98.0" +channel = "1.98.1" components = ["rustfmt", "clippy"]