From e26ef2b66633964451612e594e1eee0d051b8e5f Mon Sep 17 00:00:00 2001 From: Maksim Merzhanov Date: Sat, 11 Apr 2026 13:02:10 +0300 Subject: [PATCH] ci: add golangci-lint + test workflows, smoke tests, bump to Go 1.26 - Port .golangci.yml from awl (27 linters, Go 1.26) - Add .github/workflows/{test,golangci-lint}.yml (linux/macos/windows matrix) - Add config round-trip tests and application startup smoke test - Bump go.mod to 1.26.0 --- .github/workflows/golangci-lint.yml | 19 +++ .github/workflows/test.yml | 40 +++++ .golangci.yml | 88 +++++++++++ application.go | 10 +- application_test.go | 218 ++++++++++++++++++++++++++++ config/config_test.go | 129 ++++++++++++++++ config/other.go | 2 +- go.mod | 2 +- 8 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/golangci-lint.yml create mode 100644 .github/workflows/test.yml create mode 100644 .golangci.yml create mode 100644 application_test.go create mode 100644 config/config_test.go diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..74f786e --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,19 @@ +name: golangci-lint +on: [ push, pull_request ] +jobs: + golangci: + name: lint + # run job on all pushes OR external PR, not both + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name }} + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Install Go + uses: actions/setup-go@v6 + with: + go-version: 1.26.x + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.11.4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e5ad47e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: Test +on: [ push, pull_request ] +jobs: + test: + # run job on all pushes OR external PR, not both + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name }} + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest, windows-latest, macos-latest ] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Install Go + uses: actions/setup-go@v6 + with: + go-version: 1.26.x + cache: true + - name: gofmt && go mod tidy + if: matrix.os == 'ubuntu-latest' + shell: bash + run: | + go mod tidy -compat=1.26 + test -z "$(gofmt -d .)" || (gofmt -d . && false) + test -z "$(git status --porcelain)" || (git status; git diff && false) + - name: Test + run: go test -count=1 -v ./... + - name: Test with -race + run: go test -race -count=1 -v ./... + - name: Build + run: go build . + - name: Upload build + uses: actions/upload-artifact@v7 + with: + name: awl-bootstrap-node-${{ runner.os }} + path: | + awl-bootstrap-node + awl-bootstrap-node.exe + if-no-files-found: error diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..4c5cf09 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,88 @@ +version: "2" +run: + go: "1.26" + +linters: + default: none + enable: + - asciicheck + - bodyclose + - copyloopvar + - dogsled + - dupl + - errcheck + - exhaustive + - goconst + - gocritic + - gocyclo + - goprintffuncname + - gosec + - govet + - ineffassign + - mirror + - misspell + - nakedret + - nestif + - nilnil + - nolintlint + - nosprintfhostport + - prealloc + - rowserrcheck + - sqlclosecheck + - staticcheck + - unconvert + - unparam + - unused + - wastedassign + - whitespace + + settings: + exhaustive: + default-signifies-exhaustive: true + gocritic: + disabled-checks: + - hugeParam + - rangeValCopy + - ifElseChain + enabled-tags: + - diagnostic + - performance + prealloc: + simple: true + range-loops: true + for-loops: true + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - errcheck + - goconst + path: _test\.go + - linters: + - gosec + text: 'G101:' + - linters: + - gosec + text: 'G404:' + - linters: + - gosec + text: 'G115:' + paths: + - third_party$ + - builtin$ + - examples$ + +formatters: + enable: + - gofmt + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/application.go b/application.go index 512cd7d..3099347 100644 --- a/application.go +++ b/application.go @@ -18,7 +18,7 @@ import ( "github.com/libp2p/go-libp2p/core/network" "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/core/peerstore" - "github.com/libp2p/go-libp2p/p2p/host/peerstore/pstoreds" + "github.com/libp2p/go-libp2p/p2p/host/peerstore/pstoreds" //nolint:staticcheck // disk-backed peerstore is intentional for bootstrap-node; will migrate when libp2p removes it "github.com/libp2p/go-libp2p/p2p/host/peerstore/pstoremem" rcmgr "github.com/libp2p/go-libp2p/p2p/host/resource-manager" "github.com/libp2p/go-libp2p/p2p/protocol/circuitv2/relay" @@ -41,6 +41,7 @@ type Application struct { Api *api.Handler p2pServer *p2p.P2p + datastore ds.Batching } func New() *Application { @@ -53,6 +54,7 @@ func (a *Application) Init(ctx context.Context) error { if err != nil { return fmt.Errorf("could not make p2p host config: %s", err) } + a.datastore = p2pHostConfig.DHTDatastore p2pSrv := p2p.NewP2p(ctx) host, err := p2pSrv.InitHost(p2pHostConfig) if err != nil { @@ -145,6 +147,12 @@ func (a *Application) Close() { a.logger.Errorf("closing p2p server: %v", err) } } + if a.datastore != nil { + err := a.datastore.Close() + if err != nil { + a.logger.Errorf("closing datastore: %v", err) + } + } } func (a *Application) makeP2pHostConfig() (p2p.HostConfig, error) { diff --git a/application_test.go b/application_test.go new file mode 100644 index 0000000..9db9862 --- /dev/null +++ b/application_test.go @@ -0,0 +1,218 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "testing" + "time" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/mr-tron/base58/base58" + + "github.com/anywherelan/awl-bootstrap-node/config" +) + +// TestApplicationSmoke exercises the full startup/shutdown path: +// New() -> SetupLoggerAndConfig -> Init -> HTTP GET on /p2p_info -> Close. +// It spins up a real libp2p host on loopback with OS-assigned ports and a +// badger-backed peerstore in a temp directory. No external network required. +func TestApplicationSmoke(t *testing.T) { + t.Chdir(t.TempDir()) + httpPort := writeTestConfig(t) + + app := New() + app.SetupLoggerAndConfig() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := app.Init(ctx); err != nil { + t.Fatalf("app.Init: %v", err) + } + t.Cleanup(app.Close) + + // API is started in a goroutine; poll briefly until it answers. + url := fmt.Sprintf("http://127.0.0.1:%d/api/v0/debug/p2p_info", httpPort) + body := getWithRetry(t, url, 5*time.Second) + + var info map[string]any + if err := json.Unmarshal(body, &info); err != nil { + t.Fatalf("decode p2p_info: %v\nbody: %s", err, body) + } + general, ok := info["General"].(map[string]any) + if !ok { + t.Fatalf("response missing General block: %v", info) + } + if general["Version"] != config.Version { + t.Errorf("General.Version = %v, want %q", general["Version"], config.Version) + } + + // Log endpoint should return 200 with plain text even if the buffer is empty. + logURL := fmt.Sprintf("http://127.0.0.1:%d/api/v0/debug/log", httpPort) + resp, err := http.Get(logURL) //nolint:gosec,noctx // trusted loopback URL in test + if err != nil { + t.Fatalf("GET log: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("log endpoint status = %d, want 200", resp.StatusCode) + } +} + +// writeTestConfig generates a fresh ed25519 identity, builds a minimal Config +// listening on loopback with OS-assigned libp2p ports, saves it to +// config.AppConfigFilename in the current directory, and returns the HTTP port +// that was wired in so callers can reach the API. +func writeTestConfig(t *testing.T) int { + t.Helper() + + priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatalf("generate key: %v", err) + } + pid, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatalf("peer.IDFromPrivateKey: %v", err) + } + rawKey, err := priv.Raw() + if err != nil { + t.Fatalf("priv.Raw: %v", err) + } + + httpPort := freeTCPPort(t) + conf := &config.Config{ + LoggerLevel: "info", + HttpListenAddress: fmt.Sprintf("127.0.0.1:%d", httpPort), + P2pNode: config.P2pNode{ + PeerID: pid.String(), + Identity: base58.Encode(rawKey), + // Loopback only, OS-assigned ports — no external networking. + ListenAddresses: []string{ + "/ip4/127.0.0.1/tcp/0", + "/ip4/127.0.0.1/udp/0/quic-v1", + }, + BootstrapPeers: []string{}, + }, + } + if err := config.SaveConfig(conf, config.AppConfigFilename); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + return httpPort +} + +// TestGenerateExampleConfig runs the real generateExampleConfig helper from +// main.go end-to-end: it spins up a throwaway libp2p host to mint an identity, +// then writes a usable config file to disk. We reload the file via LoadConfig +// (same path production uses) and verify the identity/peer-id are populated, +// the private key round-trips, and the default bootstrap peers and listen +// addresses from setDefaults are present. +func TestGenerateExampleConfig(t *testing.T) { + t.Chdir(t.TempDir()) + + // Use the real filename so LoadConfig can find it through CalcAppDataDir. + generateExampleConfig(config.AppConfigFilename) + + info, err := os.Stat(config.AppConfigFilename) + if err != nil { + t.Fatalf("stat generated config: %v", err) + } + if info.Size() == 0 { + t.Fatal("generated config is empty") + } + + loaded, err := config.LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + if loaded.P2pNode.Identity == "" { + t.Error("generated config has no Identity") + } + if loaded.P2pNode.PeerID == "" { + t.Error("generated config has no PeerID") + } + // PeerID must parse as a valid libp2p peer ID. + pid, err := peer.Decode(loaded.P2pNode.PeerID) + if err != nil { + t.Fatalf("peer.Decode: %v", err) + } + + // Identity bytes must parse as an ed25519 private key whose derived + // peer ID matches the stored PeerID. + raw := loaded.PrivKey() + if raw == nil { + t.Fatal("loaded.PrivKey() returned nil") + } + priv, err := crypto.UnmarshalEd25519PrivateKey(raw) + if err != nil { + t.Fatalf("UnmarshalEd25519PrivateKey: %v", err) + } + derivedPID, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatalf("IDFromPrivateKey: %v", err) + } + if derivedPID != pid { + t.Errorf("derived PeerID %q does not match stored %q", derivedPID, pid) + } + + if len(loaded.P2pNode.BootstrapPeers) == 0 { + t.Error("generated config has no BootstrapPeers (expected awl defaults)") + } + if len(loaded.GetListenAddresses()) == 0 { + t.Error("generated config has no ListenAddresses") + } + + // Sanity check: base58 Identity should decode back to ed25519 raw key size (64 bytes). + decoded, err := base58.Decode(loaded.P2pNode.Identity) + if err != nil { + t.Fatalf("base58.Decode identity: %v", err) + } + if len(decoded) == 0 { + t.Error("decoded identity is empty") + } +} + +func freeTCPPort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + port := l.Addr().(*net.TCPAddr).Port + _ = l.Close() + return port +} + +func getWithRetry(t *testing.T, url string, timeout time.Duration) []byte { + t.Helper() + deadline := time.Now().Add(timeout) + var lastErr error + for time.Now().Before(deadline) { + b, err := tryGet(url) + if err == nil { + return b + } + lastErr = err + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("GET %s: %v", url, lastErr) + return nil +} + +func tryGet(url string) ([]byte, error) { + resp, err := http.Get(url) //nolint:gosec,noctx // trusted loopback URL in test + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + return io.ReadAll(resp.Body) +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..d30ca81 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,129 @@ +package config + +import ( + "bytes" + "path/filepath" + "testing" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" +) + +func TestSetDefaults(t *testing.T) { + conf := NewConfig() + + if len(conf.P2pNode.ListenAddresses) != 4 { + t.Fatalf("expected 4 default listen addresses, got %d", len(conf.P2pNode.ListenAddresses)) + } + if len(conf.P2pNode.BootstrapPeers) != 0 { + t.Fatal("BootstrapPeers should be empty") + } + if conf.LoggerLevel != "info" { + t.Errorf("default LoggerLevel = %q, want info", conf.LoggerLevel) + } + if conf.HttpListenAddress == "" { + t.Error("HttpListenAddress should have a default") + } +} + +func TestNewExampleConfig(t *testing.T) { + conf := NewExampleConfig() + + if len(conf.P2pNode.BootstrapPeers) == 0 { + t.Fatal("example config should include default bootstrap peers") + } + if len(conf.GetListenAddresses()) == 0 { + t.Fatal("example config should have listen addresses") + } +} + +func TestSetIdentityAndPrivKey(t *testing.T) { + t.Chdir(t.TempDir()) + + priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatal(err) + } + pid, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatal(err) + } + + conf := NewConfig() + conf.SetIdentity(priv, pid) + + if conf.P2pNode.PeerID != pid.String() { + t.Errorf("PeerID not stored: got %q, want %q", conf.P2pNode.PeerID, pid.String()) + } + if conf.P2pNode.Identity == "" { + t.Error("Identity should be set after SetIdentity") + } + + raw := conf.PrivKey() + if raw == nil { + t.Fatal("PrivKey returned nil after SetIdentity") + } + wantRaw, err := priv.Raw() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(raw, wantRaw) { + t.Error("PrivKey round-trip produced different bytes") + } +} + +func TestSaveLoadConfigRoundTrip(t *testing.T) { + t.Chdir(t.TempDir()) + + priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatal(err) + } + pid, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatal(err) + } + + orig := NewExampleConfig() + orig.SetIdentity(priv, pid) + + if err := SaveConfig(orig, AppConfigFilename); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + loaded, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + if loaded.P2pNode.PeerID != orig.P2pNode.PeerID { + t.Errorf("PeerID mismatch: got %q, want %q", loaded.P2pNode.PeerID, orig.P2pNode.PeerID) + } + if loaded.P2pNode.Identity != orig.P2pNode.Identity { + t.Error("Identity not preserved through save/load") + } + if len(loaded.P2pNode.BootstrapPeers) != len(orig.P2pNode.BootstrapPeers) { + t.Errorf("BootstrapPeers count mismatch: got %d, want %d", + len(loaded.P2pNode.BootstrapPeers), len(orig.P2pNode.BootstrapPeers)) + } + if len(loaded.GetListenAddresses()) != len(orig.GetListenAddresses()) { + t.Errorf("ListenAddresses count mismatch: got %d, want %d", + len(loaded.GetListenAddresses()), len(orig.GetListenAddresses())) + } + + // GetBootstrapPeers should parse without errors for the known-good default set. + infos := loaded.GetBootstrapPeers() + if len(infos) == 0 { + t.Error("GetBootstrapPeers returned no peers for example config") + } +} + +func TestPeerstoreDirIsRelativeWhenNoAppDataDir(t *testing.T) { + t.Chdir(t.TempDir()) + conf := NewConfig() + // With no config.yaml beside the test binary, CalcAppDataDir returns "". + want := filepath.Join("", DhtPeerstoreDataDirectory) + if got := conf.PeerstoreDir(); got != want { + t.Errorf("PeerstoreDir = %q, want %q", got, want) + } +} diff --git a/config/other.go b/config/other.go index 1d8728a..8e68561 100644 --- a/config/other.go +++ b/config/other.go @@ -113,7 +113,7 @@ func setDefaults(conf *Config) { // Other if conf.LoggerLevel == "" { - conf.LoggerLevel = "debug" + conf.LoggerLevel = "info" } if conf.HttpListenAddress == "" { conf.HttpListenAddress = "127.0.0.1:" + strconv.Itoa(DefaultHTTPPort) diff --git a/go.mod b/go.mod index 2203170..826952d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/anywherelan/awl-bootstrap-node -go 1.25.0 +go 1.26.0 require ( github.com/anywherelan/awl v0.15.0