fix(cli): fallback to local signals when remote admin is unavailable
- commit
- 800ace03777cc836a1a522089aa457ee1401fe08
- author
- brice · · 2026-02-06 17:58 UTC
- committer
- brice · · 2026-02-06 19:34 UTC
- parent
- cdca39f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +242 -2
M internal/adapters/in/cli/deploy.go +8 -0
| @@ -35,6 +35,14 @@ Examples: | |||
| 35 | 35 | // Remote deployment via admin API | |
| 36 | 36 | result, err := client.Deploy(ctx, deployDomain) | |
| 37 | 37 | if err != nil { | |
| 38 | + | if shouldFallbackToLocal(err) { | |
| 39 | + | domain, localErr := app.SendDeploySignal(deployDomain) | |
| 40 | + | if localErr == nil { | |
| 41 | + | fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err))) | |
| 42 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain))) | |
| 43 | + | return nil | |
| 44 | + | } | |
| 45 | + | } | |
| 38 | 46 | return fmt.Errorf("failed to deploy: %w", err) | |
| 39 | 47 | } | |
| 40 | 48 | containerID := result.ContainerID | |
| @@ -35,6 +35,14 @@ Examples: | |||||
| 35 | // Remote deployment via admin API | 35 | // Remote deployment via admin API | ||
| 36 | result, err := client.Deploy(ctx, deployDomain) | 36 | result, err := client.Deploy(ctx, deployDomain) | ||
| 37 | if err != nil { | 37 | if err != nil { | ||
| 38 | + | if shouldFallbackToLocal(err) { | |||
| 39 | + | domain, localErr := app.SendDeploySignal(deployDomain) | |||
| 40 | + | if localErr == nil { | |||
| 41 | + | fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err))) | |||
| 42 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain))) | |||
| 43 | + | return nil | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 38 | return fmt.Errorf("failed to deploy: %w", err) | 46 | return fmt.Errorf("failed to deploy: %w", err) | ||
| 39 | } | 47 | } | ||
| 40 | containerID := result.ContainerID | 48 | containerID := result.ContainerID | ||
A internal/adapters/in/cli/fallback.go +36 -0
| @@ -0,0 +1,36 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | func shouldFallbackToLocal(err error) bool { | |
| 6 | + | if err == nil { | |
| 7 | + | return false | |
| 8 | + | } | |
| 9 | + | msg := strings.ToLower(err.Error()) | |
| 10 | + | ||
| 11 | + | // Retry local signal mode when remote API is unavailable or misrouted. | |
| 12 | + | if strings.Contains(msg, "connection refused") || | |
| 13 | + | strings.Contains(msg, "no such host") || | |
| 14 | + | strings.Contains(msg, "i/o timeout") || | |
| 15 | + | strings.Contains(msg, "timeout") { | |
| 16 | + | return true | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | if strings.Contains(msg, "500") || | |
| 20 | + | strings.Contains(msg, "501") || | |
| 21 | + | strings.Contains(msg, "502") || | |
| 22 | + | strings.Contains(msg, "503") || | |
| 23 | + | strings.Contains(msg, "504") || | |
| 24 | + | strings.Contains(msg, "505") { | |
| 25 | + | return true | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | if strings.Contains(msg, "internal server error") || | |
| 29 | + | strings.Contains(msg, "service unavailable") || | |
| 30 | + | strings.Contains(msg, "bad gateway") || | |
| 31 | + | strings.Contains(msg, "gateway timeout") { | |
| 32 | + | return true | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | return false | |
| 36 | + | } | |
| @@ -0,0 +1,36 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | func shouldFallbackToLocal(err error) bool { | |||
| 6 | + | if err == nil { | |||
| 7 | + | return false | |||
| 8 | + | } | |||
| 9 | + | msg := strings.ToLower(err.Error()) | |||
| 10 | + | ||||
| 11 | + | // Retry local signal mode when remote API is unavailable or misrouted. | |||
| 12 | + | if strings.Contains(msg, "connection refused") || | |||
| 13 | + | strings.Contains(msg, "no such host") || | |||
| 14 | + | strings.Contains(msg, "i/o timeout") || | |||
| 15 | + | strings.Contains(msg, "timeout") { | |||
| 16 | + | return true | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | if strings.Contains(msg, "500") || | |||
| 20 | + | strings.Contains(msg, "501") || | |||
| 21 | + | strings.Contains(msg, "502") || | |||
| 22 | + | strings.Contains(msg, "503") || | |||
| 23 | + | strings.Contains(msg, "504") || | |||
| 24 | + | strings.Contains(msg, "505") { | |||
| 25 | + | return true | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | if strings.Contains(msg, "internal server error") || | |||
| 29 | + | strings.Contains(msg, "service unavailable") || | |||
| 30 | + | strings.Contains(msg, "bad gateway") || | |||
| 31 | + | strings.Contains(msg, "gateway timeout") { | |||
| 32 | + | return true | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | return false | |||
| 36 | + | } | |||
A internal/adapters/in/cli/fallback_test.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestShouldFallbackToLocal(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | err error | |
| 14 | + | want bool | |
| 15 | + | }{ | |
| 16 | + | {name: "nil", err: nil, want: false}, | |
| 17 | + | {name: "connection refused", err: errors.New(`Post "https://reg.example.com/admin/restart": dial tcp 127.0.0.1:443: connect: connection refused`), want: true}, | |
| 18 | + | {name: "status 503", err: errors.New("503 Service Unavailable: Registry Unavailable"), want: true}, | |
| 19 | + | {name: "status 500", err: errors.New("500 Internal Server Error: failed to deploy container"), want: true}, | |
| 20 | + | {name: "timeout", err: errors.New("request timeout"), want: true}, | |
| 21 | + | {name: "auth error", err: errors.New("401 Unauthorized: invalid token"), want: false}, | |
| 22 | + | {name: "bad request", err: errors.New("400 Bad Request: invalid domain"), want: false}, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | for _, tt := range tests { | |
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |
| 27 | + | assert.Equal(t, tt.want, shouldFallbackToLocal(tt.err)) | |
| 28 | + | }) | |
| 29 | + | } | |
| 30 | + | } | |
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestShouldFallbackToLocal(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | err error | |||
| 14 | + | want bool | |||
| 15 | + | }{ | |||
| 16 | + | {name: "nil", err: nil, want: false}, | |||
| 17 | + | {name: "connection refused", err: errors.New(`Post "https://reg.example.com/admin/restart": dial tcp 127.0.0.1:443: connect: connection refused`), want: true}, | |||
| 18 | + | {name: "status 503", err: errors.New("503 Service Unavailable: Registry Unavailable"), want: true}, | |||
| 19 | + | {name: "status 500", err: errors.New("500 Internal Server Error: failed to deploy container"), want: true}, | |||
| 20 | + | {name: "timeout", err: errors.New("request timeout"), want: true}, | |||
| 21 | + | {name: "auth error", err: errors.New("401 Unauthorized: invalid token"), want: false}, | |||
| 22 | + | {name: "bad request", err: errors.New("400 Bad Request: invalid domain"), want: false}, | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | for _, tt := range tests { | |||
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 27 | + | assert.Equal(t, tt.want, shouldFallbackToLocal(tt.err)) | |||
| 28 | + | }) | |||
| 29 | + | } | |||
| 30 | + | } | |||
M internal/adapters/in/cli/local.go +32 -2
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "path/filepath" | |
| 8 | + | "strings" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/zerowrap" | |
| 10 | 11 | "github.com/spf13/viper" | |
| @@ -12,6 +13,8 @@ import ( | |||
| 12 | 13 | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 13 | 14 | "github.com/bnema/gordon/internal/app" | |
| 14 | 15 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 16 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 17 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | 18 | "github.com/bnema/gordon/internal/usecase/config" | |
| 16 | 19 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | |
| 17 | 20 | ) | |
| @@ -82,8 +85,8 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||
| 82 | 85 | envDir = filepath.Join(dataDir, "env") | |
| 83 | 86 | } | |
| 84 | 87 | ||
| 85 | - | // Create domain secret store and service | |
| 86 | - | domainSecretStore, err := domainsecrets.NewFileStore(envDir, log) | |
| 88 | + | // Create domain secret store using configured backend (pass or file-based) | |
| 89 | + | domainSecretStore, err := createLocalDomainSecretStore(v, envDir, log) | |
| 87 | 90 | if err != nil { | |
| 88 | 91 | return nil, fmt.Errorf("failed to create domain secret store: %w", err) | |
| 89 | 92 | } | |
| @@ -95,3 +98,30 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||
| 95 | 98 | dataDir: dataDir, | |
| 96 | 99 | }, nil | |
| 97 | 100 | } | |
| 101 | + | ||
| 102 | + | func createLocalDomainSecretStore(v *viper.Viper, envDir string, log zerowrap.Logger) (out.DomainSecretStore, error) { | |
| 103 | + | backend := resolveLocalSecretsBackend(v) | |
| 104 | + | if backend == domain.SecretsBackendPass { | |
| 105 | + | return domainsecrets.NewPassStore(log) | |
| 106 | + | } | |
| 107 | + | return domainsecrets.NewFileStore(envDir, log) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func resolveLocalSecretsBackend(v *viper.Viper) domain.SecretsBackend { | |
| 111 | + | backend := strings.TrimSpace(v.GetString("auth.secrets_backend")) | |
| 112 | + | if backend == "" { | |
| 113 | + | // Legacy key support for older configs. | |
| 114 | + | backend = strings.TrimSpace(v.GetString("secrets.backend")) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | switch backend { | |
| 118 | + | case "pass": | |
| 119 | + | return domain.SecretsBackendPass | |
| 120 | + | case "sops": | |
| 121 | + | return domain.SecretsBackendSops | |
| 122 | + | case "unsafe", "": | |
| 123 | + | return domain.SecretsBackendUnsafe | |
| 124 | + | default: | |
| 125 | + | return domain.SecretsBackendUnsafe | |
| 126 | + | } | |
| 127 | + | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "context" | 5 | "context" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | + | "strings" | |||
| 8 | 9 | ||||
| 9 | "github.com/bnema/zerowrap" | 10 | "github.com/bnema/zerowrap" | ||
| 10 | "github.com/spf13/viper" | 11 | "github.com/spf13/viper" | ||
| @@ -12,6 +13,8 @@ import ( | |||||
| 12 | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | 13 | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | ||
| 13 | "github.com/bnema/gordon/internal/app" | 14 | "github.com/bnema/gordon/internal/app" | ||
| 14 | "github.com/bnema/gordon/internal/boundaries/in" | 15 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 16 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 17 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | "github.com/bnema/gordon/internal/usecase/config" | 18 | "github.com/bnema/gordon/internal/usecase/config" | ||
| 16 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | 19 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | ||
| 17 | ) | 20 | ) | ||
| @@ -82,8 +85,8 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||||
| 82 | envDir = filepath.Join(dataDir, "env") | 85 | envDir = filepath.Join(dataDir, "env") | ||
| 83 | } | 86 | } | ||
| 84 | 87 | ||||
| 85 | - | // Create domain secret store and service | 88 | + | // Create domain secret store using configured backend (pass or file-based) |
| 86 | - | domainSecretStore, err := domainsecrets.NewFileStore(envDir, log) | 89 | + | domainSecretStore, err := createLocalDomainSecretStore(v, envDir, log) |
| 87 | if err != nil { | 90 | if err != nil { | ||
| 88 | return nil, fmt.Errorf("failed to create domain secret store: %w", err) | 91 | return nil, fmt.Errorf("failed to create domain secret store: %w", err) | ||
| 89 | } | 92 | } | ||
| @@ -95,3 +98,30 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||||
| 95 | dataDir: dataDir, | 98 | dataDir: dataDir, | ||
| 96 | }, nil | 99 | }, nil | ||
| 97 | } | 100 | } | ||
| 101 | + | ||||
| 102 | + | func createLocalDomainSecretStore(v *viper.Viper, envDir string, log zerowrap.Logger) (out.DomainSecretStore, error) { | |||
| 103 | + | backend := resolveLocalSecretsBackend(v) | |||
| 104 | + | if backend == domain.SecretsBackendPass { | |||
| 105 | + | return domainsecrets.NewPassStore(log) | |||
| 106 | + | } | |||
| 107 | + | return domainsecrets.NewFileStore(envDir, log) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func resolveLocalSecretsBackend(v *viper.Viper) domain.SecretsBackend { | |||
| 111 | + | backend := strings.TrimSpace(v.GetString("auth.secrets_backend")) | |||
| 112 | + | if backend == "" { | |||
| 113 | + | // Legacy key support for older configs. | |||
| 114 | + | backend = strings.TrimSpace(v.GetString("secrets.backend")) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | switch backend { | |||
| 118 | + | case "pass": | |||
| 119 | + | return domain.SecretsBackendPass | |||
| 120 | + | case "sops": | |||
| 121 | + | return domain.SecretsBackendSops | |||
| 122 | + | case "unsafe", "": | |||
| 123 | + | return domain.SecretsBackendUnsafe | |||
| 124 | + | default: | |||
| 125 | + | return domain.SecretsBackendUnsafe | |||
| 126 | + | } | |||
| 127 | + | } | |||
A internal/adapters/in/cli/local_test.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zerowrap" | |
| 7 | + | "github.com/spf13/viper" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestResolveLocalSecretsBackend(t *testing.T) { | |
| 15 | + | t.Run("auth backend takes precedence", func(t *testing.T) { | |
| 16 | + | v := viper.New() | |
| 17 | + | v.Set("auth.secrets_backend", "pass") | |
| 18 | + | v.Set("secrets.backend", "unsafe") | |
| 19 | + | ||
| 20 | + | got := resolveLocalSecretsBackend(v) | |
| 21 | + | require.Equal(t, domain.SecretsBackendPass, got) | |
| 22 | + | }) | |
| 23 | + | ||
| 24 | + | t.Run("falls back to legacy secrets backend key", func(t *testing.T) { | |
| 25 | + | v := viper.New() | |
| 26 | + | v.Set("secrets.backend", "pass") | |
| 27 | + | ||
| 28 | + | got := resolveLocalSecretsBackend(v) | |
| 29 | + | require.Equal(t, domain.SecretsBackendPass, got) | |
| 30 | + | }) | |
| 31 | + | ||
| 32 | + | t.Run("defaults to unsafe for unknown backend", func(t *testing.T) { | |
| 33 | + | v := viper.New() | |
| 34 | + | v.Set("auth.secrets_backend", "wat") | |
| 35 | + | ||
| 36 | + | got := resolveLocalSecretsBackend(v) | |
| 37 | + | require.Equal(t, domain.SecretsBackendUnsafe, got) | |
| 38 | + | }) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestCreateLocalDomainSecretStore_UsesFileStoreForUnsafe(t *testing.T) { | |
| 42 | + | v := viper.New() | |
| 43 | + | v.Set("auth.secrets_backend", "unsafe") | |
| 44 | + | ||
| 45 | + | log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"}) | |
| 46 | + | store, err := createLocalDomainSecretStore(v, t.TempDir(), log) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | require.IsType(t, &domainsecrets.FileStore{}, store) | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/zerowrap" | |||
| 7 | + | "github.com/spf13/viper" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestResolveLocalSecretsBackend(t *testing.T) { | |||
| 15 | + | t.Run("auth backend takes precedence", func(t *testing.T) { | |||
| 16 | + | v := viper.New() | |||
| 17 | + | v.Set("auth.secrets_backend", "pass") | |||
| 18 | + | v.Set("secrets.backend", "unsafe") | |||
| 19 | + | ||||
| 20 | + | got := resolveLocalSecretsBackend(v) | |||
| 21 | + | require.Equal(t, domain.SecretsBackendPass, got) | |||
| 22 | + | }) | |||
| 23 | + | ||||
| 24 | + | t.Run("falls back to legacy secrets backend key", func(t *testing.T) { | |||
| 25 | + | v := viper.New() | |||
| 26 | + | v.Set("secrets.backend", "pass") | |||
| 27 | + | ||||
| 28 | + | got := resolveLocalSecretsBackend(v) | |||
| 29 | + | require.Equal(t, domain.SecretsBackendPass, got) | |||
| 30 | + | }) | |||
| 31 | + | ||||
| 32 | + | t.Run("defaults to unsafe for unknown backend", func(t *testing.T) { | |||
| 33 | + | v := viper.New() | |||
| 34 | + | v.Set("auth.secrets_backend", "wat") | |||
| 35 | + | ||||
| 36 | + | got := resolveLocalSecretsBackend(v) | |||
| 37 | + | require.Equal(t, domain.SecretsBackendUnsafe, got) | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestCreateLocalDomainSecretStore_UsesFileStoreForUnsafe(t *testing.T) { | |||
| 42 | + | v := viper.New() | |||
| 43 | + | v.Set("auth.secrets_backend", "unsafe") | |||
| 44 | + | ||||
| 45 | + | log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"}) | |||
| 46 | + | store, err := createLocalDomainSecretStore(v, t.TempDir(), log) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | require.IsType(t, &domainsecrets.FileStore{}, store) | |||
| 49 | + | } | |||
M internal/adapters/in/cli/restart.go +8 -0
| @@ -43,6 +43,14 @@ Examples: | |||
| 43 | 43 | ||
| 44 | 44 | result, err := client.Restart(ctx, restartDomain, withAttachments) | |
| 45 | 45 | if err != nil { | |
| 46 | + | if !withAttachments && shouldFallbackToLocal(err) { | |
| 47 | + | domain, localErr := app.SendDeploySignal(restartDomain) | |
| 48 | + | if localErr == nil { | |
| 49 | + | fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote restart failed (%v), used local deploy-signal fallback", err))) | |
| 50 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restart signal sent for %s (local deploy path)", domain))) | |
| 51 | + | return nil | |
| 52 | + | } | |
| 53 | + | } | |
| 46 | 54 | return fmt.Errorf("failed to restart: %w", err) | |
| 47 | 55 | } | |
| 48 | 56 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restarted %s", result.Domain))) | |
| @@ -43,6 +43,14 @@ Examples: | |||||
| 43 | 43 | ||||
| 44 | result, err := client.Restart(ctx, restartDomain, withAttachments) | 44 | result, err := client.Restart(ctx, restartDomain, withAttachments) | ||
| 45 | if err != nil { | 45 | if err != nil { | ||
| 46 | + | if !withAttachments && shouldFallbackToLocal(err) { | |||
| 47 | + | domain, localErr := app.SendDeploySignal(restartDomain) | |||
| 48 | + | if localErr == nil { | |||
| 49 | + | fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote restart failed (%v), used local deploy-signal fallback", err))) | |||
| 50 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restart signal sent for %s (local deploy path)", domain))) | |||
| 51 | + | return nil | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 46 | return fmt.Errorf("failed to restart: %w", err) | 54 | return fmt.Errorf("failed to restart: %w", err) | ||
| 47 | } | 55 | } | ||
| 48 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restarted %s", result.Domain))) | 56 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restarted %s", result.Domain))) | ||
M internal/adapters/in/cli/root.go +7 -0
| @@ -190,6 +190,13 @@ func runReload() error { | |||
| 190 | 190 | // runReloadRemote triggers a reload on a remote Gordon instance. | |
| 191 | 191 | func runReloadRemote(ctx context.Context, client *remote.Client) error { | |
| 192 | 192 | if err := client.Reload(ctx); err != nil { | |
| 193 | + | if shouldFallbackToLocal(err) { | |
| 194 | + | if localErr := runReload(); localErr == nil { | |
| 195 | + | fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err) | |
| 196 | + | fmt.Println("Configuration reloaded successfully") | |
| 197 | + | return nil | |
| 198 | + | } | |
| 199 | + | } | |
| 193 | 200 | return fmt.Errorf("failed to reload: %w", err) | |
| 194 | 201 | } | |
| 195 | 202 | fmt.Println("Configuration reloaded successfully") | |
| @@ -190,6 +190,13 @@ func runReload() error { | |||||
| 190 | // runReloadRemote triggers a reload on a remote Gordon instance. | 190 | // runReloadRemote triggers a reload on a remote Gordon instance. | ||
| 191 | func runReloadRemote(ctx context.Context, client *remote.Client) error { | 191 | func runReloadRemote(ctx context.Context, client *remote.Client) error { | ||
| 192 | if err := client.Reload(ctx); err != nil { | 192 | if err := client.Reload(ctx); err != nil { | ||
| 193 | + | if shouldFallbackToLocal(err) { | |||
| 194 | + | if localErr := runReload(); localErr == nil { | |||
| 195 | + | fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err) | |||
| 196 | + | fmt.Println("Configuration reloaded successfully") | |||
| 197 | + | return nil | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 193 | return fmt.Errorf("failed to reload: %w", err) | 200 | return fmt.Errorf("failed to reload: %w", err) | ||
| 194 | } | 201 | } | ||
| 195 | fmt.Println("Configuration reloaded successfully") | 202 | fmt.Println("Configuration reloaded successfully") | ||
M internal/adapters/out/domainsecrets/pass_store_test.go +72 -0
| @@ -177,6 +177,78 @@ func TestPassStore_DeleteAttachment(t *testing.T) { | |||
| 177 | 177 | assert.False(t, exists) | |
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | + | func TestPassStore_Delete_Idempotent(t *testing.T) { | |
| 181 | + | requirePass(t) | |
| 182 | + | ||
| 183 | + | store, err := NewPassStore(testLogger()) | |
| 184 | + | require.NoError(t, err) | |
| 185 | + | ||
| 186 | + | domainName := fmt.Sprintf("idempotent.%d.example.com", time.Now().UnixNano()) | |
| 187 | + | keys := []string{"API_KEY"} | |
| 188 | + | defer cleanupPassDomain(t, domainName, keys) | |
| 189 | + | ||
| 190 | + | err = store.Set(domainName, map[string]string{"API_KEY": "alpha"}) | |
| 191 | + | require.NoError(t, err) | |
| 192 | + | ||
| 193 | + | err = store.Delete(domainName, "API_KEY") | |
| 194 | + | require.NoError(t, err) | |
| 195 | + | ||
| 196 | + | // Second delete of an already-removed key must be a no-op. | |
| 197 | + | err = store.Delete(domainName, "API_KEY") | |
| 198 | + | require.NoError(t, err) | |
| 199 | + | ||
| 200 | + | values, err := store.GetAll(domainName) | |
| 201 | + | require.NoError(t, err) | |
| 202 | + | assert.NotContains(t, values, "API_KEY") | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func TestPassStore_DeleteAttachment_Idempotent(t *testing.T) { | |
| 206 | + | requirePass(t) | |
| 207 | + | ||
| 208 | + | store, err := NewPassStore(testLogger()) | |
| 209 | + | require.NoError(t, err) | |
| 210 | + | ||
| 211 | + | containerName := fmt.Sprintf("idempotent-attachment-%d", time.Now().UnixNano()) | |
| 212 | + | keys := []string{"POSTGRES_PASSWORD"} | |
| 213 | + | defer CleanupPassAttachment(t, containerName, keys) | |
| 214 | + | ||
| 215 | + | err = store.SetAttachment(containerName, map[string]string{"POSTGRES_PASSWORD": "secret123"}) | |
| 216 | + | require.NoError(t, err) | |
| 217 | + | ||
| 218 | + | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |
| 219 | + | require.NoError(t, err) | |
| 220 | + | ||
| 221 | + | // Second delete of an already-removed key must be a no-op. | |
| 222 | + | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |
| 223 | + | require.NoError(t, err) | |
| 224 | + | ||
| 225 | + | values, err := store.GetAllAttachment(containerName) | |
| 226 | + | require.NoError(t, err) | |
| 227 | + | assert.NotContains(t, values, "POSTGRES_PASSWORD") | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func TestPassStore_SetAttachment_OverwriteValue(t *testing.T) { | |
| 231 | + | requirePass(t) | |
| 232 | + | ||
| 233 | + | store, err := NewPassStore(testLogger()) | |
| 234 | + | require.NoError(t, err) | |
| 235 | + | ||
| 236 | + | containerName := fmt.Sprintf("attachment-overwrite-%d", time.Now().UnixNano()) | |
| 237 | + | keys := []string{"REDIS_PASSWORD"} | |
| 238 | + | defer CleanupPassAttachment(t, containerName, keys) | |
| 239 | + | ||
| 240 | + | err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "first"}) | |
| 241 | + | require.NoError(t, err) | |
| 242 | + | ||
| 243 | + | // Re-setting same key with a different value should deterministically overwrite. | |
| 244 | + | err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "second"}) | |
| 245 | + | require.NoError(t, err) | |
| 246 | + | ||
| 247 | + | values, err := store.GetAllAttachment(containerName) | |
| 248 | + | require.NoError(t, err) | |
| 249 | + | assert.Equal(t, "second", values["REDIS_PASSWORD"]) | |
| 250 | + | } | |
| 251 | + | ||
| 180 | 252 | func TestPassStore_ListKeys_RecoversOrphanedEntries(t *testing.T) { | |
| 181 | 253 | requirePass(t) | |
| 182 | 254 | ||
| @@ -177,6 +177,78 @@ func TestPassStore_DeleteAttachment(t *testing.T) { | |||||
| 177 | assert.False(t, exists) | 177 | assert.False(t, exists) | ||
| 178 | } | 178 | } | ||
| 179 | 179 | ||||
| 180 | + | func TestPassStore_Delete_Idempotent(t *testing.T) { | |||
| 181 | + | requirePass(t) | |||
| 182 | + | ||||
| 183 | + | store, err := NewPassStore(testLogger()) | |||
| 184 | + | require.NoError(t, err) | |||
| 185 | + | ||||
| 186 | + | domainName := fmt.Sprintf("idempotent.%d.example.com", time.Now().UnixNano()) | |||
| 187 | + | keys := []string{"API_KEY"} | |||
| 188 | + | defer cleanupPassDomain(t, domainName, keys) | |||
| 189 | + | ||||
| 190 | + | err = store.Set(domainName, map[string]string{"API_KEY": "alpha"}) | |||
| 191 | + | require.NoError(t, err) | |||
| 192 | + | ||||
| 193 | + | err = store.Delete(domainName, "API_KEY") | |||
| 194 | + | require.NoError(t, err) | |||
| 195 | + | ||||
| 196 | + | // Second delete of an already-removed key must be a no-op. | |||
| 197 | + | err = store.Delete(domainName, "API_KEY") | |||
| 198 | + | require.NoError(t, err) | |||
| 199 | + | ||||
| 200 | + | values, err := store.GetAll(domainName) | |||
| 201 | + | require.NoError(t, err) | |||
| 202 | + | assert.NotContains(t, values, "API_KEY") | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func TestPassStore_DeleteAttachment_Idempotent(t *testing.T) { | |||
| 206 | + | requirePass(t) | |||
| 207 | + | ||||
| 208 | + | store, err := NewPassStore(testLogger()) | |||
| 209 | + | require.NoError(t, err) | |||
| 210 | + | ||||
| 211 | + | containerName := fmt.Sprintf("idempotent-attachment-%d", time.Now().UnixNano()) | |||
| 212 | + | keys := []string{"POSTGRES_PASSWORD"} | |||
| 213 | + | defer CleanupPassAttachment(t, containerName, keys) | |||
| 214 | + | ||||
| 215 | + | err = store.SetAttachment(containerName, map[string]string{"POSTGRES_PASSWORD": "secret123"}) | |||
| 216 | + | require.NoError(t, err) | |||
| 217 | + | ||||
| 218 | + | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |||
| 219 | + | require.NoError(t, err) | |||
| 220 | + | ||||
| 221 | + | // Second delete of an already-removed key must be a no-op. | |||
| 222 | + | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |||
| 223 | + | require.NoError(t, err) | |||
| 224 | + | ||||
| 225 | + | values, err := store.GetAllAttachment(containerName) | |||
| 226 | + | require.NoError(t, err) | |||
| 227 | + | assert.NotContains(t, values, "POSTGRES_PASSWORD") | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func TestPassStore_SetAttachment_OverwriteValue(t *testing.T) { | |||
| 231 | + | requirePass(t) | |||
| 232 | + | ||||
| 233 | + | store, err := NewPassStore(testLogger()) | |||
| 234 | + | require.NoError(t, err) | |||
| 235 | + | ||||
| 236 | + | containerName := fmt.Sprintf("attachment-overwrite-%d", time.Now().UnixNano()) | |||
| 237 | + | keys := []string{"REDIS_PASSWORD"} | |||
| 238 | + | defer CleanupPassAttachment(t, containerName, keys) | |||
| 239 | + | ||||
| 240 | + | err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "first"}) | |||
| 241 | + | require.NoError(t, err) | |||
| 242 | + | ||||
| 243 | + | // Re-setting same key with a different value should deterministically overwrite. | |||
| 244 | + | err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "second"}) | |||
| 245 | + | require.NoError(t, err) | |||
| 246 | + | ||||
| 247 | + | values, err := store.GetAllAttachment(containerName) | |||
| 248 | + | require.NoError(t, err) | |||
| 249 | + | assert.Equal(t, "second", values["REDIS_PASSWORD"]) | |||
| 250 | + | } | |||
| 251 | + | ||||
| 180 | func TestPassStore_ListKeys_RecoversOrphanedEntries(t *testing.T) { | 252 | func TestPassStore_ListKeys_RecoversOrphanedEntries(t *testing.T) { | ||
| 181 | requirePass(t) | 253 | requirePass(t) | ||
| 182 | 254 | ||||