diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml
index a63a59d..61caf39 100644
--- a/.github/workflows/commit.yml
+++ b/.github/workflows/commit.yml
@@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
- go-version: "1.26.1"
+ go-version: "1.26.4"
- run: go mod download
- run: go mod verify
@@ -29,31 +29,33 @@ jobs:
tests:
name: tests-${{ matrix.os }}
runs-on: ${{ matrix.os }}
- # Check on all supported GitHub Actions OS
- # https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories
- # https://github.com/actions/runner-images
- # https://github.com/actions/partner-runner-images
+ # One runner owns the strongest behavior gate. The remaining runners are
+ # portability sentinels for each supported OS family and Linux arm64.
strategy:
fail-fast: false
matrix:
os:
- - ubuntu-22.04
- ubuntu-24.04
- - ubuntu-22.04-arm
- ubuntu-24.04-arm
- - windows-2022
- windows-2025
- - macos-14
- - macos-15
- macos-26
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
- go-version: "1.26.1"
+ go-version: "1.26.4"
- run: go mod download
- - run: go test -v ./... -coverprofile=./coverage.txt -covermode=atomic -coverpkg=./...
+
+ - name: Run behavior tests with race detection
+ if: matrix.os == 'ubuntu-24.04'
+ run: >-
+ go test -race -shuffle=on -vet=all -failfast ./...
+ -coverprofile=./coverage.txt -covermode=atomic -coverpkg=./...
+
+ - name: Run portability tests
+ if: matrix.os != 'ubuntu-24.04'
+ run: go test -shuffle=on -vet=all -failfast ./...
- name: Archive code coverage results
if: matrix.os == 'ubuntu-24.04'
@@ -82,7 +84,7 @@ jobs:
- uses: actions/setup-go@v6
with:
- go-version: "1.26.1"
+ go-version: "1.26.4"
- name: Set up QEMU for cross-compilation of Docker images
uses: docker/setup-qemu-action@v3
@@ -204,7 +206,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
- go-version: "1.26.1"
+ go-version: "1.26.4"
- run: go mod download
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 88dddf9..c17153f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,7 +19,7 @@ jobs:
- uses: actions/setup-go@v6
with:
- go-version: "1.26.1"
+ go-version: "1.26.4"
- name: Set up QEMU for cross-compilation of Docker images
uses: docker/setup-qemu-action@v3
diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml
index c0da179..d318aa4 100644
--- a/.github/workflows/version-check.yml
+++ b/.github/workflows/version-check.yml
@@ -34,9 +34,11 @@ jobs:
fi
echo "π¦ Update available: $CURRENT_TIMESTAMP β $LATEST_TIMESTAMP"
- echo "needs_update=true" >> $GITHUB_ENV
- echo "latest_ts=$LATEST_TIMESTAMP" >> $GITHUB_ENV
- echo "current_ts=$CURRENT_TIMESTAMP" >> $GITHUB_ENV
+ {
+ echo "needs_update=true"
+ echo "latest_ts=$LATEST_TIMESTAMP"
+ echo "current_ts=$CURRENT_TIMESTAMP"
+ } >> "$GITHUB_ENV"
# Store versions for issue body
echo "$API_JSON" | jq -r ".\"$LATEST_TIMESTAMP\"" > new_versions.json
diff --git a/AGENTS.md b/AGENTS.md
index 8d8e515..edaeba5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,53 +1,122 @@
-## Overview
+# any-sync-bundle repository guide
-- `any-sync-bundle` wraps the Anytype coordinator, consensus, filenode, and sync services into one binary (`lightnode/anynodes.go`).
-- All services share the coordinator's network stack: TCP 33010, QUIC/UDP 33020, one PeerID, one DRPC mux.
-- The filenode supports two storage backends (auto-selected based on configuration):
- - **BadgerDB** (default): Local embedded storage via `lightcmp/lightfilenodestore`
- - **S3** (optional): Cloud storage via upstream `s3store` implementation
-- External dependencies: MongoDB for coordinator/consensus, Redis for filenode cache. Sync node persists to AnyStore on disk.
+## Mission and ownership
-Config bootstrap (cmd/start.go):
+`any-sync-bundle` is a light process wrapper around the upstream Anytype
+coordinator, consensus, filenode, and sync applications.
-1. Load existing bundle YAML if present.
-2. Otherwise create one via `config.CreateWrite`, injecting values from env/flags.
-3. Always write the client config (`YamlClientConfig`) to the target path.
+This repository owns orchestration, configuration conversion, shared-network
+wiring, filenode store selection, and the local BadgerDB adapter. Preserve
+upstream application behavior and lifecycle contracts. Do not copy or replace
+an upstream service when a narrow adapter or upstream change is enough.
-## Architecture Notes
+MinIO is an S3-compatible integration dependency, not another storage backend.
+Configured S3 storage uses upstream `s3store`; local storage uses BadgerDB.
-- Coordinator starts first, then consensus, filenode, sync (`runBundleServices`).
-- `extractSharedNetwork` copies network components from the coordinator into other apps.
-- DRPC routes by method prefix (`/CoordinatorService`, `/ConsensusService`, `/FileService`, `/SpaceSyncService`).
-- Data layout (default `./data`):
- - `bundle-config.yml` β persisted configuration (credentials, keys)
- - `client-config.yml` β generated client config (regenerated on start)
- - `storage/` β local storage directory:
- - `network-store/` β network configuration
- - `storage-sync/` β sync node persistence (AnyStore)
- - `storage-file/` β filenode data (BadgerDB, when not using S3)
+## Repository map
-## Development
+- `main.go` owns process signals and the final shutdown watchdog.
+- `cmd/` owns the CLI, bundle lifecycle, embedded-process supervision, and
+ MongoDB replica-set initialization.
+- `config/` owns YAML boundaries and conversion to upstream node configs.
+- `lightnode/anynodes.go` composes the four upstream applications, their shared
+ network components, and the filenode store.
+- `lightcmp/lightfilenodestore/` implements the local BadgerDB store.
+- `integration/` and `compose.*.yml` prove and document Docker-backed system
+ boundaries.
+- `README.md` and `CONTRIBUTING.md` are the operator and developer workflow
+ sources; keep their commands aligned with CI.
-### Compose files
+## Runtime invariants
-- `compose.dev.yml` β development dependencies (MongoDB replica set + Redis Stack).
-- `compose.aio.yml` β bundle image with embedded MongoDB/Redis.
-- `compose.external.yml` β bundle image plus external MongoDB and Redis containers.
-- `compose.s3.yml` β bundle with MinIO for S3 storage testing.
-- `compose.traefik.yml` β Traefik reverse proxy example.
+- All services share the coordinator's PeerID, network stack, and DRPC mux on
+ TCP 33010 and QUIC/UDP 33020.
+- Service order is coordinator, consensus, filenode, then sync. Initialize every
+ application before running any application so the network cannot read the
+ shared DRPC mux while handlers are still being registered. Shut down in
+ reverse order.
+- The root context represents process lifetime. A process signal, startup
+ failure, service run failure, or unexpected embedded-process exit cancels it.
+ Preserve independent startup, runtime, and cleanup errors; an operator stop is
+ successful only when cleanup adds no failure.
+- All-in-one mode owns MongoDB and Redis for their full lifetime. An unexpected
+ exit fails the bundle. Intentional shutdown sends SIGTERM to every child
+ before waiting, then forces and reaps deadline survivors.
+- `cmd.ShutdownTimeout` is the application-owned aggregate shutdown bound.
+ Service, infrastructure, integration, watchdog, and bundle Compose timing must
+ derive from or be checked against that policy rather than duplicate it.
+
+## Configuration and data
+
+Configuration bootstrap has one order:
+
+1. Load an existing bundle YAML when present.
+2. Otherwise create it with `config.CreateWrite` from flags and environment.
+3. Regenerate the client configuration on every start.
+
+Validate persisted configuration at this boundary without rewriting
+operator-owned values. Validate MongoDB URIs with the driver, not only
+`net/url`. When creating a config, preserve the coordinator URI and add a path
+separator only to the derived consensus URI when the bundle adds query options.
+
+With default flags, durable paths are `./data/bundle-config.yml`,
+`./data/storage/network-store/`, `./data/storage/storage-sync/`, and
+`./data/storage/storage-file/`. The generated client config is
+`./data/client-config.yml`. All-in-one infrastructure uses `/data/mongo` and
+`/data/redis`. Treat the bundle config as sensitive because it contains
+credentials and keys.
+
+Complete MongoDB and Redis URI logging is an explicit current project decision.
+Do not change it as incidental cleanup; revisit it only for an explicit
+security or privacy requirement.
+
+## Change discipline
+
+- Read the active diff before editing. Preserve unrelated staged, unstaged, and
+ untracked work.
+- Keep changes inside the repository-owned boundaries. Keep lifecycle order and
+ resource ownership visible instead of introducing a generic framework around
+ upstream applications.
+- Test repository-owned contracts, not dependency internals. Prefer a few
+ boundary and failure-path tests over broad cross-products.
+- Treat `go.mod` as the Go-version source of truth. When changing it, keep the
+ Dockerfile and GitHub workflows aligned and verify that Nix provides the same
+ toolchain.
+
+## Verification
+
+Use focused tests while iterating. Run `gofmt -w` on each changed Go file.
+Before presenting a Go change as ready, run these commands from the repository
+root:
```bash
-go build -o any-sync-bundle .
-golangci-lint run --fix
-go test -race -shuffle=on -vet=all -failfast ./...
-go test -tags=integration ./integration/... # requires Docker
+golangci-lint run ./...
+go test -count=1 -race -shuffle=on -vet=all -failfast ./...
+go build -o /tmp/any-sync-bundle .
+git diff --check
```
-### Integration Tests
+Do not use `golangci-lint --fix` as a verification command because it mutates
+the reviewed source.
+
+For configuration, startup, shutdown, storage, Docker, or integration changes,
+also run the Docker-dependent integration suite:
+
+```bash
+go test -count=1 -tags=integration -timeout=10m ./integration/...
+```
+
+For Go toolchain, Nix, dependency, or release-build changes, also run:
+
+```bash
+nix flake check --print-build-logs
+nix build -L .#default
+```
-Uses `testcontainers-go` to spin up MongoDB, Redis, and MinIO containers.
+Documentation-only changes need `git diff --check` plus a direct review of every
+changed command, path, link, and behavioral claim. Run broader gates when the
+documentation changes an executable contract.
-Test files:
-- `integration/containers.go` β container lifecycle helpers
-- `integration/bundle.go` β bundle process manager
-- `integration/integration_test.go` β test cases
+A change is ready only when the narrow regression proof and every applicable
+broader gate pass from the final source state. Report the commands actually run
+and any verification that could not be completed.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f9c56d3..bf4597f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -6,7 +6,7 @@ Thanks for your interest in improving any-sync-bundle! This document explains ho
### Prerequisites
-- Go 1.26.1 or later
+- Go 1.26.4 or later
- Docker (optional, for testing with containers)
- golangci-lint (for linting)
@@ -82,7 +82,7 @@ release-facing files.
```sh
# Set variables (fish shell)
-set VERSION v1.4.3
+set VERSION v1.5.0
set ANYTYPE_UNIX_TIMESTAMP
diff --git a/cmd/infra.go b/cmd/infra.go new file mode 100644 index 0000000..fce44db --- /dev/null +++ b/cmd/infra.go @@ -0,0 +1,492 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "os/exec" + "strings" + "sync" + "syscall" + "time" + + "go.uber.org/zap" + + "github.com/grishy/any-sync-bundle/config" +) + +const ( + dockerMongoPort = "27017" + dockerRedisPort = "6379" + dockerMongoURI = "mongodb://127.0.0.1:27017/" + dockerMongoMajorityURI = "mongodb://127.0.0.1:27017/?w=majority" + dockerRedisURI = "redis://127.0.0.1:6379/" + dockerMongoDataDir = "/data/mongo" + dockerRedisDataDir = "/data/redis" +) + +func startAllInOneInfra(ctx context.Context, infra *infraSuite) error { + if err := ctx.Err(); err != nil { + return err + } + + // Create required data directories with proper permissions + if err := os.MkdirAll(dockerMongoDataDir, 0o750); err != nil { + return fmt.Errorf("failed to create mongo data dir: %w", err) + } + if err := os.MkdirAll(dockerRedisDataDir, 0o750); err != nil { + return fmt.Errorf("failed to create redis data dir: %w", err) + } + + log.Info("data directories prepared", + zap.String("mongo", dockerMongoDataDir), + zap.String("redis", dockerRedisDataDir)) + + mongoArgs := []string{ + "--port", dockerMongoPort, + "--dbpath", dockerMongoDataDir, + "--replSet", defaultMongoReplica, + "--bind_ip", "127.0.0.1", + } + + log.Info("starting embedded MongoDB", + zap.String("addr", "127.0.0.1:"+dockerMongoPort), + zap.String("dbpath", dockerMongoDataDir)) + + mongoProc, err := infra.start("mongo", "mongod", mongoArgs...) + if err != nil { + if isRootInterruption(ctx, err) { + return err + } + return fmt.Errorf("start mongod: %w", err) + } + + redisArgs := []string{ + "--port", dockerRedisPort, + "--dir", dockerRedisDataDir, + "--appendonly", "yes", + "--maxmemory", "256mb", + "--maxmemory-policy", "noeviction", + "--protected-mode", "no", + "--bind", "127.0.0.1", + "--loadmodule", "/opt/redis-stack/lib/redisbloom.so", + } + + log.Info("starting embedded Redis", + zap.String("addr", "127.0.0.1:"+dockerRedisPort), + zap.String("dir", dockerRedisDataDir)) + + redisProc, err := infra.start("redis", "redis-server", redisArgs...) + if err != nil { + if isRootInterruption(ctx, err) { + return err + } + return fmt.Errorf("start redis-server: %w", err) + } + + // Wait for MongoDB TCP ready (or process death) + mongoAddr := net.JoinHostPort("127.0.0.1", dockerMongoPort) + if err = waitForTCPOrExit(ctx, mongoAddr, 180*time.Second, mongoProc); err != nil { + if isRootInterruption(ctx, err) { + return err + } + if isIllegalInstruction(err) { + printMongoAVXError() + return &MongoAVXError{Cause: err} + } + return fmt.Errorf("mongodb not ready: %w", err) + } + + if err = initReplicaSetAction(ctx, defaultMongoReplica, dockerMongoURI); err != nil { + if isRootInterruption(ctx, err) { + return err + } + return fmt.Errorf("init replica set: %w", err) + } + + // Wait for Redis TCP ready (or process death) + redisAddr := net.JoinHostPort("127.0.0.1", dockerRedisPort) + if err = waitForTCPOrExit(ctx, redisAddr, 30*time.Second, redisProc); err != nil { + if isRootInterruption(ctx, err) { + return err + } + return fmt.Errorf("redis not ready: %w", err) + } + + return nil +} + +func applyAllInOneDefaults(cfg *config.Config) { + cfg.Coordinator.MongoConnect = dockerMongoURI + cfg.Consensus.MongoConnect = dockerMongoMajorityURI + cfg.FileNode.RedisConnect = dockerRedisURI +} + +type infraExitError struct { + name string + err error +} + +func (e infraExitError) Error() string { + if e.err == nil { + return fmt.Sprintf("%s exited unexpectedly", e.name) + } + return fmt.Sprintf("%s exited unexpectedly: %v", e.name, e.err) +} + +func (e infraExitError) Unwrap() error { + return e.err +} + +type infraProcess struct { + name string + process *os.Process + done chan struct{} + waitErr error +} + +func (p *infraProcess) unexpectedExit() infraExitError { + return infraExitError{name: p.name, err: p.waitErr} +} + +type infraSuite struct { + rootCtx context.Context + childrenCtx context.Context + cancelChildren context.CancelFunc + cancelRoot context.CancelFunc + waitDelay time.Duration + mu sync.Mutex + stopping bool + processes []*infraProcess +} + +func newInfraSuite( + rootCtx context.Context, + cancelRoot context.CancelFunc, + waitDelay time.Duration, +) *infraSuite { + childrenCtx, cancelChildren := context.WithCancel(context.WithoutCancel(rootCtx)) + return &infraSuite{ + rootCtx: rootCtx, + childrenCtx: childrenCtx, + cancelChildren: cancelChildren, + cancelRoot: cancelRoot, + waitDelay: waitDelay, + } +} + +func (s *infraSuite) start(name, bin string, args ...string) (*infraProcess, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if err := s.rootCtx.Err(); err != nil { + return nil, err + } + if s.stopping { + return nil, fmt.Errorf("start %s after embedded process shutdown", name) + } + + // Production callers provide fixed binaries; tests intentionally provide + // the test binary. + //nolint:gosec // Production callers fix the command specification. + cmd := exec.CommandContext(s.childrenCtx, bin, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Cancel = func() error { + return cmd.Process.Signal(syscall.SIGTERM) + } + cmd.WaitDelay = s.waitDelay + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start %s: %w", name, err) + } + + process := &infraProcess{ + name: name, + process: cmd.Process, + done: make(chan struct{}), + } + s.processes = append(s.processes, process) + + go func() { + waitErr := cmd.Wait() + s.mu.Lock() + process.waitErr = waitErr + if !s.stopping { + s.cancelRoot() + } + close(process.done) + s.mu.Unlock() + }() + + return process, nil +} + +func (p *infraProcess) shutdownError() error { + err := p.waitErr + if err == nil { + return nil + } + if errors.Is(err, context.Canceled) { + return nil + } + + shutdownErr := fmt.Errorf("%s shutdown failed: %w", p.name, err) + exitErr, ok := errors.AsType[*exec.ExitError](err) + if !ok { + return shutdownErr + } + waitStatus, ok := exitErr.Sys().(syscall.WaitStatus) + if !ok { + return shutdownErr + } + if !waitStatus.Signaled() { + return shutdownErr + } + if waitStatus.Signal() == syscall.SIGTERM { + return nil + } + if waitStatus.Signal() == syscall.SIGKILL { + return fmt.Errorf("%s required forced shutdown: %w", p.name, err) + } + + return shutdownErr +} + +func (s *infraSuite) stop(ctx context.Context) error { + // Closing done is the Wait owner's publication point. A result published + // before this handoff remains unexpected. Every later result belongs to + // intentional shutdown. + s.mu.Lock() + s.stopping = true + running := make([]*infraProcess, 0, len(s.processes)) + unexpected := make([]*infraProcess, 0, len(s.processes)) + for _, process := range s.processes { + select { + case <-process.done: + unexpected = append(unexpected, process) + default: + running = append(running, process) + } + } + s.mu.Unlock() + + // Every command watches this same context, so all children receive SIGTERM + // before this function waits for any one of them. + s.cancelChildren() + +waitForProcesses: + for _, process := range running { + select { + case <-process.done: + case <-ctx.Done(): + break waitForProcesses + } + } + + errs := make([]error, 0, len(unexpected)+len(running)) + for _, process := range unexpected { + errs = append(errs, process.unexpectedExit()) + } + + // The caller's deadline is a bound on graceful shutdown, not permission to + // abandon an owned child. Kill every survivor, then let the sole Wait owner + // publish its result. The process-level watchdog remains the final bound if + // the operating system cannot reap a killed process. + if shutdownErr := ctx.Err(); shutdownErr != nil { + for _, process := range running { + select { + case <-process.done: + continue + default: + } + + errs = append(errs, fmt.Errorf( + "%s exceeded shutdown deadline: %w", + process.name, + shutdownErr, + )) + if err := process.process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { + errs = append(errs, fmt.Errorf( + "force %s shutdown: %w", + process.name, + err, + )) + } + } + } + + for _, process := range running { + <-process.done + errs = append(errs, process.shutdownError()) + } + + return errors.Join(errs...) +} + +// isIllegalInstruction checks if an error indicates SIGILL. +// This typically means the CPU lacks required instructions (e.g., AVX for MongoDB 5.0+). +func isIllegalInstruction(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "illegal instruction") +} + +// MongoAVXError indicates MongoDB failed due to missing AVX CPU support. +type MongoAVXError struct { + Cause error +} + +func (e *MongoAVXError) Error() string { + return fmt.Sprintf("mongodb requires AVX CPU support: %v", e.Cause) +} + +func (e *MongoAVXError) Unwrap() error { + return e.Cause +} + +// printMongoAVXError displays a user-friendly error message for AVX failures. +func printMongoAVXError() { + const msg = ` +βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ +β MongoDB failed to start: CPU does not support AVX instructions β +βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€ +β β +β MongoDB 5.0+ requires AVX CPU instructions, but your processor β +β does not support them. The process was terminated by the kernel β +β with SIGILL (Illegal Instruction). β +β β +β Solutions: β +β β’ Use external MongoDB 4.4 with the start-bundle command β +β β’ See compose.external.yml for example setup β +β β +β More info: https://github.com/grishy/any-sync-bundle/pull/39 β +β β +βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ +` + fmt.Fprint(os.Stderr, msg) +} + +// waitForTCPReady polls the address until a TCP connection succeeds or timeout is reached. +func waitForTCPReady(parent context.Context, addr string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + + dialer := &net.Dialer{ + Timeout: 100 * time.Millisecond, + } + + attempts := 0 + startTime := time.Now() + + for { + attempts++ + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err == nil { + _ = conn.Close() + elapsed := time.Since(startTime) + log.Info("TCP listener ready", + zap.String("addr", addr), + zap.Int("attempts", attempts), + zap.Duration("elapsed", elapsed)) + return nil + } + + if attempts%5 == 0 { + log.Debug("waiting for TCP listener", + zap.String("addr", addr), + zap.Int("attempts", attempts), + zap.Duration("elapsed", time.Since(startTime))) + } + + select { + case <-ctx.Done(): + waitErr := ctx.Err() + if isRootInterruption(parent, waitErr) { + return parent.Err() + } + return fmt.Errorf("TCP listener %s not ready (limit: %v, attempts: %d): %w", + addr, timeout, attempts, waitErr) + case <-time.After(100 * time.Millisecond): + } + } +} + +// waitForTCPOrExit polls the address until TCP connects, process exits, or timeout. +// Returns nil if TCP is ready. +// Returns process exit error if process dies. +// Returns timeout error if deadline reached. +func waitForTCPOrExit( + parent context.Context, + addr string, + timeout time.Duration, + process *infraProcess, +) error { + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + + dialer := &net.Dialer{ + Timeout: 100 * time.Millisecond, + } + + attempts := 0 + startTime := time.Now() + + for { + attempts++ + + // Check if process died + select { + case <-process.done: + return process.unexpectedExit() + default: + } + + // Try TCP connect + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err == nil { + _ = conn.Close() + log.Info("TCP listener ready", + zap.String("addr", addr), + zap.Int("attempts", attempts), + zap.Duration("elapsed", time.Since(startTime))) + return nil + } + + // Check for timeout + select { + case <-ctx.Done(): + waitErr := ctx.Err() + if isRootInterruption(parent, waitErr) { + return parent.Err() + } + return fmt.Errorf("TCP listener %s not ready (limit: %v, attempts: %d): %w", + addr, timeout, attempts, waitErr) + default: + } + + if attempts%5 == 0 { + log.Debug("waiting for TCP listener", + zap.String("addr", addr), + zap.Int("attempts", attempts), + zap.Duration("elapsed", time.Since(startTime))) + } + + // Wait before retry, watching for process exit + select { + case <-process.done: + return process.unexpectedExit() + case <-ctx.Done(): + waitErr := ctx.Err() + if isRootInterruption(parent, waitErr) { + return parent.Err() + } + return fmt.Errorf("TCP listener %s not ready (limit: %v, attempts: %d): %w", + addr, timeout, attempts, waitErr) + case <-time.After(100 * time.Millisecond): + } + } +} diff --git a/cmd/infra_process_test.go b/cmd/infra_process_test.go new file mode 100644 index 0000000..5d55553 --- /dev/null +++ b/cmd/infra_process_test.go @@ -0,0 +1,342 @@ +//go:build !windows + +package cmd + +import ( + "context" + "errors" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +// TestInfraProcessHelper runs as a child test binary. The ready file is +// written only after signal handling is installed, which makes the parent +// tests independent of scheduler timing. +// +//nolint:gocognit // One process fixture keeps every signal mode in the same executable boundary. +func TestInfraProcessHelper(t *testing.T) { + var args []string + for idx, arg := range os.Args { + if arg == "--" { + args = os.Args[idx+1:] + break + } + } + if len(args) == 0 { + return + } + + mode := args[0] + if mode == "exit" { + return + } + if len(args) < 2 { + t.Fatal("helper requires a ready-file path") + } + + readyPath := args[1] + if mode == "stubborn" { + signal.Ignore(syscall.SIGTERM) + if err := os.WriteFile(readyPath, nil, 0o600); err != nil { + t.Fatal(err) + } + select {} + } + + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGTERM) + t.Cleanup(func() { signal.Stop(signals) }) + if err := os.WriteFile(readyPath, nil, 0o600); err != nil { + t.Fatal(err) + } + + <-signals + if mode == "nonzero" { + signal.Stop(signals) + os.Exit(23) + } + if mode != "graceful" { + t.Fatalf("unknown helper mode %q", mode) + } + + if len(args) >= 3 { + signalPath := args[2] + if signalPath != "" { + if err := os.WriteFile(signalPath, nil, 0o600); err != nil { + t.Fatal(err) + } + } + } + if len(args) >= 4 { + peerSignalPath := args[3] + if peerSignalPath != "" { + for { + if _, err := os.Stat(peerSignalPath); err == nil { + break + } + time.Sleep(5 * time.Millisecond) + } + } + } +} + +func newInfraTestSuite( + t *testing.T, + waitDelay time.Duration, +) (*infraSuite, context.CancelFunc) { + t.Helper() + rootCtx, cancelRoot := context.WithCancel(t.Context()) + t.Cleanup(cancelRoot) + return newInfraSuite(rootCtx, cancelRoot, waitDelay), cancelRoot +} + +func startInfraTestProcess( + t *testing.T, + suite *infraSuite, + name string, + mode string, + readyPath string, + coordinationPaths ...string, +) *infraProcess { + t.Helper() + // Race-instrumented test binaries otherwise wait one second after printing + // PASS. That artificial exit delay would exercise WaitDelay instead of the + // helper's signal behavior when these tests use short deadlines. + t.Setenv("GORACE", strings.TrimSpace(os.Getenv("GORACE")+" atexit_sleep_ms=0")) + args := []string{"-test.run=^TestInfraProcessHelper$", "--", mode, readyPath} + args = append(args, coordinationPaths...) + process, err := suite.start(name, os.Args[0], args...) + if err != nil { + t.Fatalf("start %s helper: %v", name, err) + } + t.Cleanup(func() { + select { + case <-process.done: + return + default: + } + + // A failed assertion can bypass infraSuite.stop, so the fixture remains + // the final owner responsible for killing and reaping its child. + _ = process.process.Kill() + <-process.done + }) + if readyPath != "" { + waitForInfraTestFile(t, readyPath) + } + return process +} + +func waitForInfraTestFile(t *testing.T, path string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(path); err == nil { + return + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for helper file %s", path) + } + time.Sleep(5 * time.Millisecond) + } +} + +// Test cleanup is the final process owner when an assertion aborts before +// infraSuite.stop can run. +func TestStartInfraTestProcessCleansUpAbandonedProcess(t *testing.T) { + var process *infraProcess + t.Run("owner", func(t *testing.T) { + suite, _ := newInfraTestSuite(t, 50*time.Millisecond) + readyPath := filepath.Join(t.TempDir(), "ready") + process = startInfraTestProcess(t, suite, "mongo", "stubborn", readyPath) + }) + + select { + case <-process.done: + default: + _ = process.process.Kill() + <-process.done + t.Fatal("test cleanup returned before killing and reaping the child") + } +} + +// Start and running supervision. +func TestInfraSuiteDoesNotStartAfterRootCancellation(t *testing.T) { + suite, cancelRoot := newInfraTestSuite(t, time.Second) + cancelRoot() + + _, err := suite.start("redis", os.Args[0], "-test.run=^TestInfraProcessHelper$") + if !isRootInterruption(suite.rootCtx, err) { + t.Fatalf("expected exact root cancellation, got %v", err) + } +} + +// A child waiter both broadcasts cancellation and retains its concrete error. +// Context owns lifetime; the returned shutdown error owns the command outcome. +func TestInfraSuiteCancelsRootAndReportsUnexpectedExit(t *testing.T) { + suite, _ := newInfraTestSuite(t, time.Second) + startInfraTestProcess(t, suite, "redis", "exit", "") + select { + case <-suite.rootCtx.Done(): + case <-time.After(5 * time.Second): + t.Fatal("unexpected exit did not cancel the root context") + } + + shutdownCtx, cancelShutdown := context.WithTimeout(t.Context(), 2*time.Second) + defer cancelShutdown() + err := suite.stop(shutdownCtx) + if err == nil || !strings.Contains(err.Error(), "redis exited unexpectedly") { + t.Fatalf("unexpected child result was not returned: %v", err) + } +} + +// Root cancellation begins service shutdown, not child shutdown. A dependency +// that exits in that interval is still unexpected and must make the final +// result fail when ownership is handed to stop. +func TestInfraSuiteStopReportsProcessThatExitedBeforeHandoff(t *testing.T) { + suite, cancelRoot := newInfraTestSuite(t, time.Second) + process := startInfraTestProcess(t, suite, "redis", "exit", "") + + select { + case <-process.done: + case <-time.After(5 * time.Second): + t.Fatal("process did not exit") + } + cancelRoot() + + shutdownCtx, cancelShutdown := context.WithTimeout(t.Context(), 2*time.Second) + defer cancelShutdown() + err := suite.stop(shutdownCtx) + if err == nil { + t.Fatal("expected pre-handoff exit to fail shutdown") + } + if !strings.Contains(err.Error(), "redis exited unexpectedly") { + t.Fatalf("unexpected shutdown error: %v", err) + } +} + +// Graceful shutdown. + +// Root cancellation hands ownership to stop before child cancellation, so a +// graceful SIGTERM exit is expected and Wait must complete before stop returns. +func TestInfraSuiteStopGracefullyReapsProcess(t *testing.T) { + suite, cancelRoot := newInfraTestSuite(t, time.Second) + readyPath := filepath.Join(t.TempDir(), "ready") + process := startInfraTestProcess(t, suite, "mongo", "graceful", readyPath) + cancelRoot() + + shutdownCtx, cancelShutdown := context.WithTimeout(t.Context(), 2*time.Second) + defer cancelShutdown() + if err := suite.stop(shutdownCtx); err != nil { + t.Fatalf("graceful stop failed: %v", err) + } + + select { + case <-process.done: + default: + t.Fatal("stop returned before Wait reaped the process") + } +} + +// Both children wait for proof that the other received SIGTERM. A supervisor +// that signals one child and immediately waits for it deadlocks this test and +// is forced to kill the first child. +func TestInfraSuiteStopSignalsEveryProcessBeforeWaiting(t *testing.T) { + suite, cancelRoot := newInfraTestSuite(t, 200*time.Millisecond) + dir := t.TempDir() + mongoReady := filepath.Join(dir, "mongo-ready") + redisReady := filepath.Join(dir, "redis-ready") + mongoSignal := filepath.Join(dir, "mongo-signal") + redisSignal := filepath.Join(dir, "redis-signal") + + startInfraTestProcess(t, suite, "mongo", "graceful", mongoReady, mongoSignal, redisSignal) + startInfraTestProcess(t, suite, "redis", "graceful", redisReady, redisSignal, mongoSignal) + cancelRoot() + + shutdownCtx, cancelShutdown := context.WithTimeout(t.Context(), 2*time.Second) + defer cancelShutdown() + if err := suite.stop(shutdownCtx); err != nil { + t.Fatalf("children were not signaled together: %v", err) + } + waitForInfraTestFile(t, mongoSignal) + waitForInfraTestFile(t, redisSignal) +} + +func TestInfraSuiteStopReportsNonZeroGracefulExit(t *testing.T) { + suite, cancelRoot := newInfraTestSuite(t, time.Second) + readyPath := filepath.Join(t.TempDir(), "ready") + startInfraTestProcess(t, suite, "redis", "nonzero", readyPath) + cancelRoot() + + shutdownCtx, cancelShutdown := context.WithTimeout(t.Context(), 2*time.Second) + defer cancelShutdown() + err := suite.stop(shutdownCtx) + if err == nil { + t.Fatal("expected non-zero shutdown exit to fail") + } + if !strings.Contains(err.Error(), "redis") { + t.Fatalf("shutdown error does not identify Redis: %v", err) + } + if _, ok := errors.AsType[*exec.ExitError](err); !ok { + t.Fatalf("shutdown error lost the process result: %v", err) + } +} + +// Forced shutdown. + +func TestInfraSuiteStopKillsAndReapsSurvivor(t *testing.T) { + suite, cancelRoot := newInfraTestSuite(t, 50*time.Millisecond) + readyPath := filepath.Join(t.TempDir(), "ready") + process := startInfraTestProcess(t, suite, "mongo", "stubborn", readyPath) + cancelRoot() + + shutdownCtx, cancelShutdown := context.WithTimeout(t.Context(), 2*time.Second) + defer cancelShutdown() + err := suite.stop(shutdownCtx) + if err == nil { + t.Fatal("expected forced MongoDB shutdown to fail") + } + + select { + case <-process.done: + default: + t.Fatal("forced process was not reaped") + } + + exitErr, ok := errors.AsType[*exec.ExitError](process.waitErr) + if !ok { + t.Fatalf("expected process exit error, got %v", process.waitErr) + } + waitStatus, ok := exitErr.Sys().(syscall.WaitStatus) + if !ok || !waitStatus.Signaled() || waitStatus.Signal() != syscall.SIGKILL { + t.Fatalf("expected SIGKILL, got %v", exitErr) + } +} + +// A caller deadline may be shorter than Cmd.WaitDelay. Stop must force the +// child and wait for its sole waiter instead of returning an owned zombie. +func TestInfraSuiteStopReapsProcessAfterCallerDeadline(t *testing.T) { + suite, cancelRoot := newInfraTestSuite(t, 200*time.Millisecond) + readyPath := filepath.Join(t.TempDir(), "ready") + process := startInfraTestProcess(t, suite, "mongo", "stubborn", readyPath) + cancelRoot() + + shutdownCtx, cancelShutdown := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancelShutdown() + err := suite.stop(shutdownCtx) + + select { + case <-process.done: + default: + t.Fatal("stop returned before reaping the process") + } + if err == nil { + t.Fatal("expected forced shutdown to fail") + } +} diff --git a/cmd/infra_test.go b/cmd/infra_test.go new file mode 100644 index 0000000..9fc0465 --- /dev/null +++ b/cmd/infra_test.go @@ -0,0 +1,186 @@ +package cmd + +import ( + "context" + "errors" + "net" + "strings" + "testing" + "testing/synctest" + "time" +) + +func TestIsIllegalInstruction(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "mixed-case illegal instruction", + err: errors.New("Signal: Illegal Instruction"), + want: true, + }, + { + name: "unrelated error", + err: errors.New("connection refused"), + want: false, + }, + { + name: "nil error", + err: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isIllegalInstruction(tt.err) + if got != tt.want { + t.Errorf("isIllegalInstruction(%v) = %v, want %v", + tt.err, got, tt.want) + } + }) + } +} + +func TestWaitForTCPOrExit_ProcessDies(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + process := &infraProcess{ + done: make(chan struct{}), + } + + expectedErr := errors.New("signal: illegal instruction") + + go func() { + time.Sleep(10 * time.Millisecond) + process.name = "mongo" + process.waitErr = expectedErr + close(process.done) + }() + + err := waitForTCPOrExit( + context.Background(), + "127.0.0.1:59999", + 5*time.Second, + process, + ) + + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, expectedErr) { + t.Errorf("expected %v, got %v", expectedErr, err) + } + }) +} + +func TestWaitForTCPOrExit_TCPReady(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to create listener: %v", err) + } + defer listener.Close() + + process := &infraProcess{ + done: make(chan struct{}), + } + + err = waitForTCPOrExit( + context.Background(), + listener.Addr().String(), + 5*time.Second, + process, + ) + if err != nil { + t.Errorf("expected nil, got %v", err) + } +} + +func TestWaitForTCPOrExit_Timeout(t *testing.T) { + process := &infraProcess{ + done: make(chan struct{}), + } + + err := waitForTCPOrExit( + context.Background(), + "127.0.0.1:59999", + 200*time.Millisecond, + process, + ) + + if err == nil { + t.Fatal("expected timeout error, got nil") + } +} + +// A process that exits successfully before opening its listener is still a +// failed dependency. Treating a nil Wait result as readiness would leave the +// bundle running without the database it owns. +func TestWaitForTCPOrExitReportsCleanProcessExit(t *testing.T) { + process := &infraProcess{ + name: "redis", + done: make(chan struct{}), + } + close(process.done) + + err := waitForTCPOrExit( + context.Background(), + "127.0.0.1:59999", + time.Minute, + process, + ) + if err == nil { + t.Fatal("expected a clean early exit to be reported") + } + if !strings.Contains(err.Error(), "redis exited unexpectedly") { + t.Fatalf("unexpected error: %v", err) + } +} + +// Readiness is part of the root startup scope. Cancellation must interrupt the +// wait immediately rather than leave shutdown blocked behind its own timeout. +func TestWaitForTCPOrExitStopsOnParentCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + process := &infraProcess{done: make(chan struct{})} + err := waitForTCPOrExit(ctx, "127.0.0.1:59999", time.Minute, process) + if !isRootInterruption(ctx, err) { + t.Fatalf("expected exact parent cancellation, got %v", err) + } +} + +func TestWaitForTCPReadyStopsOnParentCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := waitForTCPReady(ctx, "127.0.0.1:59999", time.Minute) + if !isRootInterruption(ctx, err) { + t.Fatalf("expected exact parent cancellation, got %v", err) + } +} + +func TestStartAllInOneInfraStopsBeforeAcquiringResources(t *testing.T) { + ctx, cancelRoot := context.WithCancel(t.Context()) + cancelRoot() + infra := newInfraSuite(ctx, cancelRoot, time.Second) + + err := startAllInOneInfra(ctx, infra) + if !isRootInterruption(ctx, err) { + t.Fatalf("expected exact root cancellation, got %v", err) + } +} + +func TestMongoAVXError(t *testing.T) { + cause := errors.New("signal: illegal instruction") + err := &MongoAVXError{Cause: cause} + + want := "mongodb requires AVX CPU support: signal: illegal instruction" + if got := err.Error(); got != want { + t.Fatalf("Error() = %q, want %q", got, want) + } + if !errors.Is(err, cause) { + t.Fatalf("MongoAVXError lost its cause: %v", err) + } +} diff --git a/cmd/mongo.go b/cmd/mongo.go index 4ca25d4..b2c9933 100644 --- a/cmd/mongo.go +++ b/cmd/mongo.go @@ -15,17 +15,17 @@ import ( const ( // Timeouts for MongoDB operations. - mongoConnectTimeout = 10 * time.Second - mongoCommandTimeout = 5 * time.Second - mongoStabilizeWaitTime = 5 * time.Second + mongoConnectTimeout = 10 * time.Second + mongoCommandTimeout = 5 * time.Second + mongoStabilizationDelay = 5 * time.Second // Default MongoDB parameters. defaultMongoReplica = "rs0" ) func initReplicaSetAction(ctx context.Context, replica, mongoURI string) error { - // For exponential backoff, limit number of attempts. - retryDelays := []int{1, 2, 3, 5, 8, 13, 21, 34, 55, 89} + // The leading zero makes the first attempt immediate and a final delay impossible. + attemptDelaysSec := [...]int{0, 1, 2, 3, 5, 8, 13, 21, 34, 55} log.Info("initializing mongo replica set", zap.String("uri", mongoURI), @@ -34,50 +34,67 @@ func initReplicaSetAction(ctx context.Context, replica, mongoURI string) error { // Direct - before we have a replica set, we need it. clientOpts := options.Client().ApplyURI(mongoURI).SetDirect(true) - for _, delay := range retryDelays { - err := tryInitReplicaSet(ctx, clientOpts, replica) - if err == nil { + var lastErr error + for _, attemptDelaySec := range attemptDelaysSec { + attemptDelay := time.Duration(attemptDelaySec) * time.Second + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(attemptDelay): + } + + lastErr = tryInitReplicaSet(ctx, clientOpts, replica) + if lastErr == nil { log.Info("successfully initialized mongo replica set") return nil - } else if ctx.Err() != nil { - log.Error("context canceled while initializing mongo replica set", zap.Error(ctx.Err())) + } + if ctx.Err() != nil { return ctx.Err() } - - log.Warn("failed to initialize mongo replica set, retrying...", - zap.Error(err), - zap.Int("delay_seconds", delay)) - - time.Sleep(time.Duration(delay) * time.Second) } - return errors.New("failed to initialize mongo replica set after all retries") + return fmt.Errorf("failed to initialize mongo replica set after all retries: %w", lastErr) } func tryInitReplicaSet(ctx context.Context, clientOpts *options.ClientOptions, replica string) error { - ctxConn, cancel := context.WithTimeout(ctx, mongoConnectTimeout) + connCtx, cancel := context.WithTimeout(ctx, mongoConnectTimeout) defer cancel() log.Debug("connecting to mongo", zap.String("uri", clientOpts.GetURI())) - client, err := mongo.Connect(ctxConn, clientOpts) + client, err := mongo.Connect(connCtx, clientOpts) if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } return fmt.Errorf("failed to connect to mongo: %w", err) } defer func() { - if errDisconnect := client.Disconnect(ctx); errDisconnect != nil { - log.Error("failed to disconnect from mongo", zap.Error(errDisconnect)) + disconnectCtx, cancelDisconnect := context.WithTimeout( + context.WithoutCancel(ctx), + mongoCommandTimeout, + ) + defer cancelDisconnect() + if disconnectErr := client.Disconnect(disconnectCtx); disconnectErr != nil { + log.Error("failed to disconnect from mongo", zap.Error(disconnectErr)) } }() - errInit := initNewReplicaSet(ctx, client, replica, clientOpts.GetURI()) - if errInit == nil { + initErr := initNewReplicaSet(ctx, client, replica, clientOpts.GetURI()) + if initErr == nil { log.Info("successfully initialized new replica set, waiting for stabilization...") - time.Sleep(mongoStabilizeWaitTime) - return nil + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(mongoStabilizationDelay): + return nil + } + } + log.Warn("failed to initialize new replica set", zap.Error(initErr)) + if ctx.Err() != nil { + return ctx.Err() } - log.Warn("failed to initialize new replica set", zap.Error(errInit)) return checkReplicaSetStatus(ctx, client) } @@ -100,22 +117,22 @@ func initNewReplicaSet(ctx context.Context, client *mongo.Client, replica, uri s }}, } - ctxCmd, cancel := context.WithTimeout(ctx, mongoCommandTimeout) + cmdCtx, cancel := context.WithTimeout(ctx, mongoCommandTimeout) defer cancel() log.Debug("initializing new replica set") - return client.Database("admin").RunCommand(ctxCmd, cmd).Err() + return client.Database("admin").RunCommand(cmdCtx, cmd).Err() } func checkReplicaSetStatus(ctx context.Context, client *mongo.Client) error { - ctxCmd, cancel := context.WithTimeout(ctx, mongoCommandTimeout) + cmdCtx, cancel := context.WithTimeout(ctx, mongoCommandTimeout) defer cancel() log.Info("checking replica set status") var result bson.M err := client.Database("admin"). - RunCommand(ctxCmd, bson.D{{Key: "replSetGetStatus", Value: 1}}). + RunCommand(cmdCtx, bson.D{{Key: "replSetGetStatus", Value: 1}}). Decode(&result) if err != nil { log.Warn("replica set status check failed", zap.Error(err)) diff --git a/cmd/mongo_test.go b/cmd/mongo_test.go new file mode 100644 index 0000000..4d13089 --- /dev/null +++ b/cmd/mongo_test.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "context" + "errors" + "net" + "testing" + "testing/synctest" + "time" + + "go.mongodb.org/mongo-driver/mongo/options" +) + +const mongoURIWithInvalidOptions = "mongodb://localhost/?directConnection=invalid" + +// The lifecycle distinguishes an exact root cancellation from operational +// failures. A driver error produced after that cancellation must not turn an +// operator-requested stop into a non-zero exit. +func TestTryInitReplicaSetReturnsExactCancellation(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { + _ = listener.Close() + }) + if err = listener.(*net.TCPListener).SetDeadline(time.Now().Add(10 * time.Second)); err != nil { + t.Fatalf("set listener deadline: %v", err) + } + + ctx, cancel := context.WithCancel(t.Context()) + result := make(chan error, 1) + clientOpts := options.Client(). + ApplyURI("mongodb://" + listener.Addr().String() + "/"). + SetDirect(true) + go func() { + result <- tryInitReplicaSet(ctx, clientOpts, defaultMongoReplica) + }() + + connection, err := listener.Accept() + if err != nil { + t.Fatalf("accept MongoDB connection: %v", err) + } + cancel() + _ = connection.Close() + + select { + case err = <-result: + case <-time.After(10 * time.Second): + t.Fatal("replica-set attempt ignored cancellation") + } + if err != context.Canceled { //nolint:errorlint // The process boundary requires exact cancellation identity. + t.Fatalf("expected exact cancellation, got %v", err) + } +} + +// Cancellation owns the replica-set retry loop as well as each MongoDB call. +// A signal during backoff must not wait for the next retry delay to expire. +func TestInitReplicaSetActionCancellationInterruptsRetryDelay(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + const cancelAfter = 100 * time.Millisecond + ctx, cancel := context.WithCancel(t.Context()) + go func() { + time.Sleep(cancelAfter) + cancel() + }() + + started := time.Now() + err := initReplicaSetAction(ctx, defaultMongoReplica, mongoURIWithInvalidOptions) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected cancellation, got %v", err) + } + if elapsed := time.Since(started); elapsed != cancelAfter { + t.Fatalf("retry delay ignored cancellation: got %v want %v", elapsed, cancelAfter) + } + }) +} + +// Ten attempts need nine separating delays. A delay after the final failed +// attempt consumes shutdown budget without making another attempt possible. +func TestInitReplicaSetActionDoesNotWaitAfterFinalAttempt(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + const retryDelayTotal = 142 * time.Second + + started := time.Now() + err := initReplicaSetAction( + t.Context(), + defaultMongoReplica, + mongoURIWithInvalidOptions, + ) + if err == nil { + t.Fatal("expected replica-set initialization to fail") + } + if errors.Unwrap(err) == nil { + t.Fatal("final retry error does not preserve the last attempt failure") + } + if elapsed := time.Since(started); elapsed != retryDelayTotal { + t.Fatalf("unexpected retry delay total: got %v want %v", elapsed, retryDelayTotal) + } + }) +} diff --git a/cmd/root.go b/cmd/root.go index 53964cb..a1a22c6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -50,10 +50,10 @@ const ( var log = logger.NewNamed("cli") // Root returns the main CLI application with all commands and flags configured. -func Root(ctx context.Context) *cli.App { +func Root(ctx context.Context, cancelRoot context.CancelFunc) *cli.App { cli.VersionPrinter = versionPrinter - // Any-sync package, used in network communication but just for info. + // any-sync package, used in network communication but just for info. // Yes, this is global between all instances of the app... // TODO: Create issue to avoid global app and use app instance instead. app.AppName = appName @@ -72,8 +72,8 @@ func Root(ctx context.Context) *cli.App { Flags: buildGlobalFlags(), Before: setupLogger, Commands: []*cli.Command{ - cmdStartAllInOne(ctx), - cmdStartBundle(ctx), + cmdStartAllInOne(ctx, cancelRoot), + cmdStartBundle(ctx, cancelRoot), }, } } diff --git a/cmd/services.go b/cmd/services.go new file mode 100644 index 0000000..177bef9 --- /dev/null +++ b/cmd/services.go @@ -0,0 +1,267 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "slices" + "time" + + "github.com/anyproto/any-sync/app" + "go.uber.org/zap" + + "github.com/grishy/any-sync-bundle/config" +) + +type bundleService struct { + name string + app *app.App +} + +// startServices initializes and runs all bundle services using a custom two-phase approach. +// +// Why we can't use app.Start() directly: +// The bundle architecture has 4 separate apps (coordinator, consensus, filenode, sync) that +// share a single DRPC multiplexer from the coordinator's server component. If we call +// app.Start() sequentially on each service, a race condition occurs: +// +// 1. coordinator.Start() = Init (registers handlers) + Run (starts network listeners) +// 2. Network is now accepting connections and calling mux.HandleRPC() +// 3. consensus.Start() = Init tries to register handlers on the same mux +// 4. RACE: goroutine reads mux map (HandleRPC) while another writes to it (register) +func startServices( + ctx context.Context, + cancelRoot context.CancelFunc, + services []bundleService, + cfg *config.Config, +) error { + if err := ctx.Err(); err != nil { + return err + } + + log.Info("initiating service startup", zap.Int("count", len(services))) + log.Info("βββ Phase 1: Initializing all services βββ") + + rollbackCtx := context.WithoutCancel(ctx) + var initialized []bundleService + for _, service := range services { + partial, err := initOneApp(ctx, service) + if err != nil { + rootErr := ctx.Err() + cancelRoot() + shutdownCtx, cancelShutdown := context.WithTimeout(rollbackCtx, servicesShutdownTimeout) + partialErr := shutdownRunnables(shutdownCtx, service.name, partial) + shutdownErr := shutdownServices(shutdownCtx, initialized) + cancelShutdown() + cleanupErr := errors.Join(partialErr, shutdownErr) + return startupResult(rootErr, err, cleanupErr) + } + initialized = append(initialized, service) + } + log.Info("β all services initialized, all DRPC handlers registered") + + // Phase 2: Run all services + // Track which services have been successfully Run() to avoid closing + // components that were Init'd but never Run'd (they may have nil pointers). + log.Info("βββ Phase 2: Running all services βββ") + var running []bundleService + for _, service := range initialized { + partial, err := runOneApp(ctx, service, cfg) + if err != nil { + rootErr := ctx.Err() + cancelRoot() + shutdownCtx, cancelShutdown := context.WithTimeout(rollbackCtx, servicesShutdownTimeout) + partialErr := shutdownRunnables(shutdownCtx, service.name, partial) + shutdownErr := shutdownServices(shutdownCtx, running) + cancelShutdown() + cleanupErr := errors.Join(partialErr, shutdownErr) + return startupResult(rootErr, err, cleanupErr) + } + running = append(running, service) + } + log.Info("β all services running") + + return nil +} + +// initOneApp initializes all components for a single app. +func initOneApp( + ctx context.Context, + service bundleService, +) ([]app.ComponentRunnable, error) { + log.Info("βΆ initializing service", zap.String("name", service.name)) + + var firstError error + var initialized []app.ComponentRunnable + var failedRunnable app.ComponentRunnable + + service.app.IterateComponents(func(component app.Component) { + if firstError != nil { + return + } + if err := ctx.Err(); err != nil { + firstError = err + return + } + if err := component.Init(service.app); err != nil { + firstError = fmt.Errorf("component '%s': %w", component.Name(), err) + if runnable, ok := component.(app.ComponentRunnable); ok { + failedRunnable = runnable + } + return + } + + if runnable, ok := component.(app.ComponentRunnable); ok { + initialized = append(initialized, runnable) + } + }) + + if firstError == nil { + firstError = ctx.Err() + } + if firstError != nil { + if isRootInterruption(ctx, firstError) { + return initialized, firstError + } + if failedRunnable != nil { + initialized = append(initialized, failedRunnable) + } + return initialized, fmt.Errorf("service '%s' init failed: %w", service.name, firstError) + } + + log.Info("β service initialized", zap.String("name", service.name)) + return nil, nil +} + +// runOneApp runs all runnable components for a single app. +func runOneApp( + ctx context.Context, + service bundleService, + cfg *config.Config, +) ([]app.ComponentRunnable, error) { + log.Info("βΆ running service", zap.String("name", service.name)) + + var firstError error + var running []app.ComponentRunnable + var failedRunnable app.ComponentRunnable + + service.app.IterateComponents(func(component app.Component) { + if firstError != nil { + return + } + runnable, ok := component.(app.ComponentRunnable) + if !ok { + return + } + if err := ctx.Err(); err != nil { + firstError = err + return + } + if err := runnable.Run(ctx); err != nil { + if isRootInterruption(ctx, err) { + firstError = err + failedRunnable = runnable + return + } + firstError = fmt.Errorf("component '%s': %w", runnable.Name(), err) + failedRunnable = runnable + return + } + running = append(running, runnable) + }) + + if firstError != nil { + if failedRunnable != nil { + running = append(running, failedRunnable) + } + if isRootInterruption(ctx, firstError) { + return running, firstError + } + return running, fmt.Errorf("service '%s' run failed: %w", service.name, firstError) + } + + if service.name != "coordinator" { + log.Info("β service running", zap.String("name", service.name)) + return nil, nil + } + + addr := cfg.Network.ListenTCPAddr + log.Info("waiting for coordinator TCP listener", zap.String("addr", addr)) + if err := waitForTCPReady(ctx, addr, 5*time.Second); err != nil { + if isRootInterruption(ctx, err) { + return running, err + } + return running, fmt.Errorf("coordinator network not ready: %w", err) + } + + log.Info("coordinator network ready") + log.Info("β service running", zap.String("name", service.name)) + return nil, nil +} + +func shutdownRunnables( + ctx context.Context, + serviceName string, + runnables []app.ComponentRunnable, +) error { + if len(runnables) == 0 { + return nil + } + + log.Info("β‘ cleaning up partially started service", + zap.String("name", serviceName), + zap.Int("components", len(runnables))) + + var errs []error + for _, runnable := range slices.Backward(runnables) { + log.Info("βΆ stopping component", + zap.String("service", serviceName), + zap.String("component", runnable.Name())) + + if err := runnable.Close(ctx); err != nil { + errs = append(errs, fmt.Errorf( + "service %s component %s cleanup failed: %w", + serviceName, + runnable.Name(), + err, + )) + continue + } + + log.Info("β component cleaned up", + zap.String("service", serviceName), + zap.String("component", runnable.Name())) + } + + return errors.Join(errs...) +} + +func shutdownServices(ctx context.Context, services []bundleService) error { + log.Info("β‘ initiating service shutdown", zap.Int("count", len(services))) + + var errs []error + for _, service := range slices.Backward(services) { + log.Info("βΆ stopping service", zap.String("name", service.name)) + + if err := service.app.Close(ctx); err != nil { + errs = append(errs, + fmt.Errorf("service %s shutdown failed: %w", service.name, err)) + } else { + log.Info("β service stopped successfully", zap.String("name", service.name)) + } + } + + return errors.Join(errs...) +} + +// A root interruption remains the startup result only when cleanup adds no +// failure. Every other combination retains all concrete errors. +func startupResult(rootErr, startupErr, cleanupErr error) error { + if rootErr != nil && startupErr == rootErr { //nolint:errorlint // Error traversal would weaken the invariant. + if cleanupErr != nil { + return cleanupErr + } + return rootErr + } + return errors.Join(startupErr, cleanupErr) +} diff --git a/cmd/services_test.go b/cmd/services_test.go new file mode 100644 index 0000000..79c1cf3 --- /dev/null +++ b/cmd/services_test.go @@ -0,0 +1,398 @@ +package cmd + +import ( + "context" + "errors" + "slices" + "strings" + "testing" + "time" + + "github.com/anyproto/any-sync/app" +) + +type lifecycleTestRunnable struct { + name string + events *[]string + closeContexts *[]context.Context + onClose func() + onInit func() + onRun func() + initErr error + runErr error + closeErr error +} + +func (r *lifecycleTestRunnable) Init(*app.App) error { + *r.events = append(*r.events, "init:"+r.name) + if r.onInit != nil { + r.onInit() + } + return r.initErr +} + +func (r *lifecycleTestRunnable) Name() string { + return r.name +} + +func (r *lifecycleTestRunnable) Run(context.Context) error { + *r.events = append(*r.events, "run:"+r.name) + if r.onRun != nil { + r.onRun() + } + return r.runErr +} + +func (r *lifecycleTestRunnable) Close(ctx context.Context) error { + *r.events = append(*r.events, "close:"+r.name) + if r.onClose != nil { + r.onClose() + } + if r.closeContexts != nil { + *r.closeContexts = append(*r.closeContexts, ctx) + } + return r.closeErr +} + +func TestStartServicesRootCancellationIsCleanButCleanupFailureIsNot(t *testing.T) { + tests := []struct { + name string + closeErr error + }{ + {name: "clean interruption"}, + {name: "cleanup failure", closeErr: errors.New("close failed")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancelRoot := context.WithCancel(t.Context()) + events := []string{} + err := startServices( + ctx, + cancelRoot, + []bundleService{{ + name: "test", + app: new(app.App).Register(&lifecycleTestRunnable{ + name: "component", + events: &events, + onRun: cancelRoot, + runErr: context.Canceled, + closeErr: tt.closeErr, + }), + }}, + nil, + ) + + if tt.closeErr == nil { + if !isRootInterruption(ctx, err) { + t.Fatalf("clean interruption lost its exact identity: %v", err) + } + } else { + if isRootInterruption(ctx, err) || !errors.Is(err, tt.closeErr) { + t.Fatalf("cleanup failure was lost: %v", err) + } + } + }) + } +} + +func TestStartServicesStopsInitializingAfterRootCancellation(t *testing.T) { + ctx, cancelRoot := context.WithCancel(t.Context()) + events := []string{} + first := &lifecycleTestRunnable{ + name: "first", + events: &events, + onInit: cancelRoot, + } + second := &lifecycleTestRunnable{ + name: "second", + events: &events, + } + + err := startServices( + ctx, + cancelRoot, + []bundleService{{ + name: "test", + app: new(app.App).Register(first).Register(second), + }}, + nil, + ) + + if !isRootInterruption(ctx, err) { + t.Fatalf("root cancellation lost its exact identity: %v", err) + } + want := []string{"init:first", "close:first"} + if !slices.Equal(events, want) { + t.Fatalf("startup acquired resources after cancellation: got %v want %v", events, want) + } +} + +func TestStartServicesStopsRunningAfterRootCancellation(t *testing.T) { + ctx, cancelRoot := context.WithCancel(t.Context()) + events := []string{} + first := &lifecycleTestRunnable{ + name: "first", + events: &events, + onRun: cancelRoot, + } + second := &lifecycleTestRunnable{ + name: "second", + events: &events, + } + + err := startServices( + ctx, + cancelRoot, + []bundleService{{ + name: "test", + app: new(app.App).Register(first).Register(second), + }}, + nil, + ) + + if !isRootInterruption(ctx, err) { + t.Fatalf("root cancellation lost its exact identity: %v", err) + } + want := []string{ + "init:first", + "init:second", + "run:first", + "close:first", + } + if !slices.Equal(events, want) { + t.Fatalf("startup acquired resources after cancellation: got %v want %v", events, want) + } +} + +func TestStartServicesPreservesIndependentComponentCancellation(t *testing.T) { + ctx, cancelRoot := context.WithCancel(t.Context()) + events := []string{} + err := startServices( + ctx, + cancelRoot, + []bundleService{{ + name: "test", + app: new(app.App).Register(&lifecycleTestRunnable{ + name: "component", + events: &events, + runErr: context.Canceled, + }), + }}, + nil, + ) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("independent component cancellation was lost: %v", err) + } + if isRootInterruption(ctx, err) { + t.Fatal("independent component cancellation became a clean root interruption") + } +} + +func TestStartServicesPreservesFailureJoinedWithRootCancellation(t *testing.T) { + ctx, cancelRoot := context.WithCancel(t.Context()) + persistenceErr := errors.New("persistence failed") + events := []string{} + err := startServices( + ctx, + cancelRoot, + []bundleService{{ + name: "test", + app: new(app.App).Register(&lifecycleTestRunnable{ + name: "component", + events: &events, + onRun: cancelRoot, + runErr: errors.Join(context.Canceled, persistenceErr), + }), + }}, + nil, + ) + + if isRootInterruption(ctx, err) || !errors.Is(err, persistenceErr) { + t.Fatalf("joined component failure was lost: %v", err) + } +} + +func TestStartServicesCancelsRootBeforeRollback(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + initErr := errors.New("init failed") + events := []string{} + var rootErrAtClose error + runnable := &lifecycleTestRunnable{ + name: "failing", + events: &events, + initErr: initErr, + onClose: func() { + rootErrAtClose = ctx.Err() + }, + } + + err := startServices(ctx, cancel, []bundleService{{ + name: "test", + app: new(app.App).Register(runnable), + }}, nil) + if err == nil { + t.Fatal("expected startup error") + } + if !errors.Is(rootErrAtClose, context.Canceled) { + t.Fatalf("rollback began before root cancellation: %v", rootErrAtClose) + } +} + +// The bundle apps depend on earlier apps, so shutdown uses one deadline in +// reverse order and still attempts every close after a failure. +func TestShutdownServicesClosesEverythingInReverseUnderOneDeadline(t *testing.T) { + events := []string{} + closeContexts := []context.Context{} + first := &lifecycleTestRunnable{ + name: "first", + events: &events, + closeContexts: &closeContexts, + closeErr: errors.New("first close failed"), + } + second := &lifecycleTestRunnable{ + name: "second", + events: &events, + closeContexts: &closeContexts, + closeErr: errors.New("second close failed"), + } + + shutdownCtx, cancelShutdown := context.WithTimeout(t.Context(), time.Minute) + defer cancelShutdown() + shutdownDeadline, _ := shutdownCtx.Deadline() + err := shutdownServices(shutdownCtx, []bundleService{ + {name: "one", app: new(app.App).Register(first)}, + {name: "two", app: new(app.App).Register(second)}, + }) + if err == nil { + t.Fatal("expected shutdown errors") + } + for _, message := range []string{"one", "first close failed", "two", "second close failed"} { + if !strings.Contains(err.Error(), message) { + t.Fatalf("shutdown error does not contain %q: %v", message, err) + } + } + want := []string{"close:second", "close:first"} + if !slices.Equal(events, want) { + t.Fatalf("unexpected close order: got %v want %v", events, want) + } + if len(closeContexts) != len(want) { + t.Fatalf("unexpected context count: got %d want %d", len(closeContexts), len(want)) + } + for _, closeCtx := range closeContexts { + closeDeadline, ok := closeCtx.Deadline() + if !ok || !closeDeadline.Equal(shutdownDeadline) { + t.Fatalf("service received a different shutdown deadline: %v", closeDeadline) + } + } +} + +func TestStartServicesInitFailureClosesInitializedComponentsAndReturnsEveryError(t *testing.T) { + events := []string{} + first := &lifecycleTestRunnable{ + name: "first", + events: &events, + closeErr: errors.New("first close failed"), + } + second := &lifecycleTestRunnable{ + name: "second", + events: &events, + initErr: errors.New("init failed"), + closeErr: errors.New("second close failed"), + } + + err := startServices( + context.Background(), + func() {}, + []bundleService{{ + name: "test", + app: new(app.App).Register(first).Register(second), + }}, + nil, + ) + if err == nil { + t.Fatal("expected init and cleanup errors") + } + for _, message := range []string{"init failed", "first close failed", "second close failed"} { + if !strings.Contains(err.Error(), message) { + t.Fatalf("error does not contain %q: %v", message, err) + } + } + want := []string{ + "init:first", + "init:second", + "close:second", + "close:first", + } + if !slices.Equal(events, want) { + t.Fatalf("unexpected lifecycle: got %v want %v", events, want) + } +} + +func TestStartServices_RunFailureClosesCurrentAndPreviousServices(t *testing.T) { + events := []string{} + closeContexts := []context.Context{} + firstService := &lifecycleTestRunnable{ + name: "one", + events: &events, + closeContexts: &closeContexts, + } + secondServiceFirst := &lifecycleTestRunnable{ + name: "two-a", + events: &events, + closeContexts: &closeContexts, + } + secondServiceSecond := &lifecycleTestRunnable{ + name: "two-b", + events: &events, + closeContexts: &closeContexts, + runErr: errors.New("boom"), + } + + appOne := new(app.App).Register(firstService) + appTwo := new(app.App). + Register(secondServiceFirst). + Register(secondServiceSecond) + + err := startServices( + context.Background(), + func() {}, + []bundleService{ + {name: "svc-one", app: appOne}, + {name: "svc-two", app: appTwo}, + }, + nil, + ) + if err == nil { + t.Fatal("expected run error, got nil") + } + + want := []string{ + "init:one", + "init:two-a", + "init:two-b", + "run:one", + "run:two-a", + "run:two-b", + "close:two-b", + "close:two-a", + "close:one", + } + if len(events) != len(want) { + t.Fatalf("unexpected event count: got %v want %v", events, want) + } + for idx := range want { + if events[idx] != want[idx] { + t.Fatalf("unexpected events: got %v want %v", events, want) + } + } + if len(closeContexts) != 3 { + t.Fatalf("unexpected close context count: %d", len(closeContexts)) + } + for _, closeCtx := range closeContexts[1:] { + if closeCtx != closeContexts[0] { + t.Fatal("startup rollback used more than one shutdown context") + } + } +} diff --git a/cmd/start.go b/cmd/start.go index 774580b..d037995 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -1,139 +1,173 @@ package cmd import ( - "bufio" "context" "errors" "fmt" - "io" - "net" "net/http" "net/http/pprof" "os" - "os/exec" "path/filepath" "runtime/debug" - "slices" "strings" "time" - "github.com/anyproto/any-sync/app" "github.com/anyproto/any-sync/app/logger" "github.com/urfave/cli/v2" "go.uber.org/zap" - bundleConfig "github.com/grishy/any-sync-bundle/config" + "github.com/grishy/any-sync-bundle/config" "github.com/grishy/any-sync-bundle/lightnode" ) -type node struct { - name string - app *app.App -} - const ( - serviceShutdownTimeout = 10 * time.Second - clientConfigMode = 0o644 - + // Bundle services share one shutdown deadline. + servicesShutdownTimeout = 30 * time.Second + + // WaitDelay is the SIGTERM grace period before os/exec sends SIGKILL. + infraProcessWaitDelay = 60 * time.Second + // Allow cmd.Wait to publish the exit after a forced kill. + infraProcessReapMargin = 5 * time.Second + infraShutdownTimeout = infraProcessWaitDelay + infraProcessReapMargin + + // Keep the process watchdog outside all cleanup deadlines. + shutdownWatchdogMargin = 10 * time.Second + // ShutdownTimeout bounds process shutdown after root cancellation. Container + // stop grace periods must be longer so the application owns forced exit. + ShutdownTimeout = servicesShutdownTimeout + infraShutdownTimeout + shutdownWatchdogMargin + + // Lifecycle events. bundleReadyEvent = "bundle_ready" bundleShutdownCompleteEvent = "bundle_shutdown_complete" - - dockerMongoPort = "27017" - dockerRedisPort = "6379" - dockerMongoURI = "mongodb://127.0.0.1:27017/" - dockerMongoMajorityURI = "mongodb://127.0.0.1:27017/?w=majority" - dockerRedisURI = "redis://127.0.0.1:6379/" - dockerMongoDataDir = "/data/mongo" - dockerRedisDataDir = "/data/redis" ) -func cmdStartAllInOne(ctx context.Context) *cli.Command { +func cmdStartAllInOne(ctx context.Context, cancelRoot context.CancelFunc) *cli.Command { return &cli.Command{ Name: "start-all-in-one", Usage: "Start bundle together with embedded MongoDB and Redis", Flags: buildStartFlags(), - Action: func(cCtx *cli.Context) error { + Action: func(c *cli.Context) error { if err := assertContainerRuntime(); err != nil { return err } printWelcomeMsg() - bundleCfg, err := prepareBundleConfig(cCtx) + bundleCfg, err := prepareBundleConfig(c) if err != nil { return err } applyAllInOneDefaults(bundleCfg) + startPprofServer(ctx, c) + + infra := newInfraSuite(ctx, cancelRoot, infraProcessWaitDelay) + + startErr := startAllInOneInfra(ctx, infra) + var bundleErr error + if startErr != nil { + rootInterrupted := isRootInterruption(ctx, startErr) + cancelRoot() + if rootInterrupted { + startErr = nil + } + } else { + bundleErr = runBundleServices(ctx, cancelRoot, bundleCfg) + } - // Start pprof server if enabled - startPprofServer(ctx, cCtx) + shutdownCtx, cancelShutdown := context.WithTimeout( + context.WithoutCancel(ctx), + infraShutdownTimeout, + ) + infraErr := infra.stop(shutdownCtx) + cancelShutdown() - infra, err := startAllInOneInfra(ctx) - if err != nil { - return err + resultErr := errors.Join(startErr, bundleErr, infraErr) + if resultErr != nil { + return resultErr } - defer infra.stop() - - return runBundleServices(ctx, bundleCfg) + reportShutdownComplete() + return nil }, } } -func cmdStartBundle(ctx context.Context) *cli.Command { +func cmdStartBundle(ctx context.Context, cancelRoot context.CancelFunc) *cli.Command { return &cli.Command{ Name: "start-bundle", Usage: "Start bundle services and use external MongoDB/Redis", Flags: buildStartFlags(), - Action: func(cCtx *cli.Context) error { + Action: func(c *cli.Context) error { printWelcomeMsg() - bundleCfg, err := prepareBundleConfig(cCtx) + bundleCfg, err := prepareBundleConfig(c) if err != nil { return err } - // Start pprof server if enabled - startPprofServer(ctx, cCtx) + startPprofServer(ctx, c) - return runBundleServices(ctx, bundleCfg) + err = runBundleServices(ctx, cancelRoot, bundleCfg) + if err != nil { + return err + } + reportShutdownComplete() + return nil }, } } -func runBundleServices(ctx context.Context, bundleCfg *bundleConfig.Config) error { +func runBundleServices( + ctx context.Context, + cancelRoot context.CancelFunc, + bundleCfg *config.Config, +) error { printConfigurationInfo(bundleCfg) - cfgNodes := bundleCfg.NodeConfigs() - bundle := lightnode.NewBundle(cfgNodes) + nodeCfgs := bundleCfg.NodeConfigs() + bundle := lightnode.NewBundle(nodeCfgs) - apps := []node{ + services := []bundleService{ {name: "coordinator", app: bundle.Coordinator}, {name: "consensus", app: bundle.Consensus}, {name: "filenode", app: bundle.FileNode}, {name: "sync", app: bundle.Sync}, } - if err := startServices(ctx, apps, bundleCfg); err != nil { + if err := startServices(ctx, cancelRoot, services, bundleCfg); err != nil { + if isRootInterruption(ctx, err) { + return nil + } return err } - emitBundleEvent(bundleReadyEvent) - printStartupMsg() - - <-ctx.Done() + select { + case <-ctx.Done(): + default: + emitBundleEvent(bundleReadyEvent) + printStartupMsg() + <-ctx.Done() + } - shutdownServices(apps) - emitBundleEvent(bundleShutdownCompleteEvent) - printShutdownMsg() + shutdownCtx, cancelShutdown := context.WithTimeout( + context.WithoutCancel(ctx), + servicesShutdownTimeout, + ) + shutdownErr := shutdownServices(shutdownCtx, services) + cancelShutdown() + return shutdownErr +} - log.Info("β Goodbye!") - return nil +// A wrapped or joined interruption may contain another failure, so only the +// exact root error represents an operator-requested stop. +func isRootInterruption(ctx context.Context, err error) bool { + rootErr := ctx.Err() + return rootErr != nil && err == rootErr //nolint:errorlint // Error traversal would weaken the invariant. } -func prepareBundleConfig(cCtx *cli.Context) (*bundleConfig.Config, error) { - bundleCfg := loadOrCreateConfig(cCtx, log) - clientCfgPath := cCtx.String(flagStartClientConfigPath) +func prepareBundleConfig(c *cli.Context) (*config.Config, error) { + bundleCfg := loadOrCreateConfig(c, log) + clientCfgPath := c.String(flagStartClientConfigPath) if err := writeClientConfig(bundleCfg, clientCfgPath); err != nil { return nil, err @@ -142,35 +176,37 @@ func prepareBundleConfig(cCtx *cli.Context) (*bundleConfig.Config, error) { return bundleCfg, nil } -func loadOrCreateConfig(cCtx *cli.Context, log logger.CtxLogger) *bundleConfig.Config { - cfgPath := cCtx.String(flagStartBundleConfigPath) +func loadOrCreateConfig(c *cli.Context, log logger.CtxLogger) *config.Config { + cfgPath := c.String(flagStartBundleConfigPath) log.Info("loading config") if _, err := os.Stat(cfgPath); err == nil { log.Info("loaded existing config") - return bundleConfig.Load(cfgPath) + return config.Load(cfgPath) } log.Info("creating new config") - return bundleConfig.CreateWrite(&bundleConfig.CreateOptions{ + return config.CreateWrite(&config.CreateOptions{ CfgPath: cfgPath, - StorePath: cCtx.String(flagStartStoragePath), - MongoURI: cCtx.String(flagStartMongoURI), - RedisURI: cCtx.String(flagStartRedisURI), - ExternalAddrs: cCtx.StringSlice(flagStartExternalAddrs), + StorePath: c.String(flagStartStoragePath), + MongoURI: c.String(flagStartMongoURI), + RedisURI: c.String(flagStartRedisURI), + ExternalAddrs: c.StringSlice(flagStartExternalAddrs), // S3 configuration (optional) - credentials via AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY env vars - S3Bucket: cCtx.String(flagStartS3Bucket), - S3Endpoint: cCtx.String(flagStartS3Endpoint), - S3Region: cCtx.String(flagStartS3Region), - S3ForcePathStyle: cCtx.Bool(flagStartS3ForcePathStyle), + S3Bucket: c.String(flagStartS3Bucket), + S3Endpoint: c.String(flagStartS3Endpoint), + S3Region: c.String(flagStartS3Region), + S3ForcePathStyle: c.Bool(flagStartS3ForcePathStyle), // Filenode configuration - FilenodeDefaultLimit: cCtx.Uint64(flagStartFilenodeDefaultLimit), + FilenodeDefaultLimit: c.Uint64(flagStartFilenodeDefaultLimit), }) } -func writeClientConfig(cfg *bundleConfig.Config, path string) error { +func writeClientConfig(cfg *config.Config, path string) error { + const clientConfigMode = 0o644 + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return fmt.Errorf("failed to create client config directory: %w", err) } @@ -180,535 +216,14 @@ func writeClientConfig(cfg *bundleConfig.Config, path string) error { return fmt.Errorf("failed to generate client config: %w", err) } - if writeErr := os.WriteFile(path, yamlData, clientConfigMode); writeErr != nil { - return fmt.Errorf("failed to write client config: %w", writeErr) + if err = os.WriteFile(path, yamlData, clientConfigMode); err != nil { + return fmt.Errorf("failed to write client config: %w", err) } log.Info("client configuration written", zap.String("path", path)) return nil } -func startAllInOneInfra(ctx context.Context) (*infraSuite, error) { - // Create required data directories with proper permissions - if err := os.MkdirAll(dockerMongoDataDir, 0o750); err != nil { - return nil, fmt.Errorf("failed to create mongo data dir: %w", err) - } - if err := os.MkdirAll(dockerRedisDataDir, 0o750); err != nil { - return nil, fmt.Errorf("failed to create redis data dir: %w", err) - } - - log.Info("data directories prepared", - zap.String("mongo", dockerMongoDataDir), - zap.String("redis", dockerRedisDataDir)) - - mongoArgs := []string{ - "--port", dockerMongoPort, - "--dbpath", dockerMongoDataDir, - "--replSet", defaultMongoReplica, - "--bind_ip", "127.0.0.1", - } - - log.Info("starting embedded MongoDB", - zap.String("addr", "127.0.0.1:"+dockerMongoPort), - zap.String("dbpath", dockerMongoDataDir)) - - mongoProc, mongoErr := newInfraProcess(ctx, "mongo", "mongod", mongoArgs...) - if mongoErr != nil { - return nil, fmt.Errorf("start mongod: %w", mongoErr) - } - - redisArgs := []string{ - "--port", dockerRedisPort, - "--dir", dockerRedisDataDir, - "--appendonly", "yes", - "--maxmemory", "256mb", - "--maxmemory-policy", "noeviction", - "--protected-mode", "no", - "--bind", "127.0.0.1", - "--loadmodule", "/opt/redis-stack/lib/redisbloom.so", - } - - log.Info("starting embedded Redis", - zap.String("addr", "127.0.0.1:"+dockerRedisPort), - zap.String("dir", dockerRedisDataDir)) - - redisProc, redisErr := newInfraProcess(ctx, "redis", "redis-server", redisArgs...) - if redisErr != nil { - mongoProc.stop() - _ = mongoProc.wait() - return nil, fmt.Errorf("start redis-server: %w", redisErr) - } - - suite := &infraSuite{ - processes: []*infraProcess{mongoProc, redisProc}, - } - - // Wait for MongoDB TCP ready (or process death) - mongoAddr := net.JoinHostPort("127.0.0.1", dockerMongoPort) - if err := waitForTCPOrExit(mongoAddr, 180*time.Second, mongoProc); err != nil { - suite.stop() - if isIllegalInstruction(err) { - printMongoAVXError() - return nil, &MongoAVXError{Cause: err} - } - return nil, fmt.Errorf("mongodb not ready: %w", err) - } - - if initErr := initReplicaSetAction(ctx, defaultMongoReplica, dockerMongoURI); initErr != nil { - suite.stop() - return nil, fmt.Errorf("init replica set: %w", initErr) - } - - // Wait for Redis TCP ready (or process death) - redisAddr := net.JoinHostPort("127.0.0.1", dockerRedisPort) - if err := waitForTCPOrExit(redisAddr, 30*time.Second, redisProc); err != nil { - suite.stop() - return nil, fmt.Errorf("redis not ready: %w", err) - } - - return suite, nil -} - -func applyAllInOneDefaults(cfg *bundleConfig.Config) { - cfg.Coordinator.MongoConnect = dockerMongoURI - cfg.Consensus.MongoConnect = dockerMongoMajorityURI - cfg.FileNode.RedisConnect = dockerRedisURI -} - -type infraProcess struct { - name string - cmd *exec.Cmd - done chan struct{} // Closed when process exits - exitErr error // Set when process exits -} - -func newInfraProcess(ctx context.Context, name, bin string, args ...string) (*infraProcess, error) { - cmd := exec.CommandContext(ctx, bin, args...) - - stdout, pipeErr := cmd.StdoutPipe() - if pipeErr != nil { - return nil, fmt.Errorf("failed to capture stdout for %s: %w", name, pipeErr) - } - - stderr, errPipe := cmd.StderrPipe() - if errPipe != nil { - return nil, fmt.Errorf("failed to capture stderr for %s: %w", name, errPipe) - } - - if startErr := cmd.Start(); startErr != nil { - return nil, fmt.Errorf("failed to start %s: %w", name, startErr) - } - - p := &infraProcess{ - name: name, - cmd: cmd, - done: make(chan struct{}), - } - - go func() { - p.exitErr = cmd.Wait() - close(p.done) - }() - - go streamPipe(name, stdout) - go streamPipe(name, stderr) - - return p, nil -} - -func (p *infraProcess) stop() { - if p == nil || p.cmd.Process == nil { - return - } - - if p.cmd.ProcessState != nil && p.cmd.ProcessState.Exited() { - return - } - - if err := p.cmd.Process.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) { - log.Warn("failed to interrupt process", - zap.String("process", p.name), - zap.Error(err)) - } -} - -func (p *infraProcess) wait() error { - if p == nil { - return nil - } - - <-p.done - return p.exitErr -} - -type infraSuite struct { - processes []*infraProcess -} - -func (s *infraSuite) stop() { - if s == nil { - return - } - - for _, p := range s.processes { - p.stop() - } - - for _, p := range s.processes { - if err := p.wait(); err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, os.ErrProcessDone) { - log.Debug("process terminated with error", - zap.String("process", p.name), - zap.Error(err)) - } - } -} - -func streamPipe(name string, reader io.Reader) { - scanner := bufio.NewScanner(reader) - buf := make([]byte, 0, 64*1024) - scanner.Buffer(buf, 1024*1024) - - for scanner.Scan() { - fmt.Printf("[%s] %s\n", name, scanner.Text()) - } - - if err := scanner.Err(); err != nil && !errors.Is(err, io.EOF) { - log.Warn("log stream error", - zap.String("process", name), - zap.Error(err)) - } -} - -// isIllegalInstruction checks if an error indicates SIGILL. -// This typically means the CPU lacks required instructions (e.g., AVX for MongoDB 5.0+). -func isIllegalInstruction(err error) bool { - if err == nil { - return false - } - return strings.Contains(strings.ToLower(err.Error()), "illegal instruction") -} - -// MongoAVXError indicates MongoDB failed due to missing AVX CPU support. -type MongoAVXError struct { - Cause error -} - -func (e *MongoAVXError) Error() string { - return fmt.Sprintf("mongodb requires AVX CPU support: %v", e.Cause) -} - -func (e *MongoAVXError) Unwrap() error { - return e.Cause -} - -// printMongoAVXError displays a user-friendly error message for AVX failures. -func printMongoAVXError() { - const msg = ` -βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -β MongoDB failed to start: CPU does not support AVX instructions β -βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€ -β β -β MongoDB 5.0+ requires AVX CPU instructions, but your processor β -β does not support them. The process was terminated by the kernel β -β with SIGILL (Illegal Instruction). β -β β -β Solutions: β -β β’ Use external MongoDB 4.4 with the start-bundle command β -β β’ See compose.external.yml for example setup β -β β -β More info: https://github.com/grishy/any-sync-bundle/pull/39 β -β β -βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -` - fmt.Fprint(os.Stderr, msg) -} - -// waitForTCPReady polls the address until a TCP connection succeeds or timeout is reached. -func waitForTCPReady(addr string, timeout time.Duration) error { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - dialer := &net.Dialer{ - Timeout: 100 * time.Millisecond, - } - - attempt := 0 - startTime := time.Now() - - for { - attempt++ - conn, err := dialer.DialContext(ctx, "tcp", addr) - if err == nil { - _ = conn.Close() - elapsed := time.Since(startTime) - log.Info("TCP listener ready", - zap.String("addr", addr), - zap.Int("attempts", attempt), - zap.Duration("elapsed", elapsed)) - return nil - } - - select { - case <-ctx.Done(): - return fmt.Errorf("TCP listener not ready after %v (attempts: %d): %w", timeout, attempt, ctx.Err()) - default: - } - - if attempt%5 == 0 { - log.Debug("waiting for TCP listener", - zap.String("addr", addr), - zap.Int("attempts", attempt), - zap.Duration("elapsed", time.Since(startTime))) - } - - time.Sleep(100 * time.Millisecond) - } -} - -// waitForTCPOrExit polls the address until TCP connects, process exits, or timeout. -// Returns nil if TCP is ready. -// Returns process exit error if process dies. -// Returns timeout error if deadline reached. -func waitForTCPOrExit(addr string, timeout time.Duration, proc *infraProcess) error { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - dialer := &net.Dialer{ - Timeout: 100 * time.Millisecond, - } - - attempt := 0 - startTime := time.Now() - - for { - attempt++ - - // Check if process died - select { - case <-proc.done: - return proc.exitErr - default: - } - - // Try TCP connect - conn, err := dialer.DialContext(ctx, "tcp", addr) - if err == nil { - _ = conn.Close() - log.Info("TCP listener ready", - zap.String("addr", addr), - zap.Int("attempts", attempt), - zap.Duration("elapsed", time.Since(startTime))) - return nil - } - - // Check for timeout - select { - case <-ctx.Done(): - return fmt.Errorf("timeout after %v (attempts: %d)", timeout, attempt) - default: - } - - if attempt%5 == 0 { - log.Debug("waiting for TCP listener", - zap.String("addr", addr), - zap.Int("attempts", attempt), - zap.Duration("elapsed", time.Since(startTime))) - } - - // Wait before retry, watching for process exit - select { - case <-proc.done: - return proc.exitErr - case <-ctx.Done(): - return fmt.Errorf("timeout after %v (attempts: %d)", timeout, attempt) - case <-time.After(100 * time.Millisecond): - } - } -} - -// startServices initializes and runs all bundle services using a custom two-phase approach. -// -// Why we can't use app.Start() directly: -// The bundle architecture has 4 separate apps (coordinator, consensus, filenode, sync) that -// share a single DRPC multiplexer from the coordinator's server component. If we call -// app.Start() sequentially on each service, a race condition occurs: -// -// 1. coordinator.Start() = Init (registers handlers) + Run (starts network listeners) -// 2. Network is now accepting connections and calling mux.HandleRPC() -// 3. consensus.Start() = Init tries to register handlers on the same mux -// 4. RACE: goroutine reads mux map (HandleRPC) while another writes to it (register) -func startServices(ctx context.Context, apps []node, cfg *bundleConfig.Config) error { - log.Info("initiating service startup", zap.Int("count", len(apps))) - log.Info("βββ Phase 1: Initializing all services βββ") - - initialized := []node{} - for _, app := range apps { - if err := initOneApp(app); err != nil { - shutdownServices(initialized) - return err - } - initialized = append(initialized, app) - } - log.Info("β all services initialized, all DRPC handlers registered") - - // Phase 2: Run all services - // Track which services have been successfully Run() to avoid closing - // components that were Init'd but never Run'd (they may have nil pointers). - log.Info("βββ Phase 2: Running all services βββ") - running := []node{} - for _, app := range initialized { - if err := runOneApp(ctx, app, cfg); err != nil { - shutdownServices(running) - return err - } - running = append(running, app) - } - log.Info("β all services running") - - return nil -} - -// initOneApp initializes all components for a single app. -func initOneApp(n node) error { - log.Info("βΆ initializing service", zap.String("name", n.name)) - - var firstError error - var initialized []app.ComponentRunnable - var failedRunnable app.ComponentRunnable - - n.app.IterateComponents(func(c app.Component) { - if firstError != nil { - return - } - if err := c.Init(n.app); err != nil { - firstError = fmt.Errorf("component '%s': %w", c.Name(), err) - if runnable, ok := c.(app.ComponentRunnable); ok { - failedRunnable = runnable - } - log.Error("component init failed", - zap.String("service", n.name), - zap.String("component", c.Name()), - zap.Error(err)) - return - } - - if runnable, ok := c.(app.ComponentRunnable); ok { - initialized = append(initialized, runnable) - } - }) - - if firstError != nil { - if failedRunnable != nil { - initialized = append(initialized, failedRunnable) - } - shutdownRunnables(n.name, initialized) - return fmt.Errorf("service '%s' init failed: %w", n.name, firstError) - } - - log.Info("β service initialized", zap.String("name", n.name)) - return nil -} - -// runOneApp runs all runnable components for a single app. -func runOneApp(ctx context.Context, n node, cfg *bundleConfig.Config) error { - log.Info("βΆ running service", zap.String("name", n.name)) - - var firstError error - var running []app.ComponentRunnable - var failedRunnable app.ComponentRunnable - - n.app.IterateComponents(func(c app.Component) { - if firstError != nil { - return - } - if runnable, ok := c.(app.ComponentRunnable); ok { - if err := runnable.Run(ctx); err != nil { - firstError = fmt.Errorf("component '%s': %w", runnable.Name(), err) - failedRunnable = runnable - log.Error("component run failed", - zap.String("service", n.name), - zap.String("component", runnable.Name()), - zap.Error(err)) - return - } - running = append(running, runnable) - } - }) - - if firstError != nil { - if failedRunnable != nil { - running = append(running, failedRunnable) - } - shutdownRunnables(n.name, running) - return fmt.Errorf("service '%s' run failed: %w", n.name, firstError) - } - - // Coordinator-specific: wait for network to be ready - if n.name == "coordinator" { - addr := cfg.Network.ListenTCPAddr - log.Info("waiting for coordinator TCP listener", zap.String("addr", addr)) - - if err := waitForTCPReady(addr, 5*time.Second); err != nil { - shutdownRunnables(n.name, running) - return fmt.Errorf("coordinator network not ready: %w", err) - } - - log.Info("coordinator network ready") - } - - log.Info("β service running", zap.String("name", n.name)) - return nil -} - -func shutdownRunnables(serviceName string, runnables []app.ComponentRunnable) { - if len(runnables) == 0 { - return - } - - log.Info("β‘ cleaning up partially started service", - zap.String("name", serviceName), - zap.Int("components", len(runnables))) - - ctx, cancel := context.WithTimeout(context.Background(), serviceShutdownTimeout) - defer cancel() - - for _, runnable := range slices.Backward(runnables) { - log.Info("βΆ stopping component", - zap.String("service", serviceName), - zap.String("component", runnable.Name())) - - if err := runnable.Close(ctx); err != nil { - log.Error("β component cleanup failed", - zap.String("service", serviceName), - zap.String("component", runnable.Name()), - zap.Error(err)) - continue - } - - log.Info("β component cleaned up", - zap.String("service", serviceName), - zap.String("component", runnable.Name())) - } -} - -func shutdownServices(apps []node) { - log.Info("β‘ initiating service shutdown", zap.Int("count", len(apps))) - - for _, a := range slices.Backward(apps) { - log.Info("βΆ stopping service", zap.String("name", a.name)) - - ctx, cancel := context.WithTimeout(context.Background(), serviceShutdownTimeout) - - if err := a.app.Close(ctx); err != nil { - log.Error("β service shutdown failed", zap.String("name", a.name), zap.Error(err)) - } else { - log.Info("β service stopped successfully", zap.String("name", a.name)) - } - - cancel() - } -} - func emitBundleEvent(event string, fields ...zap.Field) { fields = append(fields, zap.String("event", event)) log.Info("bundle lifecycle event", fields...) @@ -756,7 +271,8 @@ func printStartupMsg() { `) } -func printShutdownMsg() { +func reportShutdownComplete() { + emitBundleEvent(bundleShutdownCompleteEvent) fmt.Printf(` βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ @@ -765,9 +281,10 @@ func printShutdownMsg() { βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ `) + log.Info("β Goodbye!") } -func printConfigurationInfo(cfg *bundleConfig.Config) { +func printConfigurationInfo(cfg *config.Config) { log.Info("βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ") log.Info("Configuration Summary") log.Info("βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ") @@ -802,28 +319,23 @@ func assertContainerRuntime() error { ) } -func startPprofServer(ctx context.Context, cCtx *cli.Context) { - if !cCtx.Bool(flagPprof) { +func startPprofServer(ctx context.Context, c *cli.Context) { + if !c.Bool(flagPprof) { return } - addr := cCtx.String(flagPprofAddr) + addr := c.String(flagPprofAddr) log.Info("π starting pprof HTTP server", zap.String("addr", addr), zap.String("url", "http://"+addr+"/debug/pprof/")) - // Create a custom mux and manually register pprof handlers - // This avoids gosec G108 warning and is more secure than using the default mux + // A private mux avoids exposing pprof through the process-wide default mux. mux := http.NewServeMux() - - // Register pprof handlers mux.HandleFunc("/debug/pprof/", pprof.Index) mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) mux.HandleFunc("/debug/pprof/profile", pprof.Profile) mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) mux.HandleFunc("/debug/pprof/trace", pprof.Trace) - - // Register additional profile types mux.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine")) mux.Handle("/debug/pprof/heap", pprof.Handler("heap")) mux.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate")) @@ -845,8 +357,8 @@ func startPprofServer(ctx context.Context, cCtx *cli.Context) { go func() { <-ctx.Done() - shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) - defer cancel() + shutdownCtx, cancelShutdown := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancelShutdown() if err := server.Shutdown(shutdownCtx); err != nil { log.Warn("pprof server shutdown failed", zap.Error(err)) } diff --git a/cmd/start_integration_test.go b/cmd/start_integration_test.go deleted file mode 100644 index 5e7bf7a..0000000 --- a/cmd/start_integration_test.go +++ /dev/null @@ -1,138 +0,0 @@ -//go:build integration - -package cmd - -import ( - "context" - "errors" - "os" - "os/exec" - "path/filepath" - "testing" - "time" -) - -// TestWaitForTCPOrExit_SIGILL tests detection of SIGILL (AVX failure simulation). -// Run with: go test -tags=integration -v ./cmd/... -func TestWaitForTCPOrExit_SIGILL(t *testing.T) { - // Create a temporary script that exits with SIGILL - tmpDir := t.TempDir() - scriptPath := filepath.Join(tmpDir, "fake-mongod") - - // Script that sends SIGILL to itself - script := `#!/bin/bash -kill -ILL $$ -` - if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { - t.Fatalf("failed to write script: %v", err) - } - - // Start the fake mongod - ctx := context.Background() - proc, err := newInfraProcess(ctx, "mongo", scriptPath) - if err != nil { - t.Fatalf("failed to start fake mongod: %v", err) - } - - // Wait for TCP (should fail with SIGILL) - err = waitForTCPOrExit("127.0.0.1:27017", 5*time.Second, proc) - - // Verify we got the error - if err == nil { - t.Fatal("expected SIGILL error, got nil") - } - - // Check it's detected as illegal instruction - if !isIllegalInstruction(err) { - t.Errorf("expected illegal instruction error, got: %v", err) - } - - t.Logf("Correctly detected SIGILL: %v", err) -} - -// TestWaitForTCPOrExit_ProcessExitsNormally tests detection of normal exit. -func TestWaitForTCPOrExit_ProcessExitsNormally(t *testing.T) { - // Create a script that exits normally with error - tmpDir := t.TempDir() - scriptPath := filepath.Join(tmpDir, "fake-mongod") - - script := `#!/bin/bash -echo "Config error: invalid option" -exit 1 -` - if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { - t.Fatalf("failed to write script: %v", err) - } - - ctx := context.Background() - proc, err := newInfraProcess(ctx, "mongo", scriptPath) - if err != nil { - t.Fatalf("failed to start fake mongod: %v", err) - } - - err = waitForTCPOrExit("127.0.0.1:27017", 5*time.Second, proc) - - if err == nil { - t.Fatal("expected error, got nil") - } - - // Should NOT be detected as illegal instruction - if isIllegalInstruction(err) { - t.Errorf("should not be illegal instruction: %v", err) - } - - t.Logf("Correctly detected normal exit: %v", err) -} - -// TestMongoAVXError_Integration tests the full error flow. -func TestMongoAVXError_Integration(t *testing.T) { - // Create a script that exits with SIGILL - tmpDir := t.TempDir() - scriptPath := filepath.Join(tmpDir, "fake-mongod") - - script := `#!/bin/bash -kill -ILL $$ -` - if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { - t.Fatalf("failed to write script: %v", err) - } - - ctx := context.Background() - proc, err := newInfraProcess(ctx, "mongo", scriptPath) - if err != nil { - t.Fatalf("failed to start fake mongod: %v", err) - } - - err = waitForTCPOrExit("127.0.0.1:27017", 5*time.Second, proc) - - // Simulate the error handling from startAllInOneInfra - if err != nil && isIllegalInstruction(err) { - // This is what would happen in real code - avxErr := &MongoAVXError{Cause: err} - - // Verify error chain works - var target *MongoAVXError - if !errors.As(avxErr, &target) { - t.Error("errors.As should match MongoAVXError") - } - - t.Logf("Full AVX error: %v", avxErr) - } else { - t.Errorf("expected SIGILL error, got: %v", err) - } -} - -// TestRealMongodNotFound tests behavior when mongod binary doesn't exist. -func TestRealMongodNotFound(t *testing.T) { - ctx := context.Background() - _, err := newInfraProcess(ctx, "mongo", "/nonexistent/mongod") - - if err == nil { - t.Fatal("expected error for nonexistent binary") - } - - // Should be exec error, not process exit - if !errors.Is(err, exec.ErrNotFound) && !os.IsNotExist(err) { - t.Logf("Got expected error type: %v", err) - } -} diff --git a/cmd/start_test.go b/cmd/start_test.go index 8837ecb..197285c 100644 --- a/cmd/start_test.go +++ b/cmd/start_test.go @@ -1,264 +1,58 @@ package cmd import ( - "context" - "errors" - "net" + "os" "testing" - "testing/synctest" "time" - "github.com/anyproto/any-sync/app" + "gopkg.in/yaml.v3" ) -func TestIsIllegalInstruction(t *testing.T) { - tests := []struct { - name string - err error - want bool - }{ - { - name: "sigill lowercase", - err: errors.New("signal: illegal instruction (core dumped)"), - want: true, - }, - { - name: "sigill uppercase", - err: errors.New("SIGNAL: ILLEGAL INSTRUCTION"), - want: true, - }, - { - name: "sigill mixed case", - err: errors.New("Signal: Illegal Instruction"), - want: true, - }, - { - name: "connection refused", - err: errors.New("connection refused"), - want: false, - }, - { - name: "exit status 1", - err: errors.New("exit status 1"), - want: false, - }, - { - name: "nil error", - err: nil, - want: false, - }, +// Every documented container must outlive the process watchdog so the +// application, rather than Docker, owns forced shutdown and reports the +// resulting failure. +func TestShutdownTimeoutFitsContainerStopGracePeriod(t *testing.T) { + composePaths := []string{ + "../compose.aio.yml", + "../compose.external.yml", + "../compose.s3.yml", + "../compose.traefik.yml", } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := isIllegalInstruction(tt.err) - if got != tt.want { - t.Errorf("isIllegalInstruction(%v) = %v, want %v", - tt.err, got, tt.want) - } - }) - } -} - -func TestWaitForTCPOrExit_ProcessDies(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - // Create a mock process that dies - proc := &infraProcess{ - done: make(chan struct{}), + for _, composePath := range composePaths { + data, err := os.ReadFile(composePath) + if err != nil { + t.Fatalf("read %s: %v", composePath, err) } - expectedErr := errors.New("signal: illegal instruction") - - // Simulate process dying after a short time - go func() { - time.Sleep(10 * time.Millisecond) - proc.exitErr = expectedErr - close(proc.done) - }() - - // Use a non-existent address so TCP never connects - // With synctest, time advances automatically when blocked - err := waitForTCPOrExit("127.0.0.1:59999", 5*time.Second, proc) - - if err == nil { - t.Fatal("expected error, got nil") + var compose struct { + Services map[string]struct { + StopGracePeriod string `yaml:"stop_grace_period"` + } `yaml:"services"` } - if !errors.Is(err, expectedErr) { - t.Errorf("expected %v, got %v", expectedErr, err) + if unmarshalErr := yaml.Unmarshal(data, &compose); unmarshalErr != nil { + t.Fatalf("parse %s: %v", composePath, unmarshalErr) } - }) -} - -func TestWaitForTCPOrExit_TCPReady(t *testing.T) { - // Start a TCP listener - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("failed to create listener: %v", err) - } - defer listener.Close() - - // Create a mock process that stays alive - proc := &infraProcess{ - done: make(chan struct{}), - } - - // Wait for TCP (should succeed quickly) - err = waitForTCPOrExit(listener.Addr().String(), 5*time.Second, proc) - if err != nil { - t.Errorf("expected nil, got %v", err) - } -} -func TestWaitForTCPOrExit_Timeout(t *testing.T) { - // Create a mock process that stays alive - proc := &infraProcess{ - done: make(chan struct{}), - } - - // Use a non-existent address and short timeout - err := waitForTCPOrExit("127.0.0.1:59999", 200*time.Millisecond, proc) - - if err == nil { - t.Fatal("expected timeout error, got nil") - } -} - -func TestMongoAVXError(t *testing.T) { - cause := errors.New("signal: illegal instruction") - err := &MongoAVXError{Cause: cause} - - t.Run("error message", func(t *testing.T) { - want := "mongodb requires AVX CPU support: signal: illegal instruction" - if got := err.Error(); got != want { - t.Errorf("Error() = %q, want %q", got, want) + bundle, ok := compose.Services["any-sync-bundle"] + if !ok { + t.Fatalf("%s does not define the any-sync-bundle service", composePath) } - }) - - t.Run("unwrap", func(t *testing.T) { - got := errors.Unwrap(err) - if !errors.Is(got, cause) { - t.Errorf("Unwrap() = %v, want %v", got, cause) - } - }) - - t.Run("errors.As", func(t *testing.T) { - var target *MongoAVXError - if !errors.As(err, &target) { - t.Error("errors.As() should match MongoAVXError") + if bundle.StopGracePeriod == "" { + t.Fatalf("%s does not bound the bundle stop grace period", composePath) } - }) - - t.Run("errors.Is with wrapped", func(t *testing.T) { - wrapped := errors.New("signal: illegal instruction") - avxErr := &MongoAVXError{Cause: wrapped} - if !errors.Is(avxErr, wrapped) { - t.Error("errors.Is() should find wrapped cause") - } - }) -} -type lifecycleTestRunnable struct { - name string - events *[]string - initErr error - runErr error -} - -func (r *lifecycleTestRunnable) Init(*app.App) error { - *r.events = append(*r.events, "init:"+r.name) - return r.initErr -} - -func (r *lifecycleTestRunnable) Name() string { - return r.name -} - -func (r *lifecycleTestRunnable) Run(context.Context) error { - *r.events = append(*r.events, "run:"+r.name) - return r.runErr -} - -func (r *lifecycleTestRunnable) Close(context.Context) error { - *r.events = append(*r.events, "close:"+r.name) - return nil -} - -func TestInitOneApp_PartialFailureClosesCurrentService(t *testing.T) { - events := []string{} - first := &lifecycleTestRunnable{name: "first", events: &events} - second := &lifecycleTestRunnable{ - name: "second", - events: &events, - initErr: errors.New("boom"), - } - third := &lifecycleTestRunnable{name: "third", events: &events} - - testApp := new(app.App). - Register(first). - Register(second). - Register(third) - - err := initOneApp(node{name: "test", app: testApp}) - if err == nil { - t.Fatal("expected init error, got nil") - } - - want := []string{ - "init:first", - "init:second", - "close:second", - "close:first", - } - if len(events) != len(want) { - t.Fatalf("unexpected event count: got %v want %v", events, want) - } - for idx := range want { - if events[idx] != want[idx] { - t.Fatalf("unexpected events: got %v want %v", events, want) + containerStopGracePeriod, err := time.ParseDuration(bundle.StopGracePeriod) + if err != nil { + t.Fatalf("parse stop_grace_period in %s: %v", composePath, err) } - } -} - -func TestStartServices_RunFailureClosesCurrentAndPreviousServices(t *testing.T) { - events := []string{} - firstService := &lifecycleTestRunnable{name: "one", events: &events} - secondServiceFirst := &lifecycleTestRunnable{name: "two-a", events: &events} - secondServiceSecond := &lifecycleTestRunnable{ - name: "two-b", - events: &events, - runErr: errors.New("boom"), - } - - appOne := new(app.App).Register(firstService) - appTwo := new(app.App). - Register(secondServiceFirst). - Register(secondServiceSecond) - - err := startServices(context.Background(), []node{ - {name: "svc-one", app: appOne}, - {name: "svc-two", app: appTwo}, - }, nil) - if err == nil { - t.Fatal("expected run error, got nil") - } - - want := []string{ - "init:one", - "init:two-a", - "init:two-b", - "run:one", - "run:two-a", - "run:two-b", - "close:two-b", - "close:two-a", - "close:one", - } - if len(events) != len(want) { - t.Fatalf("unexpected event count: got %v want %v", events, want) - } - for idx := range want { - if events[idx] != want[idx] { - t.Fatalf("unexpected events: got %v want %v", events, want) + if ShutdownTimeout >= containerStopGracePeriod { + t.Fatalf( + "%s stop grace period %v must exceed shutdown timeout %v", + composePath, + containerStopGracePeriod, + ShutdownTimeout, + ) } } } diff --git a/compose.aio.yml b/compose.aio.yml index 849f791..43495de 100644 --- a/compose.aio.yml +++ b/compose.aio.yml @@ -7,9 +7,10 @@ services: any-sync-bundle: - image: ghcr.io/grishy/any-sync-bundle:1.4.3-2026-04-21 + image: ghcr.io/grishy/any-sync-bundle:1.5.0-2026-07-17 container_name: any-sync-bundle-aio restart: unless-stopped + stop_grace_period: 2m ports: - "33010:33010" - "33020:33020/udp" diff --git a/compose.external.yml b/compose.external.yml index c6325ee..b9b5207 100644 --- a/compose.external.yml +++ b/compose.external.yml @@ -57,9 +57,10 @@ services: start_period: 5s any-sync-bundle: - image: ghcr.io/grishy/any-sync-bundle:1.4.3-2026-04-21-minimal + image: ghcr.io/grishy/any-sync-bundle:1.5.0-2026-07-17-minimal container_name: any-sync-bundle restart: unless-stopped + stop_grace_period: 2m depends_on: mongo: condition: service_healthy diff --git a/compose.s3.yml b/compose.s3.yml index 94a2481..c20ca51 100644 --- a/compose.s3.yml +++ b/compose.s3.yml @@ -44,9 +44,10 @@ services: " any-sync-bundle: - image: ghcr.io/grishy/any-sync-bundle:1.4.3-2026-04-21 + image: ghcr.io/grishy/any-sync-bundle:1.5.0-2026-07-17 container_name: any-sync-bundle-aio restart: unless-stopped + stop_grace_period: 2m depends_on: minio: condition: service_healthy diff --git a/compose.traefik.yml b/compose.traefik.yml index ac8dad1..fc5b28b 100644 --- a/compose.traefik.yml +++ b/compose.traefik.yml @@ -39,9 +39,10 @@ services: - /var/run/docker.sock:/var/run/docker.sock:ro any-sync-bundle: - image: ghcr.io/grishy/any-sync-bundle:1.4.3-2026-04-21 + image: ghcr.io/grishy/any-sync-bundle:1.5.0-2026-07-17 container_name: any-sync-bundle-aio restart: unless-stopped + stop_grace_period: 2m environment: # IMPORTANT: Replace this with your server's public IP or hostname # This is the address clients will use to connect (Traefik's public address) diff --git a/config/bundle.go b/config/bundle.go index 65de033..8d35c3e 100644 --- a/config/bundle.go +++ b/config/bundle.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "path/filepath" + "slices" "strings" "github.com/anyproto/any-sync/accountservice" @@ -15,6 +16,8 @@ import ( "github.com/anyproto/any-sync/app/logger" "github.com/anyproto/any-sync/util/crypto" + "github.com/redis/go-redis/v9" + "go.mongodb.org/mongo-driver/mongo/options" "go.uber.org/zap" "gopkg.in/mgo.v2/bson" "gopkg.in/yaml.v3" @@ -30,6 +33,11 @@ const ( // oneTiB is one tebibyte (2^40 bytes), used as the default filenode storage limit. oneTiB = 1024 * 1024 * 1024 * 1024 + + defaultListenTCPAddr = "0.0.0.0:33010" + defaultListenUDPAddr = "0.0.0.0:33020" + defaultCoordinatorDatabase = "coordinator" + defaultConsensusDatabase = "consensus" ) type Config struct { @@ -142,16 +150,13 @@ func (cfg *Config) Validate() error { if err := validateListenAddr("network.listenUDPAddr", cfg.Network.ListenUDPAddr); err != nil { return err } - if err := validateURI("coordinator.mongoConnect", cfg.Coordinator.MongoConnect, - "mongodb", "mongodb+srv"); err != nil { + if err := validateMongoURI("coordinator.mongoConnect", cfg.Coordinator.MongoConnect); err != nil { return err } - if err := validateURI("consensus.mongoConnect", cfg.Consensus.MongoConnect, - "mongodb", "mongodb+srv"); err != nil { + if err := validateMongoURI("consensus.mongoConnect", cfg.Consensus.MongoConnect); err != nil { return err } - if err := validateURI("filenode.redisConnect", cfg.FileNode.RedisConnect, - "redis", "rediss"); err != nil { + if err := validateRedisURI("filenode.redisConnect", cfg.FileNode.RedisConnect); err != nil { return err } if cfg.FileNode.S3 != nil { @@ -173,7 +178,7 @@ func (cfg *S3Config) Validate() error { if strings.TrimSpace(cfg.Endpoint) == "" { return ErrS3EndpointRequired } - if err := validateURI("filenode.s3.endpoint", cfg.Endpoint); err != nil { + if err := validateURI("filenode.s3.endpoint", cfg.Endpoint, "http", "https"); err != nil { return err } return nil @@ -184,6 +189,9 @@ func validateListenAddr(field string, raw string) error { if addr == "" { return fmt.Errorf("%s is required", field) } + if addr != raw { + return fmt.Errorf("%s must not contain surrounding whitespace", field) + } host, port, err := net.SplitHostPort(addr) if err != nil { @@ -199,11 +207,31 @@ func validateListenAddr(field string, raw string) error { return nil } +func validateMongoURI(field string, mongoURI string) error { + if err := options.Client().ApplyURI(mongoURI).Validate(); err != nil { + return fmt.Errorf("%s must be a valid MongoDB URI: %w", field, err) + } + return nil +} + +func validateRedisURI(field string, redisURI string) error { + if err := validateURI(field, redisURI, "redis", "rediss"); err != nil { + return err + } + if _, err := redis.ParseURL(redisURI); err != nil { + return fmt.Errorf("%s must be a valid Redis URI: %w", field, err) + } + return nil +} + func validateURI(field string, raw string, allowedSchemes ...string) error { value := strings.TrimSpace(raw) if value == "" { return fmt.Errorf("%s is required", field) } + if value != raw { + return fmt.Errorf("%s must not contain surrounding whitespace", field) + } parsed, err := url.Parse(value) if err != nil { @@ -218,10 +246,8 @@ func validateURI(field string, raw string, allowedSchemes ...string) error { if len(allowedSchemes) == 0 { return nil } - for _, allowedScheme := range allowedSchemes { - if parsed.Scheme == allowedScheme { - return nil - } + if slices.Contains(allowedSchemes, parsed.Scheme) { + return nil } return fmt.Errorf("%s must use one of: %s", field, strings.Join(allowedSchemes, ", ")) @@ -251,6 +277,7 @@ func Load(cfgPath string) *Config { zap.Int("current", CurrentBundleFormat), zap.String("path", cfgPath)) } + if validateErr := cfg.Validate(); validateErr != nil { log.Panic("invalid config", zap.Error(validateErr), zap.String("path", cfgPath)) } @@ -312,8 +339,8 @@ func newBundleConfig(cfg *CreateOptions) *Config { netID := netKey.GetPublic().Network() - // Parse MongoDB URI and add w=majority if not already present. - // Base on Anytype dockercompose version. + // Consensus requires majority writes. Preserve the operator-provided URI + // for the coordinator and derive the consensus URI from it. mongoConsensusURI, err := url.Parse(cfg.MongoURI) if err != nil { log.Panic("invalid mongo URI", zap.Error(err)) @@ -321,6 +348,10 @@ func newBundleConfig(cfg *CreateOptions) *Config { query := mongoConsensusURI.Query() if query.Get("w") == "" { + // A path separator becomes mandatory when the bundle adds query options. + if mongoConsensusURI.Path == "" { + mongoConsensusURI.Path = "/" + } query.Set("w", "majority") mongoConsensusURI.RawQuery = query.Encode() } @@ -334,16 +365,16 @@ func newBundleConfig(cfg *CreateOptions) *Config { StoragePath: cfg.StorePath, Account: newAcc(netKey), Network: NetworkConfig{ - ListenTCPAddr: "0.0.0.0:33010", - ListenUDPAddr: "0.0.0.0:33020", + ListenTCPAddr: defaultListenTCPAddr, + ListenUDPAddr: defaultListenUDPAddr, }, Coordinator: CoordinatorConfig{ MongoConnect: cfg.MongoURI, - MongoDatabase: "coordinator", + MongoDatabase: defaultCoordinatorDatabase, }, Consensus: ConsensusConfig{ MongoConnect: mongoConsensusURI.String(), - MongoDatabase: "consensus", + MongoDatabase: defaultConsensusDatabase, }, FileNode: FileNodeConfig{ RedisConnect: cfg.RedisURI, diff --git a/config/bundle_test.go b/config/bundle_test.go index 7bb4a17..2bf389d 100644 --- a/config/bundle_test.go +++ b/config/bundle_test.go @@ -8,499 +8,258 @@ import ( "github.com/anyproto/any-sync/accountservice" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) -func TestLoad_ValidFormat(t *testing.T) { - // Create a temporary valid config with bundleFormat=1 - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "valid-config.yml") - - validConfig := `bundleVersion: "0.13.0" -bundleFormat: 1 -externalAddr: - - "192.168.1.100" -configId: "test-config-id" -networkId: "test-network-id" -storagePath: "./data/storage" -account: - peerId: "test-peer-id" - peerKey: "test-peer-key" - signingKey: "test-signing-key" -network: - listenTCPAddr: "0.0.0.0:33010" - listenUDPAddr: "0.0.0.0:33020" -coordinator: - mongoConnect: "mongodb://localhost:27017/" - mongoDatabase: "coordinator" -consensus: - mongoConnect: "mongodb://localhost:27017/?w=majority" - mongoDatabase: "consensus" -filenode: - redisConnect: "redis://localhost:6379/" -` - - err := os.WriteFile(cfgPath, []byte(validConfig), 0o600) - require.NoError(t, err) - - // Should load successfully - cfg := Load(cfgPath) - assert.NotNil(t, cfg) - assert.Equal(t, 1, cfg.BundleFormat) - assert.Equal(t, "0.13.0", cfg.BundleVersion) -} - -func TestLoad_MissingFormat(t *testing.T) { - // Create a config without bundleFormat field (defaults to 0) - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "missing-format.yml") - - configWithoutFormat := `bundleVersion: "0.13.0" -externalAddr: - - "192.168.1.100" -configId: "test-config-id" -networkId: "test-network-id" -storagePath: "./data/storage" -account: - peerId: "test-peer-id" - peerKey: "test-peer-key" - signingKey: "test-signing-key" -network: - listenTCPAddr: "0.0.0.0:33010" - listenUDPAddr: "0.0.0.0:33020" -coordinator: - mongoConnect: "mongodb://localhost:27017/" - mongoDatabase: "coordinator" -consensus: - mongoConnect: "mongodb://localhost:27017/?w=majority" - mongoDatabase: "consensus" -filenode: - redisConnect: "redis://localhost:6379/" -` - - err := os.WriteFile(cfgPath, []byte(configWithoutFormat), 0o600) - require.NoError(t, err) - - // Should panic with "config format too old" - assert.Panics(t, func() { - Load(cfgPath) - }) -} - -func TestLoad_FormatZero(t *testing.T) { - // Create a config with explicit bundleFormat=0 - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "format-zero.yml") - - configFormatZero := `bundleVersion: "0.12.0" -bundleFormat: 0 -externalAddr: - - "192.168.1.100" -configId: "test-config-id" -networkId: "test-network-id" -storagePath: "./data/storage" -account: - peerId: "test-peer-id" - peerKey: "test-peer-key" - signingKey: "test-signing-key" -network: - listenTCPAddr: "0.0.0.0:33010" - listenUDPAddr: "0.0.0.0:33020" -coordinator: - mongoConnect: "mongodb://localhost:27017/" - mongoDatabase: "coordinator" -consensus: - mongoConnect: "mongodb://localhost:27017/?w=majority" - mongoDatabase: "consensus" -filenode: - redisConnect: "redis://localhost:6379/" -` - - err := os.WriteFile(cfgPath, []byte(configFormatZero), 0o600) - require.NoError(t, err) - - // Should panic with "config format too old" - assert.Panics(t, func() { - Load(cfgPath) - }) -} - -func TestLoad_FutureFormat(t *testing.T) { - // Create a config with bundleFormat=2 (future version) - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "future-format.yml") - - futureConfig := `bundleVersion: "1.0.0" -bundleFormat: 2 -externalAddr: - - "192.168.1.100" -configId: "test-config-id" -networkId: "test-network-id" -storagePath: "./data/storage" -account: - peerId: "test-peer-id" - peerKey: "test-peer-key" - signingKey: "test-signing-key" -network: - listenTCPAddr: "0.0.0.0:33010" - listenUDPAddr: "0.0.0.0:33020" -coordinator: - mongoConnect: "mongodb://localhost:27017/" - mongoDatabase: "coordinator" -consensus: - mongoConnect: "mongodb://localhost:27017/?w=majority" - mongoDatabase: "consensus" -filenode: - redisConnect: "redis://localhost:6379/" -` - - err := os.WriteFile(cfgPath, []byte(futureConfig), 0o600) - require.NoError(t, err) +// Loading has three format states: supported, older than supported, and newer +// than this binary. Values within either rejected range have identical meaning. +func TestLoadBundleFormat(t *testing.T) { + tests := []struct { + name string + format int + shouldPanic bool + }{ + {name: "current", format: CurrentBundleFormat}, + {name: "below minimum", format: MinSupportedBundleFormat - 1, shouldPanic: true}, + {name: "newer than binary", format: CurrentBundleFormat + 1, shouldPanic: true}, + } - // Should panic with "config format too new" - assert.Panics(t, func() { - Load(cfgPath) - }) -} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := validTestConfig() + cfg.BundleFormat = test.format -func TestLoad_NegativeFormat(t *testing.T) { - // Create a config with bundleFormat=-1 (invalid) - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "negative-format.yml") - - negativeConfig := `bundleVersion: "0.13.0" -bundleFormat: -1 -externalAddr: - - "192.168.1.100" -configId: "test-config-id" -networkId: "test-network-id" -storagePath: "./data/storage" -account: - peerId: "test-peer-id" - peerKey: "test-peer-key" - signingKey: "test-signing-key" -network: - listenTCPAddr: "0.0.0.0:33010" - listenUDPAddr: "0.0.0.0:33020" -coordinator: - mongoConnect: "mongodb://localhost:27017/" - mongoDatabase: "coordinator" -consensus: - mongoConnect: "mongodb://localhost:27017/?w=majority" - mongoDatabase: "consensus" -filenode: - redisConnect: "redis://localhost:6379/" -` - - err := os.WriteFile(cfgPath, []byte(negativeConfig), 0o600) - require.NoError(t, err) + data, err := yaml.Marshal(cfg) + require.NoError(t, err) - // Should panic with "config format too old" - assert.Panics(t, func() { - Load(cfgPath) - }) -} + cfgPath := filepath.Join(t.TempDir(), "bundle.yml") + require.NoError(t, os.WriteFile(cfgPath, data, 0o600)) -func TestCreateWrite_SetsBundleFormat(t *testing.T) { - // Verify that CreateWrite sets bundleFormat to CurrentBundleFormat - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "created-config.yml") + if test.shouldPanic { + assert.Panics(t, func() { + Load(cfgPath) + }) + return + } - opts := &CreateOptions{ - CfgPath: cfgPath, - StorePath: filepath.Join(tmpDir, "storage"), - MongoURI: "mongodb://localhost:27017/", - RedisURI: "redis://localhost:6379/", - ExternalAddrs: []string{"192.168.1.100"}, + loaded := Load(cfgPath) + assert.Equal(t, CurrentBundleFormat, loaded.BundleFormat) + }) } - - cfg := CreateWrite(opts) - assert.Equal(t, CurrentBundleFormat, cfg.BundleFormat) - - // Verify the written file can be loaded back - loadedCfg := Load(cfgPath) - assert.Equal(t, CurrentBundleFormat, loadedCfg.BundleFormat) } -// S3 Configuration Tests - -func TestValidateS3Config_Valid(t *testing.T) { - // Set credentials for this test - t.Setenv("AWS_ACCESS_KEY_ID", "test-key") - t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") +// Release fixtures exercise the reader independently of the current writer. +// Together with the creation round trips below, they cover every 1.x config +// shape that changed while bundle format 1 remained supported. +func TestLoadV1Compatibility(t *testing.T) { + t.Run("v1.0 local storage", func(t *testing.T) { + cfg := Load("testdata/bundle-v1.0.yml") + + assert.Equal(t, 1, cfg.BundleFormat) + assert.Equal(t, "1.0.0", cfg.BundleVersion) + assert.Equal(t, []string{"192.168.1.100"}, cfg.ExternalAddr) + assert.Equal(t, "test-config-id", cfg.ConfigID) + assert.Equal(t, "test-network-id", cfg.NetworkID) + assert.Equal(t, "./data/storage", cfg.StoragePath) + assert.Equal(t, "test-peer-id", cfg.Account.PeerId) + assert.Equal(t, "test-peer-key", cfg.Account.PeerKey) + assert.Equal(t, "test-signing-key", cfg.Account.SigningKey) + assert.Equal(t, "0.0.0.0:33010", cfg.Network.ListenTCPAddr) + assert.Equal(t, "0.0.0.0:33020", cfg.Network.ListenUDPAddr) + assert.Equal(t, "mongodb://localhost:27017/", cfg.Coordinator.MongoConnect) + assert.Equal(t, "coordinator", cfg.Coordinator.MongoDatabase) + assert.Equal(t, "mongodb://localhost:27017/?w=majority", cfg.Consensus.MongoConnect) + assert.Equal(t, "consensus", cfg.Consensus.MongoDatabase) + assert.Equal(t, "redis://localhost:6379/", cfg.FileNode.RedisConnect) + assert.Nil(t, cfg.FileNode.S3) + assert.Equal(t, uint64(oneTiB), cfg.NodeConfigs().Filenode.DefaultLimit) + }) - cfg, err := validateS3Config("my-bucket", "https://s3.amazonaws.com", "", false) - require.NoError(t, err) - assert.Equal(t, "my-bucket", cfg.Bucket) - assert.Equal(t, "https://s3.amazonaws.com", cfg.Endpoint) - assert.Empty(t, cfg.Region, "Region should be empty when not provided") - assert.False(t, cfg.ForcePathStyle) -} + t.Run("v1.2 S3 storage", func(t *testing.T) { + cfg := Load("testdata/bundle-v1.2-s3.yml") -func TestValidateS3Config_WithForcePathStyle(t *testing.T) { - t.Setenv("AWS_ACCESS_KEY_ID", "test-key") - t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") + assert.Equal(t, 1, cfg.BundleFormat) + assert.Equal(t, "1.2.0", cfg.BundleVersion) + require.NotNil(t, cfg.FileNode.S3) + assert.Equal(t, "my-bucket", cfg.FileNode.S3.Bucket) + assert.Equal(t, "https://s3.amazonaws.com", cfg.FileNode.S3.Endpoint) + assert.True(t, cfg.FileNode.S3.ForcePathStyle) - cfg, err := validateS3Config("my-bucket", "http://minio:9000", "", true) - require.NoError(t, err) - assert.True(t, cfg.ForcePathStyle) -} - -func TestValidateS3Config_WithRegion(t *testing.T) { - t.Setenv("AWS_ACCESS_KEY_ID", "test-key") - t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") + filenodeCfg := cfg.NodeConfigs().Filenode + assert.Equal(t, uint64(oneTiB), filenodeCfg.DefaultLimit) + assert.Equal(t, "us-east-1", filenodeCfg.S3Store.Region) + }) - cfg, err := validateS3Config("my-bucket", "http://minio:9000", "sz-hq", true) - require.NoError(t, err) - assert.Equal(t, "sz-hq", cfg.Region) -} + t.Run("v1.3 explicit storage settings", func(t *testing.T) { + const tenGiB = 10 * 1024 * 1024 * 1024 -func TestValidateS3Config_MissingBucket(t *testing.T) { - cfg, err := validateS3Config("", "https://s3.amazonaws.com", "", false) - assert.Nil(t, cfg) - assert.ErrorIs(t, err, ErrS3BucketRequired) -} + cfg := Load("testdata/bundle-v1.3-s3.yml") -func TestValidateS3Config_MissingEndpoint(t *testing.T) { - cfg, err := validateS3Config("my-bucket", "", "", false) - assert.Nil(t, cfg) - assert.ErrorIs(t, err, ErrS3EndpointRequired) -} + assert.Equal(t, 1, cfg.BundleFormat) + assert.Equal(t, "1.3.0", cfg.BundleVersion) + assert.Equal(t, uint64(tenGiB), cfg.FileNode.DefaultLimit) + require.NotNil(t, cfg.FileNode.S3) + assert.Equal(t, "eu-central-1", cfg.FileNode.S3.Region) -func TestValidateS3Config_MissingBoth(t *testing.T) { - // When both are missing, bucket error should come first - cfg, err := validateS3Config("", "", "", false) - assert.Nil(t, cfg) - assert.ErrorIs(t, err, ErrS3BucketRequired) + filenodeCfg := cfg.NodeConfigs().Filenode + assert.Equal(t, uint64(tenGiB), filenodeCfg.DefaultLimit) + assert.Equal(t, "eu-central-1", filenodeCfg.S3Store.Region) + }) } -func TestValidateS3Config_MissingCredentials(t *testing.T) { - // Ensure credentials are not set - t.Setenv("AWS_ACCESS_KEY_ID", "") - t.Setenv("AWS_SECRET_ACCESS_KEY", "") +// Loading treats the persisted configuration as operator-owned input. Invalid +// MongoDB syntax must stop startup instead of becoming different runtime state. +func TestLoadRejectsInvalidMongoURI(t *testing.T) { + cfg := validTestConfig() + cfg.Consensus.MongoConnect = "mongodb://localhost:27017?w=majority" - // Should still succeed but with a warning (tested via logs) - cfg, err := validateS3Config("my-bucket", "https://s3.amazonaws.com", "", false) + data, err := yaml.Marshal(cfg) require.NoError(t, err) - assert.NotNil(t, cfg) -} -func TestValidateS3Config_PartialCredentials(t *testing.T) { - // Only access key set - t.Setenv("AWS_ACCESS_KEY_ID", "test-key") - t.Setenv("AWS_SECRET_ACCESS_KEY", "") + cfgPath := filepath.Join(t.TempDir(), "bundle.yml") + require.NoError(t, os.WriteFile(cfgPath, data, 0o600)) - // Should still succeed but with a warning - cfg, err := validateS3Config("my-bucket", "https://s3.amazonaws.com", "", false) - require.NoError(t, err) - assert.NotNil(t, cfg) + assert.Panics(t, func() { + Load(cfgPath) + }) } -func TestCreateWrite_WithS3Config(t *testing.T) { - t.Setenv("AWS_ACCESS_KEY_ID", "test-key") - t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") - - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "s3-config.yml") - - opts := &CreateOptions{ - CfgPath: cfgPath, - StorePath: filepath.Join(tmpDir, "storage"), - MongoURI: "mongodb://localhost:27017/", - RedisURI: "redis://localhost:6379/", - ExternalAddrs: []string{"192.168.1.100"}, - S3Bucket: "test-bucket", - S3Endpoint: "https://s3.amazonaws.com", - S3ForcePathStyle: true, - } +// Generated defaults are persisted, not reconstructed only in memory, so the +// configuration remains explicit and stable across restarts. +func TestCreateWriteRoundTrip(t *testing.T) { + options := validCreateOptions(t) - cfg := CreateWrite(opts) - require.NotNil(t, cfg.FileNode.S3) - assert.Equal(t, "test-bucket", cfg.FileNode.S3.Bucket) - assert.Equal(t, "https://s3.amazonaws.com", cfg.FileNode.S3.Endpoint) - assert.True(t, cfg.FileNode.S3.ForcePathStyle) + created := CreateWrite(options) + loaded := Load(options.CfgPath) - // Verify the config can be loaded back with S3 settings - loadedCfg := Load(cfgPath) - require.NotNil(t, loadedCfg.FileNode.S3) - assert.Equal(t, "test-bucket", loadedCfg.FileNode.S3.Bucket) + assert.Equal(t, CurrentBundleFormat, created.BundleFormat) + assert.Equal(t, CurrentBundleFormat, loaded.BundleFormat) + assert.Equal(t, uint64(oneTiB), created.FileNode.DefaultLimit) + assert.Equal(t, uint64(oneTiB), loaded.FileNode.DefaultLimit) + assert.Nil(t, created.FileNode.S3) + assert.Nil(t, loaded.FileNode.S3) } -func TestCreateWrite_WithoutS3Config(t *testing.T) { - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "no-s3-config.yml") +// The coordinator receives the valid operator-provided URI unchanged. The +// bundle owns the derived consensus URI and therefore its required separator. +func TestCreateWritePreservesMongoURI(t *testing.T) { + options := validCreateOptions(t) + options.MongoURI = "mongodb://localhost:27017" - opts := &CreateOptions{ - CfgPath: cfgPath, - StorePath: filepath.Join(tmpDir, "storage"), - MongoURI: "mongodb://localhost:27017/", - RedisURI: "redis://localhost:6379/", - ExternalAddrs: []string{"192.168.1.100"}, - // No S3 options - } + cfg := CreateWrite(options) - cfg := CreateWrite(opts) - assert.Nil(t, cfg.FileNode.S3, "S3 config should be nil when not configured") + assert.Equal(t, options.MongoURI, cfg.Coordinator.MongoConnect) + assert.Equal(t, "mongodb://localhost:27017/?w=majority", cfg.Consensus.MongoConnect) } -func TestCreateWrite_S3MissingEndpoint_Panics(t *testing.T) { - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "invalid-s3-config.yml") - - opts := &CreateOptions{ - CfgPath: cfgPath, - StorePath: filepath.Join(tmpDir, "storage"), - MongoURI: "mongodb://localhost:27017/", - RedisURI: "redis://localhost:6379/", - ExternalAddrs: []string{"192.168.1.100"}, - S3Bucket: "test-bucket", - // Missing S3Endpoint - } +func TestCreateWriteRejectsInvalidMongoURI(t *testing.T) { + options := validCreateOptions(t) + options.MongoURI = "mongodb://localhost:27017?replicaSet=rs0" assert.Panics(t, func() { - CreateWrite(opts) + CreateWrite(options) }) -} -func TestCreateWrite_S3MissingBucket_Panics(t *testing.T) { - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "invalid-s3-config.yml") + _, err := os.Stat(options.CfgPath) + assert.ErrorIs(t, err, os.ErrNotExist) +} - opts := &CreateOptions{ - CfgPath: cfgPath, - StorePath: filepath.Join(tmpDir, "storage"), - MongoURI: "mongodb://localhost:27017/", - RedisURI: "redis://localhost:6379/", - ExternalAddrs: []string{"192.168.1.100"}, - S3Endpoint: "https://s3.amazonaws.com", - // Missing S3Bucket +func TestValidateS3Config(t *testing.T) { + tests := []struct { + name string + bucket string + endpoint string + withCredentials bool + wantErr error + }{ + { + name: "valid", + bucket: "my-bucket", + endpoint: "http://minio:9000", + withCredentials: true, + }, + { + name: "missing bucket", + endpoint: "http://minio:9000", + wantErr: ErrS3BucketRequired, + }, + { + name: "missing endpoint", + bucket: "my-bucket", + wantErr: ErrS3EndpointRequired, + }, + { + name: "credentials may come from another AWS provider", + bucket: "my-bucket", + endpoint: "https://s3.amazonaws.com", + }, } - assert.Panics(t, func() { - CreateWrite(opts) - }) -} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.withCredentials { + t.Setenv("AWS_ACCESS_KEY_ID", "test-key") + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") + } else { + t.Setenv("AWS_ACCESS_KEY_ID", "") + t.Setenv("AWS_SECRET_ACCESS_KEY", "") + } -func TestLoad_WithS3Config(t *testing.T) { - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "s3-config.yml") - - configWithS3 := `bundleVersion: "1.0.0" -bundleFormat: 1 -externalAddr: - - "192.168.1.100" -configId: "test-config-id" -networkId: "test-network-id" -storagePath: "./data/storage" -account: - peerId: "test-peer-id" - peerKey: "test-peer-key" - signingKey: "test-signing-key" -network: - listenTCPAddr: "0.0.0.0:33010" - listenUDPAddr: "0.0.0.0:33020" -coordinator: - mongoConnect: "mongodb://localhost:27017/" - mongoDatabase: "coordinator" -consensus: - mongoConnect: "mongodb://localhost:27017/?w=majority" - mongoDatabase: "consensus" -filenode: - redisConnect: "redis://localhost:6379/" - s3: - bucket: "my-bucket" - endpoint: "https://s3.amazonaws.com" - forcePathStyle: true -` - - err := os.WriteFile(cfgPath, []byte(configWithS3), 0o600) - require.NoError(t, err) + cfg, err := validateS3Config( + test.bucket, + test.endpoint, + "custom-region", + true, + ) + if test.wantErr != nil { + assert.Nil(t, cfg) + assert.ErrorIs(t, err, test.wantErr) + return + } - cfg := Load(cfgPath) - require.NotNil(t, cfg.FileNode.S3) - assert.Equal(t, "my-bucket", cfg.FileNode.S3.Bucket) - assert.Equal(t, "https://s3.amazonaws.com", cfg.FileNode.S3.Endpoint) - assert.True(t, cfg.FileNode.S3.ForcePathStyle) + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Equal(t, test.bucket, cfg.Bucket) + assert.Equal(t, test.endpoint, cfg.Endpoint) + assert.Equal(t, "custom-region", cfg.Region) + assert.True(t, cfg.ForcePathStyle) + }) + } } -// Filenode Default Limit Tests - -func TestCreateWrite_WithFilenodeDefaultLimit(t *testing.T) { - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "limit-config.yml") - - const tenGiB = 10 * 1024 * 1024 * 1024 // 10 GiB +func TestCreateWriteS3RoundTrip(t *testing.T) { + t.Setenv("AWS_ACCESS_KEY_ID", "test-key") + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") - opts := &CreateOptions{ - CfgPath: cfgPath, - StorePath: filepath.Join(tmpDir, "storage"), - MongoURI: "mongodb://localhost:27017/", - RedisURI: "redis://localhost:6379/", - ExternalAddrs: []string{"192.168.1.100"}, - FilenodeDefaultLimit: tenGiB, + options := validCreateOptions(t) + options.S3Bucket = "test-bucket" + options.S3Endpoint = "http://minio:9000" + options.S3Region = "custom-region" + options.S3ForcePathStyle = true + + created := CreateWrite(options) + loaded := Load(options.CfgPath) + + for _, cfg := range []*Config{created, loaded} { + require.NotNil(t, cfg.FileNode.S3) + assert.Equal(t, "test-bucket", cfg.FileNode.S3.Bucket) + assert.Equal(t, "http://minio:9000", cfg.FileNode.S3.Endpoint) + assert.Equal(t, "custom-region", cfg.FileNode.S3.Region) + assert.True(t, cfg.FileNode.S3.ForcePathStyle) } - - cfg := CreateWrite(opts) - assert.Equal(t, uint64(tenGiB), cfg.FileNode.DefaultLimit) - - // Verify it persists and loads back correctly - loadedCfg := Load(cfgPath) - assert.Equal(t, uint64(tenGiB), loadedCfg.FileNode.DefaultLimit) } -func TestCreateWrite_WithoutFilenodeDefaultLimit(t *testing.T) { - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "no-limit-config.yml") - - const oneTiB = 1024 * 1024 * 1024 * 1024 // 1 TiB - - opts := &CreateOptions{ - CfgPath: cfgPath, - StorePath: filepath.Join(tmpDir, "storage"), - MongoURI: "mongodb://localhost:27017/", - RedisURI: "redis://localhost:6379/", - ExternalAddrs: []string{"192.168.1.100"}, - // FilenodeDefaultLimit not set (zero value) - } +func TestCreateWriteExplicitFilenodeLimit(t *testing.T) { + const tenGiB = 10 * 1024 * 1024 * 1024 - cfg := CreateWrite(opts) - assert.Equal(t, uint64(oneTiB), cfg.FileNode.DefaultLimit, - "DefaultLimit should be 1 TiB when not configured (written to config file)") -} + options := validCreateOptions(t) + options.FilenodeDefaultLimit = tenGiB -func TestLoad_WithFilenodeDefaultLimit(t *testing.T) { - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "limit-config.yml") - - configWithLimit := `bundleVersion: "1.0.0" -bundleFormat: 1 -externalAddr: - - "192.168.1.100" -configId: "test-config-id" -networkId: "test-network-id" -storagePath: "./data/storage" -account: - peerId: "test-peer-id" - peerKey: "test-peer-key" - signingKey: "test-signing-key" -network: - listenTCPAddr: "0.0.0.0:33010" - listenUDPAddr: "0.0.0.0:33020" -coordinator: - mongoConnect: "mongodb://localhost:27017/" - mongoDatabase: "coordinator" -consensus: - mongoConnect: "mongodb://localhost:27017/?w=majority" - mongoDatabase: "consensus" -filenode: - redisConnect: "redis://localhost:6379/" - defaultLimit: 10737418240 -` - - err := os.WriteFile(cfgPath, []byte(configWithLimit), 0o600) - require.NoError(t, err) + created := CreateWrite(options) + loaded := Load(options.CfgPath) - cfg := Load(cfgPath) - assert.Equal(t, uint64(10737418240), cfg.FileNode.DefaultLimit) + assert.Equal(t, uint64(tenGiB), created.FileNode.DefaultLimit) + assert.Equal(t, uint64(tenGiB), loaded.FileNode.DefaultLimit) } func TestConfigValidate(t *testing.T) { @@ -509,9 +268,7 @@ func TestConfigValidate(t *testing.T) { mutate func(cfg *Config) wantErr string }{ - { - name: "valid config", - }, + {name: "valid config"}, { name: "missing external address", mutate: func(cfg *Config) { @@ -534,14 +291,55 @@ func TestConfigValidate(t *testing.T) { wantErr: "network.listenTCPAddr must be in host:port format", }, { - name: "invalid redis uri", + name: "tcp listen address with surrounding whitespace", + mutate: func(cfg *Config) { + cfg.Network.ListenTCPAddr = " 0.0.0.0:33010 " + }, + wantErr: "network.listenTCPAddr must not contain surrounding whitespace", + }, + { + name: "invalid MongoDB URI", + mutate: func(cfg *Config) { + cfg.Consensus.MongoConnect = "mongodb://localhost:27017?w=majority" + }, + wantErr: "consensus.mongoConnect must be a valid MongoDB URI", + }, + { + name: "MongoDB URI with surrounding whitespace", + mutate: func(cfg *Config) { + cfg.Consensus.MongoConnect = " mongodb://localhost:27017/?w=majority " + }, + wantErr: "consensus.mongoConnect must be a valid MongoDB URI", + }, + { + name: "invalid redis URI", mutate: func(cfg *Config) { cfg.FileNode.RedisConnect = "localhost:6379" }, wantErr: "filenode.redisConnect must include a host", }, { - name: "invalid s3 endpoint", + name: "Redis URI with surrounding whitespace", + mutate: func(cfg *Config) { + cfg.FileNode.RedisConnect = " redis://localhost:6379/ " + }, + wantErr: "filenode.redisConnect must not contain surrounding whitespace", + }, + { + name: "Redis URI with unsupported option", + mutate: func(cfg *Config) { + cfg.FileNode.RedisConnect = "redis://localhost:6379/?unsupported=true" + }, + wantErr: "filenode.redisConnect must be a valid Redis URI", + }, + { + name: "Redis URI with supported options", + mutate: func(cfg *Config) { + cfg.FileNode.RedisConnect = "redis://localhost:6379/1?dial_timeout=3s&max_retries=2" + }, + }, + { + name: "invalid S3 endpoint", mutate: func(cfg *Config) { cfg.FileNode.S3 = &S3Config{ Bucket: "bucket", @@ -550,31 +348,64 @@ func TestConfigValidate(t *testing.T) { }, wantErr: "filenode.s3.endpoint must include a host", }, + { + name: "S3 endpoint with surrounding whitespace", + mutate: func(cfg *Config) { + cfg.FileNode.S3 = &S3Config{ + Bucket: "bucket", + Endpoint: " https://s3.amazonaws.com ", + } + }, + wantErr: "filenode.s3.endpoint must not contain surrounding whitespace", + }, + { + name: "S3 endpoint with unsupported scheme", + mutate: func(cfg *Config) { + cfg.FileNode.S3 = &S3Config{ + Bucket: "bucket", + Endpoint: "ftp://s3.example.com", + } + }, + wantErr: "filenode.s3.endpoint must use one of: http, https", + }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { cfg := validTestConfig() - if tt.mutate != nil { - tt.mutate(cfg) + if test.mutate != nil { + test.mutate(cfg) } err := cfg.Validate() - if tt.wantErr == "" { + if test.wantErr == "" { require.NoError(t, err) return } require.Error(t, err) - assert.ErrorContains(t, err, tt.wantErr) + assert.ErrorContains(t, err, test.wantErr) }) } } +func validCreateOptions(t *testing.T) *CreateOptions { + t.Helper() + + dir := t.TempDir() + return &CreateOptions{ + CfgPath: filepath.Join(dir, "bundle.yml"), + StorePath: filepath.Join(dir, "storage"), + MongoURI: "mongodb://localhost:27017/", + RedisURI: "redis://localhost:6379/", + ExternalAddrs: []string{"192.168.1.100"}, + } +} + func validTestConfig() *Config { return &Config{ BundleVersion: "1.0.0", - BundleFormat: 1, + BundleFormat: CurrentBundleFormat, ExternalAddr: []string{"example.local"}, ConfigID: "test-config-id", NetworkID: "test-network-id", diff --git a/config/convert_test.go b/config/convert_test.go index f5d2bf5..f927d66 100644 --- a/config/convert_test.go +++ b/config/convert_test.go @@ -8,114 +8,90 @@ import ( "github.com/stretchr/testify/require" ) -func TestConvertS3Config_AllFields(t *testing.T) { - t.Setenv("AWS_ACCESS_KEY_ID", "test-access-key") - t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key") - - cfg := &Config{ - FileNode: FileNodeConfig{ - RedisConnect: "redis://localhost:6379/", - S3: &S3Config{ - Bucket: "my-bucket", - Endpoint: "https://s3.amazonaws.com", - ForcePathStyle: false, - }, - }, - } - - s3Cfg := cfg.convertS3Config() - - assert.Equal(t, "my-bucket", s3Cfg.Bucket) - assert.Equal(t, "my-bucket", s3Cfg.IndexBucket, "IndexBucket should match Bucket") - assert.Equal(t, "https://s3.amazonaws.com", s3Cfg.Endpoint) - assert.Equal(t, "us-east-1", s3Cfg.Region) - assert.Equal(t, "default", s3Cfg.Profile) - assert.Equal(t, 16, s3Cfg.MaxThreads) - assert.False(t, s3Cfg.ForcePathStyle) - assert.Equal(t, "test-access-key", s3Cfg.Credentials.AccessKey) - assert.Equal(t, "test-secret-key", s3Cfg.Credentials.SecretKey) +func TestConvertS3Config(t *testing.T) { + t.Run("all configured fields", func(t *testing.T) { + t.Setenv("AWS_ACCESS_KEY_ID", "test-access-key") + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key") + + cfg := newTestConfig() + cfg.FileNode.S3 = &S3Config{ + Bucket: "my-bucket", + Endpoint: "http://minio:9000", + Region: "custom-region", + ForcePathStyle: true, + } + + s3Cfg := cfg.convertS3Config() + + assert.Equal(t, "custom-region", s3Cfg.Region) + assert.Equal(t, "my-bucket", s3Cfg.Bucket) + assert.Equal(t, "my-bucket", s3Cfg.IndexBucket) + assert.Equal(t, "http://minio:9000", s3Cfg.Endpoint) + assert.Equal(t, "default", s3Cfg.Profile) + assert.Equal(t, 16, s3Cfg.MaxThreads) + assert.True(t, s3Cfg.ForcePathStyle) + assert.Equal(t, "test-access-key", s3Cfg.Credentials.AccessKey) + assert.Equal(t, "test-secret-key", s3Cfg.Credentials.SecretKey) + }) + + t.Run("empty region preserves backwards-compatible default", func(t *testing.T) { + cfg := newTestConfig() + cfg.FileNode.S3 = &S3Config{ + Bucket: "my-bucket", + Endpoint: "https://s3.amazonaws.com", + } + + assert.Equal(t, "us-east-1", cfg.convertS3Config().Region) + }) } -func TestConvertS3Config_ForcePathStyle(t *testing.T) { - t.Setenv("AWS_ACCESS_KEY_ID", "minio-key") - t.Setenv("AWS_SECRET_ACCESS_KEY", "minio-secret") +func TestFilenodeConfig(t *testing.T) { + t.Run("S3 configured", func(t *testing.T) { + t.Setenv("AWS_ACCESS_KEY_ID", "test-key") + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") - cfg := &Config{ - FileNode: FileNodeConfig{ - S3: &S3Config{ - Bucket: "local-bucket", - Endpoint: "http://minio:9000", - ForcePathStyle: true, - }, - }, - } + cfg := newTestConfig() + cfg.FileNode.S3 = &S3Config{ + Bucket: "test-bucket", + Endpoint: "http://minio:9000", + Region: "custom-region", + ForcePathStyle: true, + } - s3Cfg := cfg.convertS3Config() + filenode := cfg.NodeConfigs().Filenode - assert.True(t, s3Cfg.ForcePathStyle) - assert.Equal(t, "http://minio:9000", s3Cfg.Endpoint) -} - -func TestConvertS3Config_CustomRegion(t *testing.T) { - t.Setenv("AWS_ACCESS_KEY_ID", "minio-key") - t.Setenv("AWS_SECRET_ACCESS_KEY", "minio-secret") + require.NotNil(t, filenode) + assert.Equal(t, "test-bucket", filenode.S3Store.Bucket) + assert.Equal(t, "test-bucket", filenode.S3Store.IndexBucket) + assert.Equal(t, "http://minio:9000", filenode.S3Store.Endpoint) + assert.Equal(t, "custom-region", filenode.S3Store.Region) + assert.True(t, filenode.S3Store.ForcePathStyle) + }) - cfg := &Config{ - FileNode: FileNodeConfig{ - S3: &S3Config{ - Bucket: "local-bucket", - Endpoint: "http://minio:9000", - Region: "sz-hq", - ForcePathStyle: true, - }, - }, - } + t.Run("S3 absent", func(t *testing.T) { + filenode := newTestConfig().NodeConfigs().Filenode - s3Cfg := cfg.convertS3Config() + require.NotNil(t, filenode) + assert.Empty(t, filenode.S3Store.Bucket) + }) - assert.Equal(t, "sz-hq", s3Cfg.Region) -} + t.Run("explicit storage limit", func(t *testing.T) { + const tenGiB = 10 * 1024 * 1024 * 1024 -func TestConvertS3Config_EmptyRegionDefaultsToUSEast1(t *testing.T) { - t.Setenv("AWS_ACCESS_KEY_ID", "test-key") - t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") + cfg := newTestConfig() + cfg.FileNode.DefaultLimit = tenGiB - cfg := &Config{ - FileNode: FileNodeConfig{ - S3: &S3Config{ - Bucket: "my-bucket", - Endpoint: "https://s3.amazonaws.com", - Region: "", // Explicitly empty - }, - }, - } + assert.Equal(t, uint64(tenGiB), cfg.NodeConfigs().Filenode.DefaultLimit) + }) - s3Cfg := cfg.convertS3Config() + t.Run("zero storage limit uses compatibility default", func(t *testing.T) { + cfg := newTestConfig() + cfg.FileNode.DefaultLimit = 0 - assert.Equal(t, "us-east-1", s3Cfg.Region, "Empty region should default to us-east-1") + assert.Equal(t, uint64(oneTiB), cfg.NodeConfigs().Filenode.DefaultLimit) + }) } -func TestConvertS3Config_MissingCredentials(t *testing.T) { - t.Setenv("AWS_ACCESS_KEY_ID", "") - t.Setenv("AWS_SECRET_ACCESS_KEY", "") - - cfg := &Config{ - FileNode: FileNodeConfig{ - S3: &S3Config{ - Bucket: "my-bucket", - Endpoint: "https://s3.amazonaws.com", - }, - }, - } - - s3Cfg := cfg.convertS3Config() - - // Should still create config, but with empty credentials - assert.Empty(t, s3Cfg.Credentials.AccessKey) - assert.Empty(t, s3Cfg.Credentials.SecretKey) -} - -// newTestConfig creates a minimal valid Config for testing. func newTestConfig() *Config { return &Config{ ConfigID: "test-config-id", @@ -144,60 +120,3 @@ func newTestConfig() *Config { ExternalAddr: []string{"192.168.1.100"}, } } - -func TestFilenodeConfig_WithS3(t *testing.T) { - t.Setenv("AWS_ACCESS_KEY_ID", "test-key") - t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") - - cfg := newTestConfig() - cfg.FileNode.S3 = &S3Config{ - Bucket: "test-bucket", - Endpoint: "https://s3.amazonaws.com", - ForcePathStyle: false, - } - - nodeCfgs := cfg.NodeConfigs() - - require.NotNil(t, nodeCfgs.Filenode) - assert.Equal(t, "test-bucket", nodeCfgs.Filenode.S3Store.Bucket) - assert.Equal(t, "test-bucket", nodeCfgs.Filenode.S3Store.IndexBucket) - assert.Equal(t, "https://s3.amazonaws.com", nodeCfgs.Filenode.S3Store.Endpoint) -} - -func TestFilenodeConfig_WithoutS3(t *testing.T) { - cfg := newTestConfig() - // No S3 config - default - - nodeCfgs := cfg.NodeConfigs() - - require.NotNil(t, nodeCfgs.Filenode) - // S3Store should be empty (zero value) - assert.Empty(t, nodeCfgs.Filenode.S3Store.Bucket) -} - -// Filenode Default Limit Tests - -func TestFilenodeConfig_DefaultLimit_CustomValue(t *testing.T) { - const tenGiB = 10 * 1024 * 1024 * 1024 // 10 GiB - - cfg := newTestConfig() - cfg.FileNode.DefaultLimit = tenGiB - - nodeCfgs := cfg.NodeConfigs() - - require.NotNil(t, nodeCfgs.Filenode) - assert.Equal(t, uint64(tenGiB), nodeCfgs.Filenode.DefaultLimit) -} - -func TestFilenodeConfig_DefaultLimit_ZeroDefaultsTo1TiB(t *testing.T) { - const oneTiB = 1024 * 1024 * 1024 * 1024 // 1 TiB - - cfg := newTestConfig() - cfg.FileNode.DefaultLimit = 0 // Not set - - nodeCfgs := cfg.NodeConfigs() - - require.NotNil(t, nodeCfgs.Filenode) - assert.Equal(t, uint64(oneTiB), nodeCfgs.Filenode.DefaultLimit, - "Zero DefaultLimit should fallback to 1 TiB") -} diff --git a/config/testdata/bundle-v1.0.yml b/config/testdata/bundle-v1.0.yml new file mode 100644 index 0000000..d595f26 --- /dev/null +++ b/config/testdata/bundle-v1.0.yml @@ -0,0 +1,22 @@ +bundleVersion: "1.0.0" +bundleFormat: 1 +externalAddr: + - "192.168.1.100" +configId: "test-config-id" +networkId: "test-network-id" +storagePath: "./data/storage" +account: + peerId: "test-peer-id" + peerKey: "test-peer-key" + signingKey: "test-signing-key" +network: + listenTCPAddr: "0.0.0.0:33010" + listenUDPAddr: "0.0.0.0:33020" +coordinator: + mongoConnect: "mongodb://localhost:27017/" + mongoDatabase: "coordinator" +consensus: + mongoConnect: "mongodb://localhost:27017/?w=majority" + mongoDatabase: "consensus" +filenode: + redisConnect: "redis://localhost:6379/" diff --git a/config/testdata/bundle-v1.2-s3.yml b/config/testdata/bundle-v1.2-s3.yml new file mode 100644 index 0000000..121d840 --- /dev/null +++ b/config/testdata/bundle-v1.2-s3.yml @@ -0,0 +1,26 @@ +bundleVersion: "1.2.0" +bundleFormat: 1 +externalAddr: + - "192.168.1.100" +configId: "test-config-id" +networkId: "test-network-id" +storagePath: "./data/storage" +account: + peerId: "test-peer-id" + peerKey: "test-peer-key" + signingKey: "test-signing-key" +network: + listenTCPAddr: "0.0.0.0:33010" + listenUDPAddr: "0.0.0.0:33020" +coordinator: + mongoConnect: "mongodb://localhost:27017/" + mongoDatabase: "coordinator" +consensus: + mongoConnect: "mongodb://localhost:27017/?w=majority" + mongoDatabase: "consensus" +filenode: + redisConnect: "redis://localhost:6379/" + s3: + bucket: "my-bucket" + endpoint: "https://s3.amazonaws.com" + forcePathStyle: true diff --git a/config/testdata/bundle-v1.3-s3.yml b/config/testdata/bundle-v1.3-s3.yml new file mode 100644 index 0000000..4bb7f15 --- /dev/null +++ b/config/testdata/bundle-v1.3-s3.yml @@ -0,0 +1,27 @@ +bundleVersion: "1.3.0" +bundleFormat: 1 +externalAddr: + - "192.168.1.100" +configId: "test-config-id" +networkId: "test-network-id" +storagePath: "./data/storage" +account: + peerId: "test-peer-id" + peerKey: "test-peer-key" + signingKey: "test-signing-key" +network: + listenTCPAddr: "0.0.0.0:33010" + listenUDPAddr: "0.0.0.0:33020" +coordinator: + mongoConnect: "mongodb://localhost:27017/" + mongoDatabase: "coordinator" +consensus: + mongoConnect: "mongodb://localhost:27017/?w=majority" + mongoDatabase: "consensus" +filenode: + redisConnect: "redis://localhost:6379/" + s3: + bucket: "my-bucket" + endpoint: "https://s3.eu-central-1.amazonaws.com" + region: "eu-central-1" + defaultLimit: 10737418240 diff --git a/flake.lock b/flake.lock index 71ba609..4c03e0a 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1774106199, - "narHash": "sha256-US5Tda2sKmjrg2lNHQL3jRQ6p96cgfWh3J1QBliQ8Ws=", + "lastModified": 1784120854, + "narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "6c9a78c09ff4d6c21d0319114873508a6ec01655", + "rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index e9f502b..b37dbbe 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,6 @@ "x86_64-linux" "aarch64-linux" "aarch64-darwin" - "x86_64-darwin" ]; perSystem = @@ -35,8 +34,6 @@ let goPackage = pkgs.go; - # Version information - extract from git or use defaults - version = if self ? rev then self.shortRev else "dev"; commit = self.rev or "dirty"; date = self.lastModifiedDate or "1970-01-01T00:00:00Z"; @@ -46,11 +43,11 @@ { packages.default = (pkgs.buildGoModule.override { go = goPackage; }) rec { pname = "any-sync-bundle"; - version = "v1.4.3-2026-04-21"; + version = "v1.5.0-2026-07-17"; src = ./.; - vendorHash = "sha256-qYqMaGfEzJR2feV2GhDBhEPnkH6a5cdhTB3+hmc7ykI="; + vendorHash = "sha256-zdmDItdWo76+wluHRz3hqG4IQsXaYnRtUpT0EleRsew="; env.CGO_ENABLED = 0; @@ -62,6 +59,8 @@ "-X github.com/grishy/any-sync-bundle/cmd.date=${buildDate}" ]; + # Integration tests require Docker and run in their own CI job. + excludedPackages = [ "integration" ]; doCheck = true; meta = with lib; { @@ -92,7 +91,7 @@ ]; shellHook = '' - echo "π any-sync-bundle development environment" + echo "any-sync-bundle development environment" echo "" echo "Available commands:" echo " go build - Build the binary" diff --git a/go.mod b/go.mod index 3d76d97..4311dc2 100644 --- a/go.mod +++ b/go.mod @@ -1,31 +1,32 @@ module github.com/grishy/any-sync-bundle -go 1.26 +go 1.26.4 tool github.com/matryer/moq // Source: https://puppetdoc.anytype.io/api/v1/prod-any-sync-compatible-versions/ -// Current timestamp: "1776775011" +// Current timestamp: "1784291832" require ( - github.com/anyproto/any-sync-consensusnode v0.7.2 - github.com/anyproto/any-sync-coordinator v0.9.1 - github.com/anyproto/any-sync-filenode v0.11.1 - github.com/anyproto/any-sync-node v0.11.1 + github.com/anyproto/any-sync-consensusnode v0.12.0 + github.com/anyproto/any-sync-coordinator v0.12.0 + github.com/anyproto/any-sync-filenode v0.12.0 + github.com/anyproto/any-sync-node v0.12.1 ) require ( - github.com/anyproto/any-sync v0.11.20 + github.com/anyproto/any-sync v0.12.15 github.com/dgraph-io/badger/v4 v4.9.1 github.com/ipfs/go-block-format v0.2.3 - github.com/ipfs/go-cid v0.6.0 + github.com/ipfs/go-cid v0.6.1 github.com/multiformats/go-multihash v0.2.3 + github.com/redis/go-redis/v9 v9.21.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.41.0 github.com/testcontainers/testcontainers-go/modules/mongodb v0.41.0 github.com/testcontainers/testcontainers-go/modules/redis v0.41.0 github.com/urfave/cli/v2 v2.27.7 go.mongodb.org/mongo-driver v1.17.9 - go.uber.org/zap v1.27.1 + go.uber.org/zap v1.28.0 gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 gopkg.in/yaml.v3 v3.0.1 ) @@ -37,7 +38,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/OneOfOne/xxhash v1.2.8 // indirect github.com/akrylysov/pogreb v0.10.3-0.20240803013244-523613e335e9 // indirect - github.com/anyproto/any-store v0.4.6 // indirect + github.com/anyproto/any-store v0.4.7 // indirect github.com/anyproto/go-bip39 v1.0.0 // indirect github.com/anyproto/go-chash v0.1.0 // indirect github.com/anyproto/go-slip10 v1.0.1 // indirect @@ -46,9 +47,6 @@ require ( github.com/anyproto/lexid v0.0.6 // indirect github.com/aws/aws-sdk-go v1.55.8 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/btcsuite/btcd v0.22.1 // indirect - github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect - github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -61,14 +59,14 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/disintegration/imaging v1.6.2 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/dunglas/httpsfv v1.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -89,17 +87,17 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/huandu/skiplist v1.2.1 // indirect - github.com/ipfs/boxo v0.37.0 // indirect + github.com/ipfs/boxo v0.41.0 // indirect github.com/jbenet/go-temp-err-catcher v0.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/libp2p/go-buffer-pool v0.1.0 // indirect - github.com/libp2p/go-libp2p v0.47.0 // indirect + github.com/libp2p/go-libp2p v0.48.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/matryer/moq v0.7.1 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/mdelapenya/tlscert v0.2.0 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -111,11 +109,11 @@ require ( github.com/moby/term v0.5.2 // indirect github.com/montanaflynn/stats v0.7.1 // indirect github.com/morikuni/aec v1.0.0 // indirect - github.com/mr-tron/base58 v1.2.0 // indirect + github.com/mr-tron/base58 v1.3.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect github.com/multiformats/go-multiaddr v0.16.1 // indirect - github.com/multiformats/go-multibase v0.2.0 // indirect + github.com/multiformats/go-multibase v0.3.0 // indirect github.com/multiformats/go-multicodec v0.10.0 // indirect github.com/multiformats/go-multistream v0.6.1 // indirect github.com/multiformats/go-varint v0.1.0 // indirect @@ -129,10 +127,11 @@ require ( github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.17.0 // indirect - github.com/quic-go/quic-go v0.59.0 // indirect - github.com/redis/go-redis/v9 v9.18.0 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.60.0 // indirect + github.com/quic-go/webtransport-go v0.11.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shirou/gopsutil/v4 v4.26.2 // indirect @@ -141,7 +140,7 @@ require ( github.com/tetratelabs/wazero v1.10.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect - github.com/valyala/fastjson v1.6.7 // indirect + github.com/valyala/fastjson v1.6.10 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect @@ -151,28 +150,28 @@ require ( github.com/zeebo/blake3 v0.2.4 // indirect github.com/zeebo/errs v1.3.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect - go.opentelemetry.io/otel/metric v1.41.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect golang.org/x/image v0.21.0 // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/tools v0.47.0 // indirect google.golang.org/protobuf v1.36.11 // indirect lukechampine.com/blake3 v1.4.1 // indirect modernc.org/libc v1.66.8 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.38.0 // indirect - storj.io/drpc v0.0.34 // indirect + storj.io/drpc v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index dc60219..a61e367 100644 --- a/go.sum +++ b/go.sum @@ -11,21 +11,20 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= -github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/akrylysov/pogreb v0.10.3-0.20240803013244-523613e335e9 h1:GnBlbnor8geJB4j7GGXqVHiSG0cHNEkIYNmpYDAPa+Y= github.com/akrylysov/pogreb v0.10.3-0.20240803013244-523613e335e9/go.mod h1:fPb3n+7H42SxX84B4/os7POrR+UGRKSRr3kNS5+xq/c= -github.com/anyproto/any-store v0.4.6 h1:opnTUGfuHa/VA9Incu6iLEy3WsQ4mb2o1oAXGDysH0s= -github.com/anyproto/any-store v0.4.6/go.mod h1:Npi35qMUVZ8ouiV4o9AqpZDs6LbDOF+5ZLlVijXofFM= -github.com/anyproto/any-sync v0.11.20 h1:/yv38s5KJh1A9SmYj1zD5k8Fqp1v0UZae3n8KNBd5Z0= -github.com/anyproto/any-sync v0.11.20/go.mod h1:qR5leKtd4j6cnmFiaI4ULe+Az3qjhKpdFtScV7aFCHY= -github.com/anyproto/any-sync-consensusnode v0.7.2 h1:zGSCi63FoMJ1RPVWRuERRt71LBvjOzNkx3A8nK81NTQ= -github.com/anyproto/any-sync-consensusnode v0.7.2/go.mod h1:0EDAUCW7NjS5UgJ0CR5hUUPscZzyloqA96HLraqbe+I= -github.com/anyproto/any-sync-coordinator v0.9.1 h1:eU02nPIr5WI6Z6sWJS43bIOn/G2CBr9NEvqDufyj4Lw= -github.com/anyproto/any-sync-coordinator v0.9.1/go.mod h1:4YIT3mCgf9ACX8Hsp0Om2ioPx12MAVzq6ZEpxbr7Yy8= -github.com/anyproto/any-sync-filenode v0.11.1 h1:0uok3TMPtX9s9dtBo2PLJi/ooP9SwkE7p2nCanhugYI= -github.com/anyproto/any-sync-filenode v0.11.1/go.mod h1:43C2Y1rNa3rB58I03NXRSwySpQt2srST6bI53HRFSpE= -github.com/anyproto/any-sync-node v0.11.1 h1:OpY6iDZ60CIC2lqftc/e23F1P5SqxFy40JPIDv5sGW0= -github.com/anyproto/any-sync-node v0.11.1/go.mod h1:5WVFAhPnopael+Pbt/ToKBrrU6jE8o6MbbBfDg4gKc0= +github.com/anyproto/any-store v0.4.7 h1:329NWY/xUzGdwKSqFgjAFaPAVkTJbR+WdJirPrvTm/w= +github.com/anyproto/any-store v0.4.7/go.mod h1:8cqb52gjZSaYnlybugqpSqSG1RQygY96D2vWMbSJsLo= +github.com/anyproto/any-sync v0.12.15 h1:tkKzDhERXvsIc2TPirXtAksGe6wzHV/BLGmYOPka4Ck= +github.com/anyproto/any-sync v0.12.15/go.mod h1:Qf5cczER7nMF64KeRrgR4qRdTTKRf83M4gTzXzkujEg= +github.com/anyproto/any-sync-consensusnode v0.12.0 h1:qgScFjgDaLKAfO/9rWTtfG0sRPYfxtb62Q9PGwynnCE= +github.com/anyproto/any-sync-consensusnode v0.12.0/go.mod h1:4HcvY5v2tNk5+IF42W5Kkc9a+e0xXwbLAykTXGaeBlE= +github.com/anyproto/any-sync-coordinator v0.12.0 h1:2T1xuBm+D2tLWpp8mvc7QiFd71FhQlHNWnq0BkMQcNU= +github.com/anyproto/any-sync-coordinator v0.12.0/go.mod h1:ka6Ji1ba3s66cUZW21KbH1uCwK/zAHh4aaNiXAGkg6c= +github.com/anyproto/any-sync-filenode v0.12.0 h1:UieWIVhPr1h4qRBuO7Zyqr1i17dxEHv5R+Dbe3CAYRE= +github.com/anyproto/any-sync-filenode v0.12.0/go.mod h1:X8g8P9yULXuxA7ujIdyeRV29uz6wll8md1P7yaBNRKA= +github.com/anyproto/any-sync-node v0.12.1 h1:BQxiWHGIEVdSV8zaDS+zTQggxv6rmkXHyfb7SpD5laY= +github.com/anyproto/any-sync-node v0.12.1/go.mod h1:zwbP39ziDUP2GJ3EmqWa6fbaarmmZZrwR1kyhnp+o0E= github.com/anyproto/go-bip39 v1.0.0 h1:T6/7WowKYDeyuX/QyXtt98ZX0XXaoOh17M/LFF2M5yk= github.com/anyproto/go-bip39 v1.0.0/go.mod h1:l0rcxmXRyiWAYzE1noMAc4qbeNrbhUwxM3rqSO9ILwo= github.com/anyproto/go-chash v0.1.0 h1:I9meTPjXFRfXZHRJzjOHC/XF7Q5vzysKkiT/grsogXY= @@ -46,20 +45,6 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= -github.com/btcsuite/btcd v0.22.1 h1:CnwP9LM/M9xuRrGSCGeMVs9iv09uMqwsVX7EeIpgV2c= -github.com/btcsuite/btcd v0.22.1/go.mod h1:wqgTSL29+50LRkmOVknEdmt8ZojIzhuWvgu/iptuN7Y= -github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= -github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= -github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= -github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= -github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce h1:YtWJF7RHm2pYCvA5t0RPmAaLUhREsKuKd+SLhxFbFeQ= -github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce/go.mod h1:0DVlHczLPewLcPGEIeUEzfOJhqGPQ0mJJRDBtD307+o= -github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= -github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= -github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= -github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= -github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= 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/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -86,7 +71,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3 github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -94,8 +78,8 @@ github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dgraph-io/badger/v4 v4.9.1 h1:DocZXZkg5JJHJPtUErA0ibyHxOVUDVoXLSCV6t8NC8w= github.com/dgraph-io/badger/v4 v4.9.1/go.mod h1:5/MEx97uzdPUHR4KtkNt8asfI2T4JiEiQlV7kWUo8c0= github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= @@ -114,6 +98,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM 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/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= +github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= @@ -124,7 +110,6 @@ github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6 github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw= github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 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= @@ -148,7 +133,6 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8= @@ -160,8 +144,8 @@ 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.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -169,28 +153,24 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c= github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= github.com/huandu/skiplist v1.2.1 h1:dTi93MgjwErA/8idWTzIw4Y1kZsMWx35fmI2c8Rij7w= github.com/huandu/skiplist v1.2.1/go.mod h1:7v3iFjLcSAzO4fN5B8dvebvo/qsfumiLiDXMrPiHF9w= -github.com/ipfs/boxo v0.37.0 h1:2E3mZvydMI2t5IkAgtkmZ3sGsld0oS7o3I+xyzDk6uI= -github.com/ipfs/boxo v0.37.0/go.mod h1:8yyiRn54F2CsW13n0zwXEPrVsZix/gFj9SYIRYMZ6KE= +github.com/ipfs/boxo v0.41.0 h1:diKlFosOG2e1mgSO1CXqcMSnHvtn6ubUvaCf9iF8AIY= +github.com/ipfs/boxo v0.41.0/go.mod h1:1Fo36UVVvq3XAZwMDD82Cm4JTUi5x1k3AsJlg9DttOY= github.com/ipfs/go-block-format v0.2.3 h1:mpCuDaNXJ4wrBJLrtEaGFGXkferrw5eqVvzaHhtFKQk= github.com/ipfs/go-block-format v0.2.3/go.mod h1:WJaQmPAKhD3LspLixqlqNFxiZ3BZ3xgqxxoSR/76pnA= -github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= -github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= +github.com/ipfs/go-cid v0.6.1 h1:T5TnNb08+ueovG76Z5gx1L4Y7QOaGTXHg1F6raWFxIc= +github.com/ipfs/go-cid v0.6.1/go.mod h1:zrY0SwOhjrrIdfPQ/kf+k1sXyJ0QE7cMxfCployLBs0= github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk= github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk= -github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -203,8 +183,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= -github.com/libp2p/go-libp2p v0.47.0 h1:qQpBjSCWNQFF0hjBbKirMXE9RHLtSuzTDkTfr1rw0yc= -github.com/libp2p/go-libp2p v0.47.0/go.mod h1:s8HPh7mMV933OtXzONaGFseCg/BE//m1V34p3x4EUOY= +github.com/libp2p/go-libp2p v0.48.0 h1:h2BrLAgrj7X8bEN05K7qmrjpNHYA+6tnsGRdprjTnvo= +github.com/libp2p/go-libp2p v0.48.0/go.mod h1:Q1fBZNdmC2Hf82husCTfkKJVfHm2we5zk+NWmOGEmWk= github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0= github.com/libp2p/go-msgio v0.3.0/go.mod h1:nyRM819GmVaF9LX3l03RMh10QdOroF++NBbxAb0mmDM= github.com/libp2p/go-yamux/v5 v5.0.1 h1:f0WoX/bEF2E8SbE4c/k1Mo+/9z0O4oC/hWEA+nfYRSg= @@ -215,8 +195,8 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/matryer/moq v0.7.1 h1:/QaXqMAdOrLqlshW2z7SMS21jDi7aVrbW0wJrR+hhJk= github.com/matryer/moq v0.7.1/go.mod h1:IabIiFkaKCyHxej25INgFR+fnOxSZFMv2LYrU+ioyDs= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= @@ -241,16 +221,16 @@ github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8 github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= -github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI= +github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8= github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= github.com/multiformats/go-multiaddr v0.16.1 h1:fgJ0Pitow+wWXzN9do+1b8Pyjmo8m5WhGfzpL82MpCw= github.com/multiformats/go-multiaddr v0.16.1/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= -github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= -github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multibase v0.3.0 h1:8helZD2+4Db7NNWFiktk2NePbF0boolBe6bDQvM4r68= +github.com/multiformats/go-multibase v0.3.0/go.mod h1:MoBLQPCkRTOL3eveIPO81860j2AQY8JwcnNlRkGRUfI= github.com/multiformats/go-multicodec v0.10.0 h1:UpP223cig/Cx8J76jWt91njpK3GTAO1w02sdcjZDSuc= github.com/multiformats/go-multicodec v0.10.0/go.mod h1:wg88pM+s2kZJEQfRCKBNU+g32F5aWBEjyFHXvZLTcLI= github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= @@ -265,9 +245,6 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 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= @@ -284,14 +261,20 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= -github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= -github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= -github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= -github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= -github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= +github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= +github.com/quic-go/webtransport-go v0.11.0 h1:3afiZq7MHv3gmKCbMwZ8D5M1u0y/1RdONN9KlWp32J0= +github.com/quic-go/webtransport-go v0.11.0/go.mod h1:SHgEzUFVyj+9WUSuGB1P6Zd351Pww2leWV3SwlTovkA= +github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= +github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/redis/rueidis v1.0.71 h1:pODtnAR5GAB7j4ekhldZ29HKOxe4Hph0GTDGk1ayEQY= github.com/redis/rueidis v1.0.71/go.mod h1:lfdcZzJ1oKGKL37vh9fO3ymwt+0TdjkkUCJxbgpmcgQ= github.com/redis/rueidis/rueidiscompat v1.0.71 h1:wNZ//kEjMZgBM0KCk7ncOX8KmAgROU2kDdDNpwheG4w= @@ -332,8 +315,8 @@ github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9R github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= -github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= -github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= @@ -357,32 +340,32 @@ github.com/zeebo/errs v1.3.0 h1:hmiaKqgYZzcVgRL1Vkc1Mn2914BbzB0IBxs+ebeutGs= github.com/zeebo/errs v1.3.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.mongodb.org/mongo-driver/v2 v2.3.0 h1:sh55yOXA2vUjW1QYw/2tRlHSQViwDyPnW61AwpZ4rtU= go.mongodb.org/mongo-driver/v2 v2.3.0/go.mod h1:jHeEDJHJq7tm6ZF45Issun9dbogjfnPySb1vXA7EeAI= 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.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= -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.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= -go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= -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= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -391,46 +374,43 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= -golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= -golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= +golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s= golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= 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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -443,19 +423,18 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -463,28 +442,25 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= 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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 h1:VpOs+IwYnYBaFnrNAeB8UUWtL3vEUnzSCL1nVjPhqrw= gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -521,5 +497,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -storj.io/drpc v0.0.34 h1:q9zlQKfJ5A7x8NQNFk8x7eKUF78FMhmAbZLnFK+og7I= -storj.io/drpc v0.0.34/go.mod h1:Y9LZaa8esL1PW2IDMqJE7CFSNq7d5bQ3RI7mGPtmKMg= +storj.io/drpc v1.0.0 h1:1Xf1KCXXbV1viIfN56eqdJ3cNwpAL7OKwQkpS6ksing= +storj.io/drpc v1.0.0/go.mod h1:Y9LZaa8esL1PW2IDMqJE7CFSNq7d5bQ3RI7mGPtmKMg= diff --git a/integration/bundle.go b/integration/bundle.go index 2ba0252..d85127c 100644 --- a/integration/bundle.go +++ b/integration/bundle.go @@ -15,12 +15,15 @@ import ( "strings" "sync" "time" + + bundlecmd "github.com/grishy/any-sync-bundle/cmd" ) const ( bundleReadyEvent = "bundle_ready" bundleShutdownCompleteEvent = "bundle_shutdown_complete" filenodeStorageBackendS3 = "filenode_storage_backend_s3" + shutdownObservationMargin = 5 * time.Second ) // BundleProcess manages the any-sync-bundle process. @@ -204,7 +207,7 @@ func (bp *BundleProcess) Stop() error { select { case <-bp.waitDone: return bp.shutdownResult() - case <-time.After(30 * time.Second): + case <-time.After(bundlecmd.ShutdownTimeout + shutdownObservationMargin): _ = bp.cmd.Process.Kill() return errors.New("timeout during shutdown, killed process") } diff --git a/integration/integration_test.go b/integration/integration_test.go index bb18891..b81af63 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -3,167 +3,210 @@ package integration import ( + "bytes" "context" + "fmt" + "io" + "os" + "os/exec" "testing" "time" + "github.com/anyproto/any-sync-filenode/store/s3store" + "github.com/anyproto/any-sync/app" + blocks "github.com/ipfs/go-block-format" "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + + bundleconfig "github.com/grishy/any-sync-bundle/config" ) func TestBundleFreshInstall(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) defer cancel() - // Start MongoDB mongo, err := StartMongo(ctx) - require.NoError(t, err, "Failed to start MongoDB") + require.NoError(t, err, "start MongoDB") defer mongo.Terminate(ctx) - t.Logf("MongoDB URI: %s", mongo.URI) - // Start Redis redis, err := StartRedis(ctx) - require.NoError(t, err, "Failed to start Redis") + require.NoError(t, err, "start Redis") defer redis.Terminate(ctx) - t.Logf("Redis URI: %s", redis.URI) - // Start bundle bundle, err := StartBundle(ctx, BundleConfig{ MongoURI: mongo.URI, RedisURI: redis.URI, }) - require.NoError(t, err, "Failed to start bundle") + require.NoError(t, err, "start bundle") defer bundle.Cleanup() defer bundle.Stop() - // Wait for ready - err = bundle.WaitReady(90 * time.Second) - require.NoError(t, err, "Bundle should become ready") - t.Log("Bundle is ready") + require.NoError(t, bundle.WaitReady(90*time.Second)) + require.NoError(t, bundle.VerifyPort("33010")) + require.NoError(t, bundle.Stop(), "bundle should shut down cleanly") +} - // Verify TCP port - err = bundle.VerifyPort("33010") - require.NoError(t, err, "Port 33010 should be listening") - t.Log("Port 33010 is listening") +// This test deliberately crosses the configuration and S3 request boundaries. +// Observing startup alone cannot detect invalid SigV4 credentials or region. +func TestS3StorageCustomRegionRoundTrip(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) + defer cancel() - // Graceful shutdown - err = bundle.Stop() - require.NoError(t, err, "Bundle should shutdown cleanly") - t.Log("Bundle shutdown complete") + const minioRegion = "custom-test-region" + minio, err := StartMinIOWithRegion(ctx, minioRegion) + require.NoError(t, err, "start MinIO") + defer minio.Terminate(ctx) + + t.Setenv("AWS_ACCESS_KEY_ID", minio.AccessKey) + t.Setenv("AWS_SECRET_ACCESS_KEY", minio.SecretKey) + + cfg := &bundleconfig.Config{ + ConfigID: "s3-integration", + NetworkID: "s3-integration", + StoragePath: t.TempDir(), + Network: bundleconfig.NetworkConfig{ + ListenTCPAddr: "127.0.0.1:33010", + ListenUDPAddr: "127.0.0.1:33020", + }, + FileNode: bundleconfig.FileNodeConfig{ + S3: &bundleconfig.S3Config{ + Bucket: "anytype-data", + Endpoint: minio.Endpoint, + Region: minioRegion, + ForcePathStyle: true, + }, + }, + } + + filenodeCfg := cfg.NodeConfigs().Filenode + store := s3store.New() + require.NoError(t, store.Init(new(app.App).Register(filenodeCfg))) + require.NoError(t, store.Run(ctx)) + defer store.Close(context.Background()) + + expected := blocks.NewBlock([]byte("custom-region-round-trip")) + require.NoError(t, store.Add(ctx, []blocks.Block{expected})) + + actual, err := store.Get(ctx, expected.Cid()) + require.NoError(t, err) + require.True(t, bytes.Equal(expected.RawData(), actual.RawData())) } -func TestBundleWithS3Storage(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) +// The direct S3 test proves request signing; this test proves that bundle +// configuration selects and runs the same backend through the filenode app. +func TestBundleWithS3CustomRegion(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) defer cancel() - // Start MongoDB mongo, err := StartMongo(ctx) - require.NoError(t, err, "Failed to start MongoDB") + require.NoError(t, err, "start MongoDB") defer mongo.Terminate(ctx) - t.Logf("MongoDB URI: %s", mongo.URI) - // Start Redis redis, err := StartRedis(ctx) - require.NoError(t, err, "Failed to start Redis") + require.NoError(t, err, "start Redis") defer redis.Terminate(ctx) - t.Logf("Redis URI: %s", redis.URI) - // Start MinIO - minio, err := StartMinIO(ctx) - require.NoError(t, err, "Failed to start MinIO") + const minioRegion = "custom-test-region" + minio, err := StartMinIOWithRegion(ctx, minioRegion) + require.NoError(t, err, "start MinIO") defer minio.Terminate(ctx) - t.Logf("MinIO Endpoint: %s", minio.Endpoint) - // Start bundle with S3 bundle, err := StartBundle(ctx, BundleConfig{ MongoURI: mongo.URI, RedisURI: redis.URI, S3Bucket: "anytype-data", S3Endpoint: minio.Endpoint, + S3Region: minioRegion, S3AccessKey: minio.AccessKey, S3SecretKey: minio.SecretKey, }) - require.NoError(t, err, "Failed to start bundle") + require.NoError(t, err, "start bundle") defer bundle.Cleanup() defer bundle.Stop() - // Wait for ready - err = bundle.WaitReady(90 * time.Second) - require.NoError(t, err, "Bundle should become ready") - t.Log("Bundle is ready") - - // Verify S3 backend selected - err = bundle.WaitForS3Backend(5 * time.Second) - require.NoError(t, err, "S3 storage backend should be selected") - t.Log("S3 storage backend confirmed") - - // Verify TCP port - err = bundle.VerifyPort("33010") - require.NoError(t, err, "Port 33010 should be listening") - t.Log("Port 33010 is listening") - - // Graceful shutdown - err = bundle.Stop() - require.NoError(t, err, "Bundle should shutdown cleanly") - t.Log("Bundle shutdown complete") + require.NoError(t, bundle.WaitForS3Backend(5*time.Second)) + require.NoError(t, bundle.WaitReady(90*time.Second)) + require.NoError(t, bundle.VerifyPort("33010")) + require.NoError(t, bundle.Stop(), "bundle should shut down cleanly") } -// TestBundleWithS3CustomRegion verifies that the bundle works with MinIO -// configured with a custom region. This tests the fix for GitHub issue #47. -func TestBundleWithS3CustomRegion(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) +// This is the container-boundary proof for the embedded process supervisor. +// A clean exit means services stopped first, MongoDB and Redis received their +// grace period, every child was reaped, and the final marker was published. +func TestAllInOneContainerShutsDownCleanly(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 8*time.Minute) defer cancel() - customRegion := "custom-test-region" - - // Start MongoDB - mongo, err := StartMongo(ctx) - require.NoError(t, err, "Failed to start MongoDB") - defer mongo.Terminate(ctx) - t.Logf("MongoDB URI: %s", mongo.URI) + // All Dockerfile bases are public. An empty auth config keeps the build + // independent of unrelated or expired credentials in the operator's store. + t.Setenv("DOCKER_AUTH_CONFIG", "{}") + + image := fmt.Sprintf("any-sync-bundle-integration:%d", os.Getpid()) + buildImage := exec.CommandContext( + ctx, + "docker", + "build", + "--quiet", + "--target", + "stage-release-all-in-one", + "--tag", + image, + "..", + ) + buildOutput, err := buildImage.CombinedOutput() + require.NoError(t, err, "build all-in-one image:\n%s", buildOutput) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout( + context.Background(), + time.Minute, + ) + defer cleanupCancel() + _ = exec.CommandContext( + cleanupCtx, + "docker", + "image", + "rm", + "--force", + image, + ).Run() + }) - // Start Redis - redis, err := StartRedis(ctx) - require.NoError(t, err, "Failed to start Redis") - defer redis.Terminate(ctx) - t.Logf("Redis URI: %s", redis.URI) + container, err := testcontainers.GenericContainer( + ctx, + testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: image, + Env: map[string]string{ + "ANY_SYNC_BUNDLE_INIT_EXTERNAL_ADDRS": "127.0.0.1", + }, + WaitingFor: wait.ForLog(bundleReadyEvent). + WithStartupTimeout(7 * time.Minute), + }, + Started: true, + }, + ) + require.NoError(t, err, "start all-in-one container") + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout( + context.Background(), + time.Minute, + ) + defer cleanupCancel() + _ = container.Terminate(cleanupCtx) + }) - // Start MinIO with custom region - minio, err := StartMinIOWithRegion(ctx, customRegion) - require.NoError(t, err, "Failed to start MinIO with custom region") - defer minio.Terminate(ctx) - t.Logf("MinIO Endpoint: %s, Region: %s", minio.Endpoint, minio.Region) + stopGracePeriod := 2 * time.Minute + require.NoError(t, container.Stop(ctx, &stopGracePeriod)) - // Start bundle with S3 and matching region - bundle, err := StartBundle(ctx, BundleConfig{ - MongoURI: mongo.URI, - RedisURI: redis.URI, - S3Bucket: "anytype-data", - S3Endpoint: minio.Endpoint, - S3Region: customRegion, - S3AccessKey: minio.AccessKey, - S3SecretKey: minio.SecretKey, - }) - require.NoError(t, err, "Failed to start bundle") - defer bundle.Cleanup() - defer bundle.Stop() + logs, err := container.Logs(ctx) + require.NoError(t, err) + output, err := io.ReadAll(logs) + require.NoError(t, err) + require.NoError(t, logs.Close()) + require.Contains(t, string(output), bundleShutdownCompleteEvent) - // Wait for ready - err = bundle.WaitReady(90 * time.Second) - require.NoError(t, err, "Bundle should become ready with custom S3 region") - t.Log("Bundle is ready") - - // Verify S3 backend selected - err = bundle.WaitForS3Backend(5 * time.Second) - require.NoError(t, err, "S3 storage backend should be selected") - t.Log("S3 storage backend confirmed") - - // Verify TCP port - err = bundle.VerifyPort("33010") - require.NoError(t, err, "Port 33010 should be listening") - t.Log("Port 33010 is listening") - - // Graceful shutdown - err = bundle.Stop() - require.NoError(t, err, "Bundle should shutdown cleanly") - t.Log("Bundle shutdown complete") + state, err := container.State(ctx) + require.NoError(t, err) + require.Equal(t, 0, state.ExitCode) } diff --git a/lightcmp/lightfilenodestore/store.go b/lightcmp/lightfilenodestore/store.go index d8d6f1b..2b6561a 100644 --- a/lightcmp/lightfilenodestore/store.go +++ b/lightcmp/lightfilenodestore/store.go @@ -56,6 +56,7 @@ type LightFileNodeStore struct { cfg storeConfig db *badger.DB gcCancel context.CancelFunc + gcDone chan struct{} } func New(storePath string) *LightFileNodeStore { @@ -79,8 +80,10 @@ func (s *LightFileNodeStore) Name() string { } func (s *LightFileNodeStore) Run(ctx context.Context) error { + // Match S3's durability boundary: acknowledged writes must survive a hard reboot. opts := badger.DefaultOptions(s.cfg.storePath). WithLogger(badgerLogger{}). + WithSyncWrites(true). WithCompression(options.None). WithZSTDCompressionLevel(0) @@ -91,24 +94,34 @@ func (s *LightFileNodeStore) Run(ctx context.Context) error { s.db = db - // Create a cancellable context for GC gcCtx, cancel := context.WithCancel(ctx) + gcDone := make(chan struct{}) s.gcCancel = cancel - go s.runGC(gcCtx) + s.gcDone = gcDone + go func() { + defer close(gcDone) + s.runGC(gcCtx) + }() return nil } func (s *LightFileNodeStore) Close(_ context.Context) error { - // Cancel the GC goroutine if s.gcCancel != nil { s.gcCancel() } - if s.db == nil { - return nil + + var err error + if s.db != nil { + err = s.db.Close() + } + + if s.gcDone != nil { + <-s.gcDone } - err := s.db.Close() s.db = nil + s.gcCancel = nil + s.gcDone = nil return err } @@ -249,25 +262,19 @@ func (s *LightFileNodeStore) Add(_ context.Context, bs []blocks.Block) error { } func (s *LightFileNodeStore) Delete(_ context.Context, c cid.Cid) error { - // TODO: Create an issue that no Delete call after clean up of Bin in Anytype. - // Check before, that here is no deferred call to Delete - st := time.Now() - err := s.db.Update(func(txn *badger.Txn) error { - deleteErr := txn.Delete(blockKeyBytes(c)) - if errors.Is(deleteErr, badger.ErrKeyNotFound) { - return nil - } - return deleteErr - }) + if err := s.db.Update(func(txn *badger.Txn) error { + return txn.Delete(blockKeyBytes(c)) + }); err != nil { + return fmt.Errorf("failed to delete block: %w", err) + } log.Debug("badger delete", - zap.Error(err), zap.Duration("total", time.Since(st)), zap.String("cid", c.String()), ) - return err + return nil } func (s *LightFileNodeStore) DeleteMany(_ context.Context, toDelete []cid.Cid) error { @@ -275,26 +282,21 @@ func (s *LightFileNodeStore) DeleteMany(_ context.Context, toDelete []cid.Cid) e wb := s.db.NewWriteBatch() defer wb.Cancel() - // S3 implementation deletes sequentially and logs each failure. We keep the behavior but - // rely on Badger's batch API for efficiency and aggregate logging. for _, c := range toDelete { if err := wb.Delete(blockKeyBytes(c)); err != nil { - if errors.Is(err, badger.ErrKeyNotFound) { - continue - } - log.Warn("can't delete cid", zap.Error(err), zap.String("cid", c.String())) + return fmt.Errorf("failed to queue block deletion: %w", err) } } - err := wb.Flush() + if err := wb.Flush(); err != nil { + return fmt.Errorf("failed to delete blocks: %w", err) + } log.Debug("badger delete many", - zap.Error(err), zap.Duration("total", time.Since(st)), zap.Int("count", len(toDelete)), ) - // Original implementation never return an error return nil } diff --git a/lightcmp/lightfilenodestore/store_test.go b/lightcmp/lightfilenodestore/store_test.go index 4a0d7fa..74e101b 100644 --- a/lightcmp/lightfilenodestore/store_test.go +++ b/lightcmp/lightfilenodestore/store_test.go @@ -4,10 +4,6 @@ import ( "bytes" "context" "fmt" - "os" - "path/filepath" - "runtime" - "sync" "testing" "time" @@ -20,777 +16,287 @@ import ( "github.com/stretchr/testify/require" ) -func setupTestStore(t *testing.T) (*LightFileNodeStore, func()) { - tmpDir := t.TempDir() - store := New(tmpDir) - - // Initialize store - err := store.Init(&app.App{}) - require.NoError(t, err) - - // Run store - ctx := context.Background() - err = store.Run(ctx) - require.NoError(t, err) - - cleanup := func() { - closeErr := store.Close(context.Background()) - if closeErr != nil { - t.Logf("Failed to close store: %v", closeErr) - } - } +func setupTestStore(t *testing.T) *LightFileNodeStore { + t.Helper() - return store, cleanup + store := New(t.TempDir()) + require.NoError(t, store.Init(&app.App{})) + require.NoError(t, store.Run(t.Context())) + t.Cleanup(func() { + require.NoError(t, store.Close(context.Background())) + }) + return store } func createTestBlock(t *testing.T, data []byte) blocks.Block { - mh, err := multihash.Sum(data, multihash.SHA2_256, -1) - if err != nil { - t.Fatalf("failed to create multihash: %v", err) - } - - c := cid.NewCidV1(cid.Raw, mh) - block, err := blocks.NewBlockWithCid(data, c) - if err != nil { - t.Fatalf("failed to create block: %v", err) - } - - return block -} - -func createTestBlocks(t *testing.T, count int) []blocks.Block { - blocks := make([]blocks.Block, count) - for i := range count { - data := fmt.Appendf(nil, "test-block-%d", i) - blocks[i] = createTestBlock(t, data) - } - return blocks -} - -// Basic CRUD Tests - -func TestLightFileNodeStore_Init(t *testing.T) { - tmpDir := t.TempDir() - store := New(tmpDir) + t.Helper() - err := store.Init(&app.App{}) + hash, err := multihash.Sum(data, multihash.SHA2_256, -1) require.NoError(t, err) - assert.Equal(t, CName, store.Name()) -} - -func TestLightFileNodeStore_Run_UnwritablePath(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Skipping on Windows: directory permissions work differently") - } - - baseDir := t.TempDir() - require.NoError(t, os.Chmod(baseDir, 0o555)) - defer os.Chmod(baseDir, 0o755) - store := New(filepath.Join(baseDir, "store")) - require.NoError(t, store.Init(&app.App{})) - - err := store.Run(context.Background()) - assert.Error(t, err) + block, err := blocks.NewBlockWithCid(data, cid.NewCidV1(cid.Raw, hash)) + require.NoError(t, err) + return block } -func TestLightFileNodeStore_AddGetVariants(t *testing.T) { +func createTestBlocks(t *testing.T, prefix string, count int) []blocks.Block { t.Helper() - cases := []struct { - name string - makeBlocks func(*testing.T) []blocks.Block - }{ - { - name: "single", - makeBlocks: func(t *testing.T) []blocks.Block { - return []blocks.Block{createTestBlock(t, []byte("single-block"))} - }, - }, - { - name: "multiple", - makeBlocks: func(t *testing.T) []blocks.Block { - return createTestBlocks(t, 10) - }, - }, - { - name: "empty", - makeBlocks: func(t *testing.T) []blocks.Block { - return []blocks.Block{createTestBlock(t, []byte{})} - }, - }, - { - name: "large", - makeBlocks: func(t *testing.T) []blocks.Block { - data := make([]byte, 2*1024*1024) - for i := range data { - data[i] = byte(i % 256) - } - return []blocks.Block{createTestBlock(t, data)} - }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - blocks := tc.makeBlocks(t) - - require.NoError(t, store.Add(ctx, blocks)) - - for _, block := range blocks { - retrieved, err := store.Get(ctx, block.Cid()) - require.NoError(t, err) - require.True( - t, - bytes.Equal(block.RawData(), retrieved.RawData()), - "expected cid %s to match", - block.Cid(), - ) - } - }) + result := make([]blocks.Block, count) + for index := range count { + result[index] = createTestBlock( + t, + fmt.Appendf(nil, "%s-%d", prefix, index), + ) } + return result } -func TestLightFileNodeStore_GetMany(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - testBlocks := createTestBlocks(t, 20) - - // Add blocks - err := store.Add(ctx, testBlocks) - require.NoError(t, err) - - // Prepare CIDs - cids := make([]cid.Cid, len(testBlocks)) - for i, block := range testBlocks { - cids[i] = block.Cid() - } - - // Get many blocks - resultChan := store.GetMany(ctx, cids) +func TestLightFileNodeStoreBlockRoundTrip(t *testing.T) { + store := setupTestStore(t) + ctx := t.Context() + expected := createTestBlocks(t, "round-trip", 3) - // Collect results - results := make(map[string]blocks.Block) - for block := range resultChan { - results[block.Cid().String()] = block - } + require.NoError(t, store.Add(ctx, expected)) - // Verify all blocks retrieved - assert.Len(t, results, len(testBlocks)) - for _, original := range testBlocks { - retrieved, ok := results[original.Cid().String()] - require.True(t, ok) - assert.Equal(t, original.RawData(), retrieved.RawData()) + for _, block := range expected { + actual, err := store.Get(ctx, block.Cid()) + require.NoError(t, err) + assert.Equal(t, block.RawData(), actual.RawData()) } } -func TestLightFileNodeStore_Delete(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - block := createTestBlock(t, []byte("to be deleted")) +// A successful write must survive a hard reboot, matching the durable +// acknowledgement provided by the upstream S3 backend. +func TestLightFileNodeStoreDurableWrites(t *testing.T) { + store := setupTestStore(t) - // Add block - err := store.Add(ctx, []blocks.Block{block}) - require.NoError(t, err) + assert.True(t, store.db.Opts().SyncWrites) +} - // Verify it exists - _, err = store.Get(ctx, block.Cid()) - require.NoError(t, err) +func TestLightFileNodeStoreMissingBlock(t *testing.T) { + store := setupTestStore(t) + missing := createTestBlock(t, []byte("missing")) - // Delete block - err = store.Delete(ctx, block.Cid()) - require.NoError(t, err) + _, err := store.Get(t.Context(), missing.Cid()) - // Verify it's gone - _, err = store.Get(ctx, block.Cid()) assert.ErrorIs(t, err, fileblockstore.ErrCIDNotFound) } -func TestLightFileNodeStore_DeleteMany(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - testBlocks := createTestBlocks(t, 5) +// GetMany owns two semantics not supplied by Badger: it streams blocks over a +// channel and omits missing CIDs without failing the remaining request. +func TestLightFileNodeStoreGetMany(t *testing.T) { + store := setupTestStore(t) + ctx := t.Context() + existing := createTestBlocks(t, "existing", 3) + missing := createTestBlocks(t, "missing", 2) + require.NoError(t, store.Add(ctx, existing)) - // Add blocks - err := store.Add(ctx, testBlocks) - require.NoError(t, err) - - // Delete all blocks - cids := make([]cid.Cid, len(testBlocks)) - for i, block := range testBlocks { - cids[i] = block.Cid() + requested := make([]cid.Cid, 0, len(existing)+len(missing)) + for _, block := range existing { + requested = append(requested, block.Cid()) } - err = store.DeleteMany(ctx, cids) - require.NoError(t, err) - - // Verify all are gone - for _, c := range cids { - _, err = store.Get(ctx, c) - assert.ErrorIs(t, err, fileblockstore.ErrCIDNotFound) + for _, block := range missing { + requested = append(requested, block.Cid()) } -} - -func TestLightFileNodeStore_Delete_Idempotent(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - block := createTestBlock(t, []byte("idempotent-delete")) - require.NoError(t, store.Add(ctx, []blocks.Block{block})) - // First delete removes the block. - require.NoError(t, store.Delete(ctx, block.Cid())) - - // Subsequent deletes should be no-ops. - require.NoError(t, store.Delete(ctx, block.Cid())) - - // Deleting a CID that never existed should also succeed. - missing := createTestBlock(t, []byte("missing-delete")) - require.NoError(t, store.Delete(ctx, missing.Cid())) -} - -func TestLightFileNodeStore_DeleteMany_MissingEntries(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - blocks := createTestBlocks(t, 3) - require.NoError(t, store.Add(ctx, blocks)) + actual := make(map[string]blocks.Block) + for block := range store.GetMany(ctx, requested) { + actual[block.Cid().String()] = block + } - missing := createTestBlocks(t, 2) - var toDelete []cid.Cid - for _, block := range blocks { - toDelete = append(toDelete, block.Cid()) + assert.Len(t, actual, len(existing)) + for _, block := range existing { + delivered, ok := actual[block.Cid().String()] + require.True(t, ok, "missing expected CID %s", block.Cid()) + assert.Equal(t, block.RawData(), delivered.RawData()) } for _, block := range missing { - toDelete = append(toDelete, block.Cid()) - } - - require.NoError(t, store.DeleteMany(ctx, toDelete)) - - for _, block := range blocks { - _, err := store.Get(ctx, block.Cid()) - assert.ErrorIs(t, err, fileblockstore.ErrCIDNotFound) + assert.NotContains(t, actual, block.Cid().String()) } } -// Error Handling Tests - -func TestLightFileNodeStore_Get_NonExistent(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - block := createTestBlock(t, []byte("non-existent")) - - _, err := store.Get(ctx, block.Cid()) - assert.ErrorIs(t, err, fileblockstore.ErrCIDNotFound) -} - -func TestLightFileNodeStore_GetMany_WithMissingBlocks(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - existingBlocks := createTestBlocks(t, 3) - missingBlocks := createTestBlocks(t, 2) +func TestLightFileNodeStoreGetManyStopsOnCancellation(t *testing.T) { + store := setupTestStore(t) + ctx, cancel := context.WithCancel(t.Context()) + expected := createTestBlocks(t, "cancelled", 3) + require.NoError(t, store.Add(ctx, expected)) - // Add only existing blocks - err := store.Add(ctx, existingBlocks) - require.NoError(t, err) - - // Request both existing and missing - allCids := make([]cid.Cid, 0, 5) - for _, block := range existingBlocks { - allCids = append(allCids, block.Cid()) - } - for _, block := range missingBlocks { - allCids = append(allCids, block.Cid()) + requested := make([]cid.Cid, len(expected)) + for index, block := range expected { + requested[index] = block.Cid() } + cancel() - // Get many should return only existing blocks - resultChan := store.GetMany(ctx, allCids) - resultsMap := make(map[string]bool) - for block := range resultChan { - resultsMap[block.Cid().String()] = true + delivered := 0 + for range store.GetMany(ctx, requested) { + delivered++ } - - assert.Len(t, resultsMap, 3) + assert.Zero(t, delivered) } -// Index Operations Tests +func TestLightFileNodeStoreDelete(t *testing.T) { + t.Run("single block", func(t *testing.T) { + store := setupTestStore(t) + ctx := t.Context() + block := createTestBlock(t, []byte("single-delete")) + require.NoError(t, store.Add(ctx, []blocks.Block{block})) -func TestLightFileNodeStore_IndexPut_Get(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() + require.NoError(t, store.Delete(ctx, block.Cid())) - ctx := context.Background() - key := "test-index-key" - value := []byte("test-index-value") + _, err := store.Get(ctx, block.Cid()) + assert.ErrorIs(t, err, fileblockstore.ErrCIDNotFound) + }) - // Put index value - err := store.IndexPut(ctx, key, value) - require.NoError(t, err) + t.Run("block batch", func(t *testing.T) { + store := setupTestStore(t) + ctx := t.Context() + batch := createTestBlocks(t, "batch-delete", 3) + require.NoError(t, store.Add(ctx, batch)) - // Get index value - retrieved, err := store.IndexGet(ctx, key) - require.NoError(t, err) - assert.Equal(t, value, retrieved) -} + cids := make([]cid.Cid, len(batch)) + for index, block := range batch { + cids[index] = block.Cid() + } + require.NoError(t, store.DeleteMany(ctx, cids)) -func TestLightFileNodeStore_IndexGet_NonExistent(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() + for _, blockCID := range cids { + _, err := store.Get(ctx, blockCID) + assert.ErrorIs(t, err, fileblockstore.ErrCIDNotFound) + } + }) + + // The filenode removes index metadata only after DeleteMany succeeds, so a + // storage failure must remain visible to its caller. + t.Run("batch failure is returned", func(t *testing.T) { + store := setupTestStore(t) + oversizedHash, err := multihash.Encode( + make([]byte, 41_000), + multihash.IDENTITY, + ) + require.NoError(t, err) + oversizedCID := cid.NewCidV1(cid.Raw, oversizedHash) - ctx := context.Background() - key := "non-existent-key" + err = store.DeleteMany(t.Context(), []cid.Cid{oversizedCID}) - // Get non-existent index value - value, err := store.IndexGet(ctx, key) - require.NoError(t, err) - assert.Nil(t, value) + assert.ErrorContains(t, err, "failed to queue block deletion") + }) } -func TestLightFileNodeStore_IndexDelete(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - key := "delete-me" - value := []byte("to be deleted") +func TestLightFileNodeStoreIndexLifecycle(t *testing.T) { + store := setupTestStore(t) + ctx := t.Context() + const key = "index-key" + value := []byte("index-value") - // Put index value - err := store.IndexPut(ctx, key, value) + actual, err := store.IndexGet(ctx, key) require.NoError(t, err) + assert.Nil(t, actual) - // Verify it exists - retrieved, err := store.IndexGet(ctx, key) + require.NoError(t, store.IndexPut(ctx, key, value)) + actual, err = store.IndexGet(ctx, key) require.NoError(t, err) - assert.Equal(t, value, retrieved) - - // Delete index value - err = store.IndexDelete(ctx, key) - require.NoError(t, err) - - // Verify it's gone - retrieved, err = store.IndexGet(ctx, key) - require.NoError(t, err) - assert.Nil(t, retrieved) -} - -func TestLightFileNodeStore_IndexDelete_NonExistent(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() + assert.Equal(t, value, actual) - err := store.IndexDelete(ctx, "missing-index") + require.NoError(t, store.IndexDelete(ctx, key)) + actual, err = store.IndexGet(ctx, key) require.NoError(t, err) + assert.Nil(t, actual) } -func TestLightFileNodeStore_IndexKeyPrefix_Isolation(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - - // Create a block with a CID that could collide with index prefix - blockData := []byte("block data") - block := createTestBlock(t, blockData) - - // Add block - err := store.Add(ctx, []blocks.Block{block}) - require.NoError(t, err) - - // Create an index with a key that's the same as the block CID +func TestLightFileNodeStoreIndexKeyPrefixIsolation(t *testing.T) { + store := setupTestStore(t) + ctx := t.Context() + block := createTestBlock(t, []byte("block-value")) indexKey := block.Cid().String() - indexValue := []byte("index value") - - // Put index value - err = store.IndexPut(ctx, indexKey, indexValue) - require.NoError(t, err) - - // Both should coexist without collision - retrievedBlock, err := store.Get(ctx, block.Cid()) - require.NoError(t, err) - assert.Equal(t, blockData, retrievedBlock.RawData()) - - retrievedIndex, err := store.IndexGet(ctx, indexKey) - require.NoError(t, err) - assert.Equal(t, indexValue, retrievedIndex) -} - -// Concurrent Operations Tests + indexValue := []byte("index-value") -func TestLightFileNodeStore_ConcurrentWrites(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - numGoroutines := 10 - blocksPerGoroutine := 5 - - blockSets := make([][]blocks.Block, numGoroutines) - for i := range blockSets { - blockSets[i] = make([]blocks.Block, blocksPerGoroutine) - for j := range blockSets[i] { - data := fmt.Appendf(nil, "goroutine-%d-block-%d", i, j) - blockSets[i][j] = createTestBlock(t, data) - } - } - - var wg sync.WaitGroup - wg.Add(numGoroutines) - - cidCh := make(chan cid.Cid, numGoroutines*blocksPerGoroutine) - errCh := make(chan error, numGoroutines) - - for i := range numGoroutines { - go func(batch []blocks.Block) { - defer wg.Done() - for _, block := range batch { - if err := store.Add(ctx, []blocks.Block{block}); err != nil { - errCh <- err - return - } - cidCh <- block.Cid() - } - }(blockSets[i]) - } - - wg.Wait() - close(errCh) - for err := range errCh { - require.NoError(t, err) - } - - close(cidCh) - retrieved := 0 - for c := range cidCh { - _, err := store.Get(ctx, c) - require.NoError(t, err) - retrieved++ - } - - assert.Equal(t, numGoroutines*blocksPerGoroutine, retrieved) -} - -func TestLightFileNodeStore_ConcurrentReads(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - testBlocks := createTestBlocks(t, 10) + require.NoError(t, store.Add(ctx, []blocks.Block{block})) + require.NoError(t, store.IndexPut(ctx, indexKey, indexValue)) - // Add blocks - err := store.Add(ctx, testBlocks) + actualBlock, err := store.Get(ctx, block.Cid()) require.NoError(t, err) + assert.Equal(t, block.RawData(), actualBlock.RawData()) - numGoroutines := 20 - readsPerGoroutine := 10 - - var wg sync.WaitGroup - wg.Add(numGoroutines) - start := make(chan struct{}) - errCh := make(chan error, numGoroutines) - - for i := range numGoroutines { - go func(id int) { - defer wg.Done() - <-start - for j := range readsPerGoroutine { - block := testBlocks[(id+j)%len(testBlocks)] - retrieved, getErr := store.Get(ctx, block.Cid()) - if getErr != nil { - errCh <- getErr - return - } - if !assert.ObjectsAreEqual(block.RawData(), retrieved.RawData()) { - errCh <- fmt.Errorf("data mismatch for %s", block.Cid()) - return - } - } - }(i) - } - - close(start) - wg.Wait() - close(errCh) - for err := range errCh { - require.NoError(t, err) - } -} - -func TestLightFileNodeStore_ConcurrentMixedOperations(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - testBlocks := createTestBlocks(t, 20) - - require.NoError(t, store.Add(ctx, testBlocks[:10])) - - cases := []concurrentCase{ - readerConcurrentCase(ctx, store, testBlocks[:10]), - writerConcurrentCase(ctx, store, testBlocks[10:]), - getManyConcurrentCase(ctx, store, testBlocks[:10], 5), - } - - if err := runConcurrentCases(cases); err != nil { - t.Fatalf("concurrent operations failed: %v", err) - } - - require.NoError(t, verifyBlocks(ctx, store, testBlocks)) -} - -type concurrentCase struct { - name string - start func(<-chan struct{}) error -} - -func runConcurrentCases(cases []concurrentCase) error { - var wg sync.WaitGroup - wg.Add(len(cases)) - - start := make(chan struct{}) - errCh := make(chan error, len(cases)) - - for _, cs := range cases { - go func(cs concurrentCase) { - defer wg.Done() - errCh <- cs.start(start) - }(cs) - } - - close(start) - wg.Wait() - close(errCh) - - for err := range errCh { - if err != nil { - return err - } - } - - return nil -} - -func readerConcurrentCase(ctx context.Context, store *LightFileNodeStore, expected []blocks.Block) concurrentCase { - blocksCopy := append([]blocks.Block(nil), expected...) - return concurrentCase{ - name: "read", - start: func(ready <-chan struct{}) error { - <-ready - return verifyBlocks(ctx, store, blocksCopy) - }, - } -} - -func writerConcurrentCase(ctx context.Context, store *LightFileNodeStore, toAdd []blocks.Block) concurrentCase { - blocksCopy := append([]blocks.Block(nil), toAdd...) - return concurrentCase{ - name: "write", - start: func(ready <-chan struct{}) error { - <-ready - return addBlocks(ctx, store, blocksCopy) - }, - } -} - -func getManyConcurrentCase( - ctx context.Context, - store *LightFileNodeStore, - source []blocks.Block, - iterations int, -) concurrentCase { - cids := make([]cid.Cid, len(source)) - for i, block := range source { - cids[i] = block.Cid() - } - - return concurrentCase{ - name: "getMany", - start: func(ready <-chan struct{}) error { - <-ready - return consumeGetMany(ctx, store, cids, iterations) - }, - } -} - -func verifyBlocks(ctx context.Context, store *LightFileNodeStore, expected []blocks.Block) error { - for _, block := range expected { - got, err := store.Get(ctx, block.Cid()) - if err != nil { - return err - } - if !bytes.Equal(block.RawData(), got.RawData()) { - return fmt.Errorf("data mismatch for %s", block.Cid()) - } - } - return nil -} - -func addBlocks(ctx context.Context, store *LightFileNodeStore, toAdd []blocks.Block) error { - for _, block := range toAdd { - if err := store.Add(ctx, []blocks.Block{block}); err != nil { - return err - } - } - return nil -} - -func consumeGetMany(ctx context.Context, store *LightFileNodeStore, cids []cid.Cid, iterations int) error { - var delivered int32 - for range iterations { - resCh := store.GetMany(ctx, cids) - for range resCh { - delivered++ - } - } - - if delivered < int32(len(cids)) { - return fmt.Errorf("expected >= %d results, got %d", len(cids), delivered) - } - return nil -} - -// Edge Cases Tests - -func TestLightFileNodeStore_DuplicateAdd(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - block := createTestBlock(t, []byte("duplicate")) - - // Add block multiple times - for range 3 { - err := store.Add(ctx, []blocks.Block{block}) - require.NoError(t, err) - } - - // Should still be able to retrieve - retrieved, err := store.Get(ctx, block.Cid()) + actualIndex, err := store.IndexGet(ctx, indexKey) require.NoError(t, err) - assert.Equal(t, block.RawData(), retrieved.RawData()) + assert.Equal(t, indexValue, actualIndex) } -// Integration Tests - -func TestLightFileNodeStore_Persistence(t *testing.T) { - tmpDir := t.TempDir() - ctx := context.Background() +// Reopening the same directory verifies the adapter's durability boundary, +// including both independently prefixed data classes. +func TestLightFileNodeStorePersistence(t *testing.T) { + dir := t.TempDir() + ctx := t.Context() + block := createTestBlock(t, []byte("persistent-block")) + const indexKey = "persistent-index" + indexValue := []byte("persistent-value") - // First store instance - store1 := New(tmpDir) - err := store1.Init(&app.App{}) - require.NoError(t, err) - err = store1.Run(ctx) - require.NoError(t, err) + first := New(dir) + require.NoError(t, first.Init(&app.App{})) + require.NoError(t, first.Run(ctx)) + require.NoError(t, first.Add(ctx, []blocks.Block{block})) + require.NoError(t, first.IndexPut(ctx, indexKey, indexValue)) + require.NoError(t, first.Close(ctx)) - // Add data - block := createTestBlock(t, []byte("persistent data")) - err = store1.Add(ctx, []blocks.Block{block}) - require.NoError(t, err) - - // Add index data - err = store1.IndexPut(ctx, "persistent-key", []byte("persistent-value")) - require.NoError(t, err) + second := New(dir) + require.NoError(t, second.Init(&app.App{})) + require.NoError(t, second.Run(ctx)) + t.Cleanup(func() { + require.NoError(t, second.Close(context.Background())) + }) - // Close store - err = store1.Close(ctx) + actualBlock, err := second.Get(ctx, block.Cid()) require.NoError(t, err) + assert.Equal(t, block.RawData(), actualBlock.RawData()) - // Second store instance with same path - store2 := New(tmpDir) - err = store2.Init(&app.App{}) + actualIndex, err := second.IndexGet(ctx, indexKey) require.NoError(t, err) - err = store2.Run(ctx) - require.NoError(t, err) - defer store2.Close(ctx) - - // Verify block persisted - retrieved, err := store2.Get(ctx, block.Cid()) - require.NoError(t, err) - assert.Equal(t, block.RawData(), retrieved.RawData()) - - // Verify index persisted - indexValue, err := store2.IndexGet(ctx, "persistent-key") - require.NoError(t, err) - assert.Equal(t, []byte("persistent-value"), indexValue) + assert.Equal(t, indexValue, actualIndex) } -func TestLightFileNodeStore_ContextCancellation(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - // Create a cancellable context - ctx, cancel := context.WithCancel(context.Background()) - - testBlocks := createTestBlocks(t, 100) - err := store.Add(ctx, testBlocks) - require.NoError(t, err) - - // Prepare CIDs for GetMany - cids := make([]cid.Cid, len(testBlocks)) - for i, block := range testBlocks { - cids[i] = block.Cid() - } - - // Cancel context immediately - cancel() - - // GetMany should handle cancellation gracefully - resultChan := store.GetMany(ctx, cids) - - count := 0 - for range resultChan { - count++ - } - - // Should get fewer results due to cancellation - assert.Less(t, count, len(testBlocks)) -} - -func TestLightFileNodeStore_GarbageCollection(t *testing.T) { - tmpDir := t.TempDir() - store := New(tmpDir) - // Use a very long interval so background GC doesn't interfere with manual gcOnce() call +// GC is dependency-owned, but invoking our bounded loop once protects against +// adapter configuration that could corrupt or close the live store. +func TestLightFileNodeStoreGarbageCollection(t *testing.T) { + store := New(t.TempDir()) store.cfg.gcInterval = 24 * time.Hour store.cfg.maxGCDuration = 100 * time.Millisecond - + ctx := t.Context() require.NoError(t, store.Init(&app.App{})) - - ctx := context.Background() require.NoError(t, store.Run(ctx)) - defer store.Close(context.Background()) + t.Cleanup(func() { + require.NoError(t, store.Close(context.Background())) + }) - // Add and delete blocks to create garbage - for i := range 10 { - block := createTestBlock(t, fmt.Appendf(nil, "gc-test-%d", i)) + for index := range 10 { + block := createTestBlock(t, fmt.Appendf(nil, "garbage-%d", index)) require.NoError(t, store.Add(ctx, []blocks.Block{block})) require.NoError(t, store.Delete(ctx, block.Cid())) } - // Manually trigger GC (background GC won't run due to long interval) _, _, err := store.gcOnce() require.NoError(t, err) - // Store remains functional after GC iteration. - testBlock := createTestBlock(t, []byte("after-gc")) - require.NoError(t, store.Add(ctx, []blocks.Block{testBlock})) - - retrieved, err := store.Get(ctx, testBlock.Cid()) + expected := createTestBlock(t, []byte("after-gc")) + require.NoError(t, store.Add(ctx, []blocks.Block{expected})) + actual, err := store.Get(ctx, expected.Cid()) require.NoError(t, err) - assert.Equal(t, testBlock.RawData(), retrieved.RawData()) + assert.True(t, bytes.Equal(expected.RawData(), actual.RawData())) +} + +// Close owns the GC goroutine as well as the database. Returning before that +// goroutine exits would allow it to access the database after its lifetime. +func TestLightFileNodeStoreCloseJoinsGarbageCollection(t *testing.T) { + store := New(t.TempDir()) + require.NoError(t, store.Init(&app.App{})) + require.NoError(t, store.Run(t.Context())) + gcDone := store.gcDone + + require.NoError(t, store.Close(t.Context())) + + select { + case <-gcDone: + default: + t.Fatal("Close returned before garbage collection stopped") + } } diff --git a/lightnode/anynodes.go b/lightnode/anynodes.go index 748ec90..1f90d27 100644 --- a/lightnode/anynodes.go +++ b/lightnode/anynodes.go @@ -17,6 +17,7 @@ import ( "github.com/anyproto/any-sync-coordinator/deletionlog" "github.com/anyproto/any-sync-coordinator/identityrepo" "github.com/anyproto/any-sync-coordinator/inbox" + "github.com/anyproto/any-sync-coordinator/invitestore" coordinatorNodeconfsource "github.com/anyproto/any-sync-coordinator/nodeconfsource" "github.com/anyproto/any-sync-coordinator/spacestatus" "github.com/anyproto/any-sync-coordinator/subscribe" @@ -27,6 +28,7 @@ import ( "github.com/anyproto/any-sync-filenode/index" "github.com/anyproto/any-sync-filenode/redisprovider" filenodeStat "github.com/anyproto/any-sync-filenode/stat" + "github.com/anyproto/any-sync-filenode/store/s3store" "github.com/anyproto/any-sync/acl" "github.com/anyproto/any-sync/app" @@ -65,7 +67,6 @@ import ( "github.com/anyproto/any-sync-node/nodesync/coldsync" "github.com/anyproto/any-sync-node/nodesync/hotsync" - "github.com/anyproto/any-sync-filenode/store/s3store" "github.com/anyproto/any-sync/app/logger" "go.uber.org/zap" @@ -91,14 +92,10 @@ func newCoordinatorApp(cfg *coordinatorConfig.Config) *app.App { // Data Register(deletionlog.New()). - // Security & Transport - Register(secureservice.New()). - Register(yamux.New()). - Register(quic.New()). - // Network Services Register(peerservice.New()). Register(pool.New()). + Register(secureservice.New()). Register(server.New()). // Logging & Monitoring @@ -113,7 +110,12 @@ func newCoordinatorApp(cfg *coordinatorConfig.Config) *app.App { Register(inbox.New()). Register(accountlimit.New()). Register(identityrepo.New()). - Register(coordinator.New()) + Register(invitestore.New()). + Register(coordinator.New()). + + // Start listeners only after every request dependency is running. + Register(yamux.New()). + Register(quic.New()) return a } @@ -176,7 +178,7 @@ func newSyncApp(cfg *config.Config, net *sharedCmp) *app.App { } // selectFileStore returns S3 or BadgerDB storage based on configuration. -func selectFileStore(cfg *filenodeConfig.Config, fileDir string) app.Component { +func selectFileStore(cfg *filenodeConfig.Config, fileDir string) s3store.S3Store { if cfg.S3Store.Bucket != "" { log.Info("using S3 storage backend", zap.String("event", "filenode_storage_backend_s3"), diff --git a/lightnode/anynodes_test.go b/lightnode/anynodes_test.go index 36aa0d6..c84f833 100644 --- a/lightnode/anynodes_test.go +++ b/lightnode/anynodes_test.go @@ -4,11 +4,42 @@ import ( "reflect" "testing" + coordinatorConfig "github.com/anyproto/any-sync-coordinator/config" + "github.com/anyproto/any-sync-coordinator/coordinator" + "github.com/anyproto/any-sync-coordinator/invitestore" filenodeConfig "github.com/anyproto/any-sync-filenode/config" "github.com/anyproto/any-sync-filenode/store/s3store" + "github.com/anyproto/any-sync/net/transport/quic" + "github.com/anyproto/any-sync/net/transport/yamux" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +var _ func(*filenodeConfig.Config, string) s3store.S3Store = selectFileStore + +// The upstream coordinator owns its component list. This focused check keeps +// the wrapper aligned when a new required component is added there. +func TestNewCoordinatorAppRegistersInviteStore(t *testing.T) { + coordinatorApp := newCoordinatorApp(&coordinatorConfig.Config{}) + + assert.NotNil(t, coordinatorApp.Component(invitestore.CName)) +} + +// Transports open the shared listeners. Starting them last prevents requests +// from reaching coordinator components that have not run yet, and reversed +// shutdown closes the listeners before their dependencies. +func TestNewCoordinatorAppStartsTransportsLast(t *testing.T) { + coordinatorApp := newCoordinatorApp(&coordinatorConfig.Config{}) + names := coordinatorApp.ComponentNames() + require.GreaterOrEqual(t, len(names), 3) + + assert.Equal(t, []string{ + coordinator.CName, + yamux.CName, + quic.CName, + }, names[len(names)-3:]) +} + func TestSelectFileStore_S3(t *testing.T) { cfg := &filenodeConfig.Config{ S3Store: s3store.Config{ @@ -38,15 +69,3 @@ func TestSelectFileStore_BadgerDB(t *testing.T) { typeName := reflect.TypeOf(store).String() assert.Contains(t, typeName, "LightFileNodeStore", "should return BadgerDB store when bucket is empty") } - -func TestSelectFileStore_EmptyS3Config(t *testing.T) { - cfg := &filenodeConfig.Config{ - // Default zero value for S3Store - } - - store := selectFileStore(cfg, "/tmp/filestore") - - // Check type name - typeName := reflect.TypeOf(store).String() - assert.Contains(t, typeName, "LightFileNodeStore", "should return BadgerDB store for empty S3 config") -} diff --git a/main.go b/main.go index cdf2a71..251cc9e 100644 --- a/main.go +++ b/main.go @@ -12,26 +12,26 @@ import ( ) func main() { - // terminationSignals are signals that cause the program to exit in the supported platforms. - // List from kubectl project. - terminationSignals := []os.Signal{syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT} + ctx, cancelRoot := signal.NotifyContext( + context.Background(), + // Match Kubernetes' cross-platform termination signal set. + syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, + ) + defer cancelRoot() - ctx, cancel := signal.NotifyContext(context.Background(), terminationSignals...) - defer cancel() - - cliRoot := cmd.Root(ctx) + cliRoot := cmd.Root(ctx, cancelRoot) go func() { <-ctx.Done() - time.Sleep(30 * time.Second) + time.Sleep(cmd.ShutdownTimeout) fmt.Println("\nForced exit by timeout") os.Exit(1) }() if err := cliRoot.Run(os.Args); err != nil { + cancelRoot() fmt.Println("\nError:") fmt.Printf(" > %+v\n", err) - cancel() os.Exit(1) //nolint:gocritic // need to exit with error code } }