From b499570c7c1fa74c1dccb8d8e8e8020bf93e8b62 Mon Sep 17 00:00:00 2001 From: "Sergei G." Date: Mon, 20 Jul 2026 15:04:01 +0400 Subject: [PATCH 1/7] config: validate format 1 configuration boundaries Validate MongoDB URIs with the driver without rewriting operator-owned values. Derive only the consensus URI owned by the bundle. Cover the historical local and S3 shapes that share bundle format 1, including compatibility defaults for region and storage limits. --- config/bundle.go | 43 +- config/bundle_test.go | 692 ++++++++++------------------- config/convert_test.go | 221 +++------ config/testdata/bundle-v1.0.yml | 22 + config/testdata/bundle-v1.2-s3.yml | 26 ++ config/testdata/bundle-v1.3-s3.yml | 27 ++ 6 files changed, 412 insertions(+), 619 deletions(-) create mode 100644 config/testdata/bundle-v1.0.yml create mode 100644 config/testdata/bundle-v1.2-s3.yml create mode 100644 config/testdata/bundle-v1.3-s3.yml diff --git a/config/bundle.go b/config/bundle.go index 65de033..22feebe 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,7 @@ import ( "github.com/anyproto/any-sync/app/logger" "github.com/anyproto/any-sync/util/crypto" + "go.mongodb.org/mongo-driver/mongo/options" "go.uber.org/zap" "gopkg.in/mgo.v2/bson" "gopkg.in/yaml.v3" @@ -30,6 +32,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,12 +149,10 @@ 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, @@ -199,6 +204,13 @@ 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 validateURI(field string, raw string, allowedSchemes ...string) error { value := strings.TrimSpace(raw) if value == "" { @@ -218,10 +230,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 +261,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 +323,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 +332,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 +349,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..21241f4 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) +// 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 load successfully - cfg := Load(cfgPath) - assert.NotNil(t, cfg) - assert.Equal(t, 1, cfg.BundleFormat) - assert.Equal(t, "0.13.0", cfg.BundleVersion) -} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := validTestConfig() + cfg.BundleFormat = test.format -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) + 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 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) + if test.shouldPanic { + assert.Panics(t, func() { + Load(cfgPath) + }) + return + } - // Should panic with "config format too old" - assert.Panics(t, func() { - Load(cfgPath) - }) + loaded := Load(cfgPath) + assert.Equal(t, CurrentBundleFormat, loaded.BundleFormat) + }) + } } -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) - - // Should panic with "config format too new" - assert.Panics(t, func() { - Load(cfgPath) +// 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) }) -} -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) + t.Run("v1.2 S3 storage", func(t *testing.T) { + cfg := Load("testdata/bundle-v1.2-s3.yml") - // Should panic with "config format too old" - assert.Panics(t, func() { - Load(cfgPath) - }) -} - -func TestCreateWrite_SetsBundleFormat(t *testing.T) { - // Verify that CreateWrite sets bundleFormat to CurrentBundleFormat - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "created-config.yml") + 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) - opts := &CreateOptions{ - CfgPath: cfgPath, - StorePath: filepath.Join(tmpDir, "storage"), - MongoURI: "mongodb://localhost:27017/", - RedisURI: "redis://localhost:6379/", - ExternalAddrs: []string{"192.168.1.100"}, - } - - cfg := CreateWrite(opts) - assert.Equal(t, CurrentBundleFormat, cfg.BundleFormat) + filenodeCfg := cfg.NodeConfigs().Filenode + assert.Equal(t, uint64(oneTiB), filenodeCfg.DefaultLimit) + assert.Equal(t, "us-east-1", filenodeCfg.S3Store.Region) + }) - // Verify the written file can be loaded back - loadedCfg := Load(cfgPath) - assert.Equal(t, CurrentBundleFormat, loadedCfg.BundleFormat) -} + t.Run("v1.3 explicit storage settings", func(t *testing.T) { + const tenGiB = 10 * 1024 * 1024 * 1024 -// S3 Configuration Tests + cfg := Load("testdata/bundle-v1.3-s3.yml") -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") + 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) - 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) -} - -func TestValidateS3Config_WithForcePathStyle(t *testing.T) { - t.Setenv("AWS_ACCESS_KEY_ID", "test-key") - t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") - - cfg, err := validateS3Config("my-bucket", "http://minio:9000", "", true) - require.NoError(t, err) - assert.True(t, cfg.ForcePathStyle) + filenodeCfg := cfg.NodeConfigs().Filenode + assert.Equal(t, uint64(tenGiB), filenodeCfg.DefaultLimit) + assert.Equal(t, "eu-central-1", filenodeCfg.S3Store.Region) + }) } -func TestValidateS3Config_WithRegion(t *testing.T) { - t.Setenv("AWS_ACCESS_KEY_ID", "test-key") - t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") +// 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" - cfg, err := validateS3Config("my-bucket", "http://minio:9000", "sz-hq", true) + data, err := yaml.Marshal(cfg) require.NoError(t, err) - assert.Equal(t, "sz-hq", cfg.Region) -} -func TestValidateS3Config_MissingBucket(t *testing.T) { - cfg, err := validateS3Config("", "https://s3.amazonaws.com", "", false) - assert.Nil(t, cfg) - assert.ErrorIs(t, err, ErrS3BucketRequired) -} + cfgPath := filepath.Join(t.TempDir(), "bundle.yml") + require.NoError(t, os.WriteFile(cfgPath, data, 0o600)) -func TestValidateS3Config_MissingEndpoint(t *testing.T) { - cfg, err := validateS3Config("my-bucket", "", "", false) - assert.Nil(t, cfg) - assert.ErrorIs(t, err, ErrS3EndpointRequired) -} - -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) + assert.Panics(t, func() { + Load(cfgPath) + }) } -func TestValidateS3Config_MissingCredentials(t *testing.T) { - // Ensure credentials are not set - t.Setenv("AWS_ACCESS_KEY_ID", "") - t.Setenv("AWS_SECRET_ACCESS_KEY", "") - - // Should still succeed but with a warning (tested via logs) - cfg, err := validateS3Config("my-bucket", "https://s3.amazonaws.com", "", false) - require.NoError(t, err) - assert.NotNil(t, cfg) -} +// 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) -func TestValidateS3Config_PartialCredentials(t *testing.T) { - // Only access key set - t.Setenv("AWS_ACCESS_KEY_ID", "test-key") - t.Setenv("AWS_SECRET_ACCESS_KEY", "") + created := CreateWrite(options) + loaded := Load(options.CfgPath) - // 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.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_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, - } +// 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" - 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) + cfg := CreateWrite(options) - // 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, options.MongoURI, cfg.Coordinator.MongoConnect) + assert.Equal(t, "mongodb://localhost:27017/?w=majority", cfg.Consensus.MongoConnect) } -func TestCreateWrite_WithoutS3Config(t *testing.T) { - tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, "no-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"}, - // No S3 options - } - - cfg := CreateWrite(opts) - assert.Nil(t, cfg.FileNode.S3, "S3 config should be nil when not configured") -} - -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") +func TestCreateWriteExplicitFilenodeLimit(t *testing.T) { + const tenGiB = 10 * 1024 * 1024 * 1024 - const oneTiB = 1024 * 1024 * 1024 * 1024 // 1 TiB + options := validCreateOptions(t) + options.FilenodeDefaultLimit = tenGiB - 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) - } - - cfg := CreateWrite(opts) - assert.Equal(t, uint64(oneTiB), cfg.FileNode.DefaultLimit, - "DefaultLimit should be 1 TiB when not configured (written to config file)") -} - -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,28 @@ func TestConfigValidate(t *testing.T) { wantErr: "network.listenTCPAddr must be in host:port format", }, { - name: "invalid redis uri", + 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: "invalid S3 endpoint", mutate: func(cfg *Config) { cfg.FileNode.S3 = &S3Config{ Bucket: "bucket", @@ -552,29 +323,42 @@ func TestConfigValidate(t *testing.T) { }, } - 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 From e0053e2cac2d7f93d5bf8ccbb6d80e58c7fb405b Mon Sep 17 00:00:00 2001 From: "Sergei G." Date: Mon, 20 Jul 2026 15:04:48 +0400 Subject: [PATCH 2/7] bundle: own service and infrastructure lifecycle Use one root cancellation path for startup, service failure, and embedded process exits. Shut services down in reverse order, signal MongoDB and Redis together, and always reap owned children. Keep the application watchdog and Compose grace periods under one shutdown policy while preserving startup and cleanup failures. --- cmd/infra.go | 492 +++++++++++++++++++++++ cmd/infra_process_test.go | 342 ++++++++++++++++ cmd/infra_test.go | 186 +++++++++ cmd/mongo.go | 68 ++-- cmd/mongo_test.go | 57 +++ cmd/root.go | 8 +- cmd/services.go | 267 +++++++++++++ cmd/services_test.go | 398 +++++++++++++++++++ cmd/start.go | 726 ++++++---------------------------- cmd/start_integration_test.go | 138 ------- cmd/start_test.go | 278 ++----------- compose.aio.yml | 1 + compose.external.yml | 1 + compose.s3.yml | 1 + compose.traefik.yml | 1 + integration/bundle.go | 5 +- main.go | 18 +- 17 files changed, 1955 insertions(+), 1032 deletions(-) create mode 100644 cmd/infra.go create mode 100644 cmd/infra_process_test.go create mode 100644 cmd/infra_test.go create mode 100644 cmd/mongo_test.go create mode 100644 cmd/services.go create mode 100644 cmd/services_test.go delete mode 100644 cmd/start_integration_test.go 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..a16c41e 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,56 @@ 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 { - 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())) + var lastErr error + for _, attemptDelaySec := range attemptDelaysSec { + attemptDelay := time.Duration(attemptDelaySec) * time.Second + select { + case <-ctx.Done(): return ctx.Err() + case <-time.After(attemptDelay): } - log.Warn("failed to initialize mongo replica set, retrying...", - zap.Error(err), - zap.Int("delay_seconds", delay)) - - time.Sleep(time.Duration(delay) * time.Second) + lastErr = tryInitReplicaSet(ctx, clientOpts, replica) + if lastErr == nil { + log.Info("successfully initialized mongo replica set") + return nil + } + if ctx.Err() != nil { + return lastErr + } } - 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 { 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)) + if disconnectErr := client.Disconnect(ctx); 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(errInit)) + log.Warn("failed to initialize new replica set", zap.Error(initErr)) return checkReplicaSetStatus(ctx, client) } @@ -100,22 +106,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..082a21b --- /dev/null +++ b/cmd/mongo_test.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "context" + "errors" + "testing" + "testing/synctest" + "time" +) + +const mongoURIWithInvalidOptions = "mongodb://localhost/?directConnection=invalid" + +// 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..7f27bbe 100644 --- a/compose.aio.yml +++ b/compose.aio.yml @@ -10,6 +10,7 @@ services: image: ghcr.io/grishy/any-sync-bundle:1.4.3-2026-04-21 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..06fd282 100644 --- a/compose.external.yml +++ b/compose.external.yml @@ -60,6 +60,7 @@ services: image: ghcr.io/grishy/any-sync-bundle:1.4.3-2026-04-21-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..e635cab 100644 --- a/compose.s3.yml +++ b/compose.s3.yml @@ -47,6 +47,7 @@ services: image: ghcr.io/grishy/any-sync-bundle:1.4.3-2026-04-21 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..9886d08 100644 --- a/compose.traefik.yml +++ b/compose.traefik.yml @@ -42,6 +42,7 @@ services: image: ghcr.io/grishy/any-sync-bundle:1.4.3-2026-04-21 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/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/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 } } From b0c02d8be81db036af6d60d089ee1af1d70fc3a4 Mon Sep 17 00:00:00 2001 From: "Sergei G." Date: Mon, 20 Jul 2026 15:06:14 +0400 Subject: [PATCH 3/7] tests: focus coverage on owned boundaries Keep tests for repository-owned storage and container boundaries while removing duplicate checks of dependency behavior. Exercise S3 request signing and the complete all-in-one shutdown path instead of treating startup markers as sufficient proof. --- integration/integration_test.go | 257 +++---- lightcmp/lightfilenodestore/store_test.go | 851 ++++------------------ lightnode/anynodes_test.go | 12 - 3 files changed, 289 insertions(+), 831 deletions(-) diff --git a/integration/integration_test.go b/integration/integration_test.go index bb18891..9f6c2c9 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -3,167 +3,172 @@ 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") - - // 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.WaitReady(90*time.Second)) + require.NoError(t, bundle.VerifyPort("33010")) + require.NoError(t, bundle.Stop(), "bundle should shut down cleanly") } -func TestBundleWithS3Storage(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) +// 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() - // 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) - - // 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) - - // 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, - S3AccessKey: minio.AccessKey, - S3SecretKey: minio.SecretKey, - }) - require.NoError(t, err, "Failed to 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") + 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())) } -// 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_test.go b/lightcmp/lightfilenodestore/store_test.go index 4a0d7fa..6a0863c 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,246 @@ 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")) +func TestLightFileNodeStoreMissingBlock(t *testing.T) { + store := setupTestStore(t) + missing := createTestBlock(t, []byte("missing")) - // Add block - err := store.Add(ctx, []blocks.Block{block}) - require.NoError(t, err) + _, err := store.Get(t.Context(), missing.Cid()) - // Verify it exists - _, err = store.Get(ctx, block.Cid()) - require.NoError(t, err) - - // Delete block - err = store.Delete(ctx, block.Cid()) - require.NoError(t, err) - - // 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) - - // Add blocks - err := store.Add(ctx, testBlocks) - require.NoError(t, err) +// 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)) - // 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() +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)) - 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) - - // 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 TestLightFileNodeStore_IndexPut_Get(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - key := "test-index-key" - value := []byte("test-index-value") +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})) - // Put index value - err := store.IndexPut(ctx, key, value) - require.NoError(t, err) + require.NoError(t, store.Delete(ctx, block.Cid())) - // Get index value - retrieved, err := store.IndexGet(ctx, key) - require.NoError(t, err) - assert.Equal(t, value, retrieved) -} + _, err := store.Get(ctx, block.Cid()) + assert.ErrorIs(t, err, fileblockstore.ErrCIDNotFound) + }) -func TestLightFileNodeStore_IndexGet_NonExistent(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() + 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)) - ctx := context.Background() - key := "non-existent-key" + cids := make([]cid.Cid, len(batch)) + for index, block := range batch { + cids[index] = block.Cid() + } + require.NoError(t, store.DeleteMany(ctx, cids)) - // Get non-existent index value - value, err := store.IndexGet(ctx, key) - require.NoError(t, err) - assert.Nil(t, value) + for _, blockCID := range cids { + _, err := store.Get(ctx, blockCID) + assert.ErrorIs(t, err, fileblockstore.ErrCIDNotFound) + } + }) } -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) + assert.Equal(t, value, actual) - // Delete index value - err = store.IndexDelete(ctx, key) + require.NoError(t, store.IndexDelete(ctx, key)) + actual, err = store.IndexGet(ctx, key) require.NoError(t, err) - - // Verify it's gone - retrieved, err = store.IndexGet(ctx, key) - require.NoError(t, err) - assert.Nil(t, retrieved) + assert.Nil(t, actual) } -func TestLightFileNodeStore_IndexDelete_NonExistent(t *testing.T) { - store, cleanup := setupTestStore(t) - defer cleanup() - - ctx := context.Background() - - err := store.IndexDelete(ctx, "missing-index") - require.NoError(t, err) -} - -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()) + indexValue := []byte("index-value") - retrievedIndex, err := store.IndexGet(ctx, indexKey) - require.NoError(t, err) - assert.Equal(t, indexValue, retrievedIndex) -} - -// Concurrent Operations Tests - -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 +// 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") -func TestLightFileNodeStore_Persistence(t *testing.T) { - tmpDir := t.TempDir() - ctx := context.Background() + 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)) - // First store instance - store1 := New(tmpDir) - err := store1.Init(&app.App{}) - require.NoError(t, err) - err = store1.Run(ctx) - require.NoError(t, err) - - // 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) - - // Close store - err = store1.Close(ctx) - require.NoError(t, err) - - // Second store instance with same path - store2 := New(tmpDir) - err = store2.Init(&app.App{}) - require.NoError(t, err) - err = store2.Run(ctx) - require.NoError(t, err) - defer store2.Close(ctx) + 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())) + }) - // Verify block persisted - retrieved, err := store2.Get(ctx, block.Cid()) + actualBlock, err := second.Get(ctx, block.Cid()) require.NoError(t, err) - assert.Equal(t, block.RawData(), retrieved.RawData()) + assert.Equal(t, block.RawData(), actualBlock.RawData()) - // Verify index persisted - indexValue, err := store2.IndexGet(ctx, "persistent-key") + actualIndex, err := second.IndexGet(ctx, indexKey) 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())) } diff --git a/lightnode/anynodes_test.go b/lightnode/anynodes_test.go index 36aa0d6..61a0760 100644 --- a/lightnode/anynodes_test.go +++ b/lightnode/anynodes_test.go @@ -38,15 +38,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") -} From 6baa2ce058a8025a43240c977a0470032951aeb7 Mon Sep 17 00:00:00 2001 From: "Sergei G." Date: Mon, 20 Jul 2026 15:09:02 +0400 Subject: [PATCH 4/7] build: align Go 1.26.4 and verification gates Use Go 1.26.4 consistently across modules, containers, CI, and developer documentation. Give one Linux runner the race-enabled behavior gate while retaining representative portability, release-build, Docker, and Nix coverage. --- .github/workflows/commit.yml | 30 ++++++++++++++++-------------- .github/workflows/release.yml | 2 +- CONTRIBUTING.md | 2 +- Dockerfile | 2 +- flake.lock | 6 +++--- flake.nix | 7 +++---- go.mod | 2 +- 7 files changed, 26 insertions(+), 25 deletions(-) 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/CONTRIBUTING.md b/CONTRIBUTING.md index f9c56d3..c2e1746 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) diff --git a/Dockerfile b/Dockerfile index 34908d5..37f993c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # # Stage: Initial bin build # -FROM --platform=$BUILDPLATFORM golang:1.26.1-alpine AS stage-bin +FROM --platform=$BUILDPLATFORM golang:1.26.4-alpine AS stage-bin WORKDIR /app # Use mount cache for dependencies 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..abeb3a1 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"; @@ -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..dff3e07 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/grishy/any-sync-bundle -go 1.26 +go 1.26.4 tool github.com/matryer/moq From 428962f965ebdc9707ad0614a1f006199f5773c1 Mon Sep 17 00:00:00 2001 From: "Sergei G." Date: Mon, 20 Jul 2026 15:10:11 +0400 Subject: [PATCH 5/7] docs: document bundle lifecycle and maintenance Describe the repository-owned lifecycle, configuration, storage, and verification boundaries for contributors and agents. Give operators the matching container stop timeout and require a completed clean shutdown before backup. Limit the compatibility promise to bundle configuration format 1. --- AGENTS.md | 147 +++++++++++++++++++++++++++++++++++++++--------------- README.md | 60 +++++++++++----------- 2 files changed, 139 insertions(+), 68 deletions(-) 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/README.md b/README.md index 00053ce..3548e6c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ docker run -d \ -p 33010:33010 \ -p 33020:33020/udp \ -v $(pwd)/data:/data \ + --stop-timeout 120 \ --restart unless-stopped \ ghcr.io/grishy/any-sync-bundle:1.4.3-2026-04-21 ``` @@ -49,49 +50,45 @@ After the first run, import `./data/client-config.yml` into Anytype apps. - **Easy to start**: A single command to launch the server - **All-in-one option**: All services in a single container or in separate binaries - **Zero-config**: Sensible defaults, configurable when needed -- **Lightweight**: No MinIO option, and no duplicate logical services +- **Lightweight**: No required MinIO, and no duplicate logical services - **Only 2 open ports**: TCP 33010 (DRPC protocol) and UDP 33020 (QUIC protocol) -### Who is this for? +**Who is this for?** -- ✅ **Self-hosters** who value simplicity over complexity -- ✅ **Low resource** Homelab setups and Raspberry Pi deployments +- Self-hosters who value simplicity over complexity +- Low resource Homelab setups and Raspberry Pi deployments -### Not for you if +**Not for you if** -- ❌ You need high-availability clustering across multiple nodes -- ❌ You require horizontal scaling beyond a single server -- ❌ You want to use the official Anytype architecture as-is +- You need high-availability clustering across multiple nodes +- You require horizontal scaling beyond a single server +- You want to use the official Anytype architecture as-is ### Architecture ![Comparison with original deployment](./docs/arch.svg) -### Version - Current version: **`v1.4.3-2026-04-21`** - +Compatibility: Bundle configuration format 1 remains readable across 1.x releases. Format: `v[bundle-version]-[anytype-compatibility-date]` - `v1.4.3` – Bundle's semantic version (SemVer) -- `2026-04-21` – Anytype any-sync compatibility date from [anytype.io](https://puppetdoc.anytype.io/api/v1/prod-any-sync-compatible-versions/) - -> The compatibility date suffix is always derived in UTC. - -> Compatibility: From 1.x onward we follow SemVer; 1.x upgrades are non‑breaking. +- `2026-04-21` – Anytype any-sync compatibility date from [anytype.io](https://puppetdoc.anytype.io/api/v1/prod-any-sync-compatible-versions/). Derived in UTC. ## Installation -### Available Images +### Container Images + +| Image Tag | Description | +| --------------------------------------------------------- | ------------------------------------------------ | +| `ghcr.io/grishy/any-sync-bundle:1.4.3-2026-04-21` | All-in-one (embedded MongoDB/Redis) | +| `ghcr.io/grishy/any-sync-bundle:1.4.3-2026-04-21-minimal` | Minimal (external MongoDB/Redis, start your own) | -| Image Tag | Description | -| --------------------------------------------------------- | ----------------------------------- | -| `ghcr.io/grishy/any-sync-bundle:1.4.3-2026-04-21` | All-in-one (embedded MongoDB/Redis) | -| `ghcr.io/grishy/any-sync-bundle:1.4.3-2026-04-21-minimal` | Minimal (external MongoDB/Redis) | +Latest tags (`:latest`, `:minimal`) are available, but explicit version tags are recommended. Better to use exact version and update your own. -Latest tags (`:latest`, `:minimal`) are available, but explicit version tags are recommended. +### Docker Compose -### Docker Compose (Recommended) +Edit `ANY_SYNC_BUNDLE_INIT_EXTERNAL_ADDRS` in the compose file before starting. | File | Description | | ---------------------- | -------------------------------------------- | @@ -101,16 +98,16 @@ Latest tags (`:latest`, `:minimal`) are available, but explicit version tags are | `compose.traefik.yml` | With Traefik reverse proxy | ```sh -# Pick one as example: +# Pick one as example one of docker compose -f compose.aio.yml up -d docker compose -f compose.external.yml up -d docker compose -f compose.s3.yml up -d ``` -Edit `ANY_SYNC_BUNDLE_INIT_EXTERNAL_ADDRS` in the compose file before starting. - ### Binary +This is only with external MongoDB/Redis option. + 1. Download from the [Release page](https://github.com/grishy/any-sync-bundle/releases) 2. Run: @@ -124,7 +121,9 @@ Edit `ANY_SYNC_BUNDLE_INIT_EXTERNAL_ADDRS` in the compose file before starting. ## Configuration -### Quick Reference +### Reference + +All `ANY_SYNC_BUNDLE_INIT*` will be taked into account on start and later on baked into config. | Variable | Purpose | Required | | --------------------------------------------- | -------------------------------- | -------- | @@ -244,8 +243,11 @@ All parameters available as binary flags or environment variables. See `./any-sy **Backup:** +Take a backup only after a successful clean stop. + ```sh -# Stop service first +docker compose -f compose.aio.yml stop +# Confirm the logs contain bundle_shutdown_complete before archiving. tar -czf backup-$(date +%Y%m%d-%H%M%S).tar.gz ./data/ ``` @@ -297,7 +299,7 @@ This project wouldn't exist without: ## License -© 2025 [Sergei G.](https://github.com/grishy) +© 2026 [Sergei G.](https://github.com/grishy) Licensed under [MIT](./LICENSE).

From 12c01a50d3c7c83a5b6802837e014245c0c0955a Mon Sep 17 00:00:00 2001 From: "Sergei G." Date: Mon, 20 Jul 2026 20:40:54 +0400 Subject: [PATCH 6/7] bundle: align Anytype v0.12 runtime boundaries Update the Anytype node modules to compatibility timestamp 1784291832 and register the coordinator invite store required by the v0.12 bootstrap. Keep the wrapper aligned with upstream component ownership by starting shared transports last and retaining the composite filenode store lifecycle contract. Make Badger acknowledgements durable, propagate deletion failures, and join its GC goroutine before releasing the database. Preserve exact operator cancellation during MongoDB initialization and validate MongoDB, Redis, and S3 inputs with the same parsers used at runtime. Restore a full-bundle custom-region S3 integration check and document clean backup and shutdown bounds. Refresh Go, Nix, Compose, and maintenance metadata for Go 1.26.4. Closes #74 --- .github/workflows/version-check.yml | 28 ++- CONTRIBUTING.md | 4 +- README.md | 17 +- cmd/mongo.go | 15 +- cmd/mongo_test.go | 44 +++++ compose.aio.yml | 2 +- compose.external.yml | 2 +- compose.s3.yml | 2 +- compose.traefik.yml | 2 +- config/bundle.go | 22 ++- config/bundle_test.go | 47 +++++ flake.nix | 4 +- go.mod | 75 ++++---- go.sum | 220 ++++++++++------------ integration/integration_test.go | 38 ++++ lightcmp/lightfilenodestore/store.go | 56 +++--- lightcmp/lightfilenodestore/store_test.go | 41 ++++ lightnode/anynodes.go | 18 +- lightnode/anynodes_test.go | 31 +++ 19 files changed, 438 insertions(+), 230 deletions(-) diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index c0da179..807a3d4 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 @@ -81,21 +83,17 @@ jobs: `go mod tidy` may also update `github.com/anyproto/any-sync` automatically. Verify: - ``` + ```sh go mod tidy go mod verify - golangci-lint run --fix ./... + golangci-lint run go test -race -shuffle=on -vet=all -failfast ./... - go build -o any-sync-bundle . - ./any-sync-bundle --version - ./any-sync-bundle --help - nix build .#default - nix flake check - ``` - - Optional smoke test: - ```bash - go test -tags=integration ./integration/... + go build -o /tmp/any-sync-bundle . + /tmp/any-sync-bundle --version + /tmp/any-sync-bundle --help + go test -v -tags=integration -timeout=10m ./integration/... + nix flake check --print-build-logs + nix build -L .#default ``` --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c2e1746..bf4597f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 # The compatibility date suffix is always derived in UTC. set ANYTYPE_FORMATTED (env TZ=UTC date -r $ANYTYPE_UNIX_TIMESTAMP +'%Y-%m-%d') @@ -97,7 +97,7 @@ git push origin tag $FINAL_VERSION `v[bundle-version]-[anytype-compatibility-date]` -- `v1.4.3` – Bundle's semantic version (SemVer) +- `v1.5.0` – Bundle's semantic version (SemVer) - `YYYY-MM-DD` – Date derived in UTC from the current Anytype compatibility timestamp: https://puppetdoc.anytype.io/api/v1/prod-any-sync-compatible-versions/ diff --git a/README.md b/README.md index 3548e6c..37d1b29 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ docker run -d \ -v $(pwd)/data:/data \ --stop-timeout 120 \ --restart unless-stopped \ - ghcr.io/grishy/any-sync-bundle:1.4.3-2026-04-21 + ghcr.io/grishy/any-sync-bundle:1.5.0-2026-07-17 ``` After the first run, import `./data/client-config.yml` into Anytype apps. @@ -68,12 +68,12 @@ After the first run, import `./data/client-config.yml` into Anytype apps. ![Comparison with original deployment](./docs/arch.svg) -Current version: **`v1.4.3-2026-04-21`** +Current version: **`v1.5.0-2026-07-17`** Compatibility: Bundle configuration format 1 remains readable across 1.x releases. Format: `v[bundle-version]-[anytype-compatibility-date]` -- `v1.4.3` – Bundle's semantic version (SemVer) -- `2026-04-21` – Anytype any-sync compatibility date from [anytype.io](https://puppetdoc.anytype.io/api/v1/prod-any-sync-compatible-versions/). Derived in UTC. +- `v1.5.0` – Bundle's semantic version (SemVer) +- `2026-07-17` – Anytype any-sync compatibility date from [anytype.io](https://puppetdoc.anytype.io/api/v1/prod-any-sync-compatible-versions/). Derived in UTC. ## Installation @@ -81,8 +81,8 @@ Format: `v[bundle-version]-[anytype-compatibility-date]` | Image Tag | Description | | --------------------------------------------------------- | ------------------------------------------------ | -| `ghcr.io/grishy/any-sync-bundle:1.4.3-2026-04-21` | All-in-one (embedded MongoDB/Redis) | -| `ghcr.io/grishy/any-sync-bundle:1.4.3-2026-04-21-minimal` | Minimal (external MongoDB/Redis, start your own) | +| `ghcr.io/grishy/any-sync-bundle:1.5.0-2026-07-17` | All-in-one (embedded MongoDB/Redis) | +| `ghcr.io/grishy/any-sync-bundle:1.5.0-2026-07-17-minimal` | Minimal (external MongoDB/Redis, start your own) | Latest tags (`:latest`, `:minimal`) are available, but explicit version tags are recommended. Better to use exact version and update your own. @@ -248,7 +248,10 @@ Take a backup only after a successful clean stop. ```sh docker compose -f compose.aio.yml stop # Confirm the logs contain bundle_shutdown_complete before archiving. -tar -czf backup-$(date +%Y%m%d-%H%M%S).tar.gz ./data/ +# MongoDB diagnostic.data contains diagnostics, not database state. +tar --exclude='./data/mongo/diagnostic.data' \ + -czf backup-$(date +%Y%m%d-%H%M%S).tar.gz \ + ./data/ ``` **Restore:** diff --git a/cmd/mongo.go b/cmd/mongo.go index a16c41e..b2c9933 100644 --- a/cmd/mongo.go +++ b/cmd/mongo.go @@ -49,7 +49,7 @@ func initReplicaSetAction(ctx context.Context, replica, mongoURI string) error { return nil } if ctx.Err() != nil { - return lastErr + return ctx.Err() } } @@ -64,11 +64,19 @@ func tryInitReplicaSet(ctx context.Context, clientOpts *options.ClientOptions, r 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 disconnectErr := client.Disconnect(ctx); disconnectErr != nil { + 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)) } }() @@ -84,6 +92,9 @@ func tryInitReplicaSet(ctx context.Context, clientOpts *options.ClientOptions, r } } log.Warn("failed to initialize new replica set", zap.Error(initErr)) + if ctx.Err() != nil { + return ctx.Err() + } return checkReplicaSetStatus(ctx, client) } diff --git a/cmd/mongo_test.go b/cmd/mongo_test.go index 082a21b..4d13089 100644 --- a/cmd/mongo_test.go +++ b/cmd/mongo_test.go @@ -3,13 +3,57 @@ 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) { diff --git a/compose.aio.yml b/compose.aio.yml index 7f27bbe..43495de 100644 --- a/compose.aio.yml +++ b/compose.aio.yml @@ -7,7 +7,7 @@ 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 diff --git a/compose.external.yml b/compose.external.yml index 06fd282..b9b5207 100644 --- a/compose.external.yml +++ b/compose.external.yml @@ -57,7 +57,7 @@ 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 diff --git a/compose.s3.yml b/compose.s3.yml index e635cab..c20ca51 100644 --- a/compose.s3.yml +++ b/compose.s3.yml @@ -44,7 +44,7 @@ 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 diff --git a/compose.traefik.yml b/compose.traefik.yml index 9886d08..fc5b28b 100644 --- a/compose.traefik.yml +++ b/compose.traefik.yml @@ -39,7 +39,7 @@ 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 diff --git a/config/bundle.go b/config/bundle.go index 22feebe..8d35c3e 100644 --- a/config/bundle.go +++ b/config/bundle.go @@ -16,6 +16,7 @@ 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" @@ -155,8 +156,7 @@ func (cfg *Config) Validate() error { 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 { @@ -178,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 @@ -189,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 { @@ -211,11 +214,24 @@ func validateMongoURI(field string, mongoURI string) error { 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 { diff --git a/config/bundle_test.go b/config/bundle_test.go index 21241f4..2bf389d 100644 --- a/config/bundle_test.go +++ b/config/bundle_test.go @@ -290,6 +290,13 @@ func TestConfigValidate(t *testing.T) { }, wantErr: "network.listenTCPAddr must be in host:port format", }, + { + 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) { @@ -311,6 +318,26 @@ func TestConfigValidate(t *testing.T) { }, wantErr: "filenode.redisConnect must include a host", }, + { + 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) { @@ -321,6 +348,26 @@ 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 _, test := range tests { diff --git a/flake.nix b/flake.nix index abeb3a1..b37dbbe 100644 --- a/flake.nix +++ b/flake.nix @@ -43,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; diff --git a/go.mod b/go.mod index dff3e07..4311dc2 100644 --- a/go.mod +++ b/go.mod @@ -5,27 +5,28 @@ 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/integration_test.go b/integration/integration_test.go index 9f6c2c9..b81af63 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -93,6 +93,44 @@ func TestS3StorageCustomRegionRoundTrip(t *testing.T) { require.True(t, bytes.Equal(expected.RawData(), actual.RawData())) } +// 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() + + mongo, err := StartMongo(ctx) + require.NoError(t, err, "start MongoDB") + defer mongo.Terminate(ctx) + + redis, err := StartRedis(ctx) + require.NoError(t, err, "start Redis") + defer redis.Terminate(ctx) + + const minioRegion = "custom-test-region" + minio, err := StartMinIOWithRegion(ctx, minioRegion) + require.NoError(t, err, "start MinIO") + defer minio.Terminate(ctx) + + 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, "start bundle") + defer bundle.Cleanup() + defer bundle.Stop() + + 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") +} + // 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. 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 6a0863c..74e101b 100644 --- a/lightcmp/lightfilenodestore/store_test.go +++ b/lightcmp/lightfilenodestore/store_test.go @@ -66,6 +66,14 @@ func TestLightFileNodeStoreBlockRoundTrip(t *testing.T) { } } +// 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) + + assert.True(t, store.db.Opts().SyncWrites) +} + func TestLightFileNodeStoreMissingBlock(t *testing.T) { store := setupTestStore(t) missing := createTestBlock(t, []byte("missing")) @@ -157,6 +165,22 @@ func TestLightFileNodeStoreDelete(t *testing.T) { 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) + + err = store.DeleteMany(t.Context(), []cid.Cid{oversizedCID}) + + assert.ErrorContains(t, err, "failed to queue block deletion") + }) } func TestLightFileNodeStoreIndexLifecycle(t *testing.T) { @@ -259,3 +283,20 @@ func TestLightFileNodeStoreGarbageCollection(t *testing.T) { require.NoError(t, err) 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 61a0760..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{ From a9459b48277ea1a2ca919e2abe09850c304661bc Mon Sep 17 00:00:00 2001 From: "Sergei G." Date: Mon, 20 Jul 2026 21:23:06 +0400 Subject: [PATCH 7/7] workflow: restore version update instructions --- .github/workflows/version-check.yml | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index 807a3d4..d318aa4 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -83,17 +83,21 @@ jobs: `go mod tidy` may also update `github.com/anyproto/any-sync` automatically. Verify: - ```sh + ``` go mod tidy go mod verify - golangci-lint run + golangci-lint run --fix ./... go test -race -shuffle=on -vet=all -failfast ./... - go build -o /tmp/any-sync-bundle . - /tmp/any-sync-bundle --version - /tmp/any-sync-bundle --help - go test -v -tags=integration -timeout=10m ./integration/... - nix flake check --print-build-logs - nix build -L .#default + go build -o any-sync-bundle . + ./any-sync-bundle --version + ./any-sync-bundle --help + nix build .#default + nix flake check + ``` + + Optional smoke test: + ```bash + go test -tags=integration ./integration/... ``` ---