fix: skip mutable sections in post-save verification to prevent false corruption errors
The post-save verifier compared ALL viper keys before/after write, causing 500 errors when AddRoute or RemoveRoute intentionally changed routes.* keys. Now only immutable config sections (server, auth, etc.) are verified. Also refactor bootstrap: extract RunE into helpers following runXxx(ctx, ..., out) convention, and use normalized resp.Image in next-step CLI message. Close #101
- commit
- 22312a8da6f1afe36c59c6242951335fdb15aa58
- author
- brice · · 2026-03-14 05:31 UTC
- committer
- brice · · 2026-03-14 05:35 UTC
- parent
- 7d7aff9
- signature
- unsigned
3 files · +189 -59
M internal/adapters/in/cli/bootstrap.go +74 -59
| @@ -1,7 +1,9 @@ | |||
| 1 | 1 | package cli | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 6 | + | "io" | |
| 5 | 7 | "sort" | |
| 6 | 8 | "strings" | |
| 7 | 9 | ||
| @@ -21,66 +23,11 @@ func newBootstrapCmd() *cobra.Command { | |||
| 21 | 23 | Short: "Create a route, attachments, and secrets together", | |
| 22 | 24 | Args: cobra.ExactArgs(2), | |
| 23 | 25 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 24 | - | ctx := cmd.Context() | |
| 25 | - | req := dto.BootstrapRequest{ | |
| 26 | - | Domain: args[0], | |
| 27 | - | Image: args[1], | |
| 28 | - | Attachments: append([]string(nil), attachments...), | |
| 29 | - | Env: map[string]string{}, | |
| 30 | - | AttachmentEnv: map[string]map[string]string{}, | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | for _, pair := range envPairs { | |
| 34 | - | parts := strings.SplitN(pair, "=", 2) | |
| 35 | - | if len(parts) != 2 { | |
| 36 | - | return fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair) | |
| 37 | - | } | |
| 38 | - | req.Env[parts[0]] = parts[1] | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | for _, pair := range attachmentEnvPairs { | |
| 42 | - | serviceAndKV := strings.SplitN(pair, ":", 2) | |
| 43 | - | if len(serviceAndKV) != 2 { | |
| 44 | - | return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair) | |
| 45 | - | } | |
| 46 | - | keyValue := strings.SplitN(serviceAndKV[1], "=", 2) | |
| 47 | - | if len(keyValue) != 2 { | |
| 48 | - | return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair) | |
| 49 | - | } | |
| 50 | - | service := serviceAndKV[0] | |
| 51 | - | if req.AttachmentEnv[service] == nil { | |
| 52 | - | req.AttachmentEnv[service] = map[string]string{} | |
| 53 | - | } | |
| 54 | - | req.AttachmentEnv[service][keyValue[0]] = keyValue[1] | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | if len(req.Env) == 0 { | |
| 58 | - | req.Env = nil | |
| 59 | - | } | |
| 60 | - | if len(req.AttachmentEnv) == 0 { | |
| 61 | - | req.AttachmentEnv = nil | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | handle, err := resolveControlPlane(configPath) | |
| 26 | + | req, err := parseBootstrapRequest(args, attachments, envPairs, attachmentEnvPairs) | |
| 65 | 27 | if err != nil { | |
| 66 | 28 | return err | |
| 67 | 29 | } | |
| 68 | - | defer handle.close() | |
| 69 | - | ||
| 70 | - | resp, err := handle.plane.Bootstrap(ctx, req) | |
| 71 | - | if resp != nil { | |
| 72 | - | if writeErr := printBootstrapSummary(cmd, resp); writeErr != nil { | |
| 73 | - | return writeErr | |
| 74 | - | } | |
| 75 | - | } | |
| 76 | - | if err != nil { | |
| 77 | - | return fmt.Errorf("failed to bootstrap: %w", err) | |
| 78 | - | } | |
| 79 | - | out := cmd.OutOrStdout() | |
| 80 | - | if err := cliWriteLine(out, cliRenderSuccess("Bootstrap complete")); err != nil { | |
| 81 | - | return err | |
| 82 | - | } | |
| 83 | - | return cliWriteLine(out, fmt.Sprintf("Next: gordon push %s --build", req.Image)) | |
| 30 | + | return runBootstrap(cmd.Context(), req, configPath, cmd.OutOrStdout()) | |
| 84 | 31 | }, | |
| 85 | 32 | } | |
| 86 | 33 | ||
| @@ -92,8 +39,76 @@ func newBootstrapCmd() *cobra.Command { | |||
| 92 | 39 | return cmd | |
| 93 | 40 | } | |
| 94 | 41 | ||
| 95 | - | func printBootstrapSummary(cmd *cobra.Command, resp *dto.BootstrapResponse) error { | |
| 96 | - | out := cmd.OutOrStdout() | |
| 42 | + | func parseBootstrapRequest(args []string, attachments, envPairs, attachmentEnvPairs []string) (dto.BootstrapRequest, error) { | |
| 43 | + | req := dto.BootstrapRequest{ | |
| 44 | + | Domain: args[0], | |
| 45 | + | Image: args[1], | |
| 46 | + | Attachments: append([]string(nil), attachments...), | |
| 47 | + | Env: map[string]string{}, | |
| 48 | + | AttachmentEnv: map[string]map[string]string{}, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | for _, pair := range envPairs { | |
| 52 | + | parts := strings.SplitN(pair, "=", 2) | |
| 53 | + | if len(parts) != 2 { | |
| 54 | + | return dto.BootstrapRequest{}, fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair) | |
| 55 | + | } | |
| 56 | + | req.Env[parts[0]] = parts[1] | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | for _, pair := range attachmentEnvPairs { | |
| 60 | + | serviceAndKV := strings.SplitN(pair, ":", 2) | |
| 61 | + | if len(serviceAndKV) != 2 { | |
| 62 | + | return dto.BootstrapRequest{}, fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair) | |
| 63 | + | } | |
| 64 | + | keyValue := strings.SplitN(serviceAndKV[1], "=", 2) | |
| 65 | + | if len(keyValue) != 2 { | |
| 66 | + | return dto.BootstrapRequest{}, fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair) | |
| 67 | + | } | |
| 68 | + | service := serviceAndKV[0] | |
| 69 | + | if req.AttachmentEnv[service] == nil { | |
| 70 | + | req.AttachmentEnv[service] = map[string]string{} | |
| 71 | + | } | |
| 72 | + | req.AttachmentEnv[service][keyValue[0]] = keyValue[1] | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | if len(req.Env) == 0 { | |
| 76 | + | req.Env = nil | |
| 77 | + | } | |
| 78 | + | if len(req.AttachmentEnv) == 0 { | |
| 79 | + | req.AttachmentEnv = nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | return req, nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func runBootstrap(ctx context.Context, req dto.BootstrapRequest, configPath string, out io.Writer) error { | |
| 86 | + | handle, err := resolveControlPlane(configPath) | |
| 87 | + | if err != nil { | |
| 88 | + | return err | |
| 89 | + | } | |
| 90 | + | defer handle.close() | |
| 91 | + | ||
| 92 | + | resp, err := handle.plane.Bootstrap(ctx, req) | |
| 93 | + | if resp != nil { | |
| 94 | + | if writeErr := printBootstrapSummary(out, resp); writeErr != nil { | |
| 95 | + | return writeErr | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | if err != nil { | |
| 99 | + | return fmt.Errorf("failed to bootstrap: %w", err) | |
| 100 | + | } | |
| 101 | + | if err := cliWriteLine(out, cliRenderSuccess("Bootstrap complete")); err != nil { | |
| 102 | + | return err | |
| 103 | + | } | |
| 104 | + | nextImage := req.Image | |
| 105 | + | if resp != nil && resp.Image != "" { | |
| 106 | + | nextImage = resp.Image | |
| 107 | + | } | |
| 108 | + | return cliWriteLine(out, fmt.Sprintf("Next: gordon push %s --build", nextImage)) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func printBootstrapSummary(out io.Writer, resp *dto.BootstrapResponse) error { | |
| 97 | 112 | if err := cliWriteLine(out, cliRenderTitle("Bootstrap")); err != nil { | |
| 98 | 113 | return err | |
| 99 | 114 | } | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package cli | 1 | package cli | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 6 | + | "io" | |||
| 5 | "sort" | 7 | "sort" | ||
| 6 | "strings" | 8 | "strings" | ||
| 7 | 9 | ||||
| @@ -21,66 +23,11 @@ func newBootstrapCmd() *cobra.Command { | |||||
| 21 | Short: "Create a route, attachments, and secrets together", | 23 | Short: "Create a route, attachments, and secrets together", | ||
| 22 | Args: cobra.ExactArgs(2), | 24 | Args: cobra.ExactArgs(2), | ||
| 23 | RunE: func(cmd *cobra.Command, args []string) error { | 25 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 24 | - | ctx := cmd.Context() | 26 | + | req, err := parseBootstrapRequest(args, attachments, envPairs, attachmentEnvPairs) |
| 25 | - | req := dto.BootstrapRequest{ | |||
| 26 | - | Domain: args[0], | |||
| 27 | - | Image: args[1], | |||
| 28 | - | Attachments: append([]string(nil), attachments...), | |||
| 29 | - | Env: map[string]string{}, | |||
| 30 | - | AttachmentEnv: map[string]map[string]string{}, | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | for _, pair := range envPairs { | |||
| 34 | - | parts := strings.SplitN(pair, "=", 2) | |||
| 35 | - | if len(parts) != 2 { | |||
| 36 | - | return fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair) | |||
| 37 | - | } | |||
| 38 | - | req.Env[parts[0]] = parts[1] | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | for _, pair := range attachmentEnvPairs { | |||
| 42 | - | serviceAndKV := strings.SplitN(pair, ":", 2) | |||
| 43 | - | if len(serviceAndKV) != 2 { | |||
| 44 | - | return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair) | |||
| 45 | - | } | |||
| 46 | - | keyValue := strings.SplitN(serviceAndKV[1], "=", 2) | |||
| 47 | - | if len(keyValue) != 2 { | |||
| 48 | - | return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair) | |||
| 49 | - | } | |||
| 50 | - | service := serviceAndKV[0] | |||
| 51 | - | if req.AttachmentEnv[service] == nil { | |||
| 52 | - | req.AttachmentEnv[service] = map[string]string{} | |||
| 53 | - | } | |||
| 54 | - | req.AttachmentEnv[service][keyValue[0]] = keyValue[1] | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | if len(req.Env) == 0 { | |||
| 58 | - | req.Env = nil | |||
| 59 | - | } | |||
| 60 | - | if len(req.AttachmentEnv) == 0 { | |||
| 61 | - | req.AttachmentEnv = nil | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | handle, err := resolveControlPlane(configPath) | |||
| 65 | if err != nil { | 27 | if err != nil { | ||
| 66 | return err | 28 | return err | ||
| 67 | } | 29 | } | ||
| 68 | - | defer handle.close() | 30 | + | return runBootstrap(cmd.Context(), req, configPath, cmd.OutOrStdout()) |
| 69 | - | ||||
| 70 | - | resp, err := handle.plane.Bootstrap(ctx, req) | |||
| 71 | - | if resp != nil { | |||
| 72 | - | if writeErr := printBootstrapSummary(cmd, resp); writeErr != nil { | |||
| 73 | - | return writeErr | |||
| 74 | - | } | |||
| 75 | - | } | |||
| 76 | - | if err != nil { | |||
| 77 | - | return fmt.Errorf("failed to bootstrap: %w", err) | |||
| 78 | - | } | |||
| 79 | - | out := cmd.OutOrStdout() | |||
| 80 | - | if err := cliWriteLine(out, cliRenderSuccess("Bootstrap complete")); err != nil { | |||
| 81 | - | return err | |||
| 82 | - | } | |||
| 83 | - | return cliWriteLine(out, fmt.Sprintf("Next: gordon push %s --build", req.Image)) | |||
| 84 | }, | 31 | }, | ||
| 85 | } | 32 | } | ||
| 86 | 33 | ||||
| @@ -92,8 +39,76 @@ func newBootstrapCmd() *cobra.Command { | |||||
| 92 | return cmd | 39 | return cmd | ||
| 93 | } | 40 | } | ||
| 94 | 41 | ||||
| 95 | - | func printBootstrapSummary(cmd *cobra.Command, resp *dto.BootstrapResponse) error { | 42 | + | func parseBootstrapRequest(args []string, attachments, envPairs, attachmentEnvPairs []string) (dto.BootstrapRequest, error) { |
| 96 | - | out := cmd.OutOrStdout() | 43 | + | req := dto.BootstrapRequest{ |
| 44 | + | Domain: args[0], | |||
| 45 | + | Image: args[1], | |||
| 46 | + | Attachments: append([]string(nil), attachments...), | |||
| 47 | + | Env: map[string]string{}, | |||
| 48 | + | AttachmentEnv: map[string]map[string]string{}, | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | for _, pair := range envPairs { | |||
| 52 | + | parts := strings.SplitN(pair, "=", 2) | |||
| 53 | + | if len(parts) != 2 { | |||
| 54 | + | return dto.BootstrapRequest{}, fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair) | |||
| 55 | + | } | |||
| 56 | + | req.Env[parts[0]] = parts[1] | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | for _, pair := range attachmentEnvPairs { | |||
| 60 | + | serviceAndKV := strings.SplitN(pair, ":", 2) | |||
| 61 | + | if len(serviceAndKV) != 2 { | |||
| 62 | + | return dto.BootstrapRequest{}, fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair) | |||
| 63 | + | } | |||
| 64 | + | keyValue := strings.SplitN(serviceAndKV[1], "=", 2) | |||
| 65 | + | if len(keyValue) != 2 { | |||
| 66 | + | return dto.BootstrapRequest{}, fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair) | |||
| 67 | + | } | |||
| 68 | + | service := serviceAndKV[0] | |||
| 69 | + | if req.AttachmentEnv[service] == nil { | |||
| 70 | + | req.AttachmentEnv[service] = map[string]string{} | |||
| 71 | + | } | |||
| 72 | + | req.AttachmentEnv[service][keyValue[0]] = keyValue[1] | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | if len(req.Env) == 0 { | |||
| 76 | + | req.Env = nil | |||
| 77 | + | } | |||
| 78 | + | if len(req.AttachmentEnv) == 0 { | |||
| 79 | + | req.AttachmentEnv = nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | return req, nil | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func runBootstrap(ctx context.Context, req dto.BootstrapRequest, configPath string, out io.Writer) error { | |||
| 86 | + | handle, err := resolveControlPlane(configPath) | |||
| 87 | + | if err != nil { | |||
| 88 | + | return err | |||
| 89 | + | } | |||
| 90 | + | defer handle.close() | |||
| 91 | + | ||||
| 92 | + | resp, err := handle.plane.Bootstrap(ctx, req) | |||
| 93 | + | if resp != nil { | |||
| 94 | + | if writeErr := printBootstrapSummary(out, resp); writeErr != nil { | |||
| 95 | + | return writeErr | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | if err != nil { | |||
| 99 | + | return fmt.Errorf("failed to bootstrap: %w", err) | |||
| 100 | + | } | |||
| 101 | + | if err := cliWriteLine(out, cliRenderSuccess("Bootstrap complete")); err != nil { | |||
| 102 | + | return err | |||
| 103 | + | } | |||
| 104 | + | nextImage := req.Image | |||
| 105 | + | if resp != nil && resp.Image != "" { | |||
| 106 | + | nextImage = resp.Image | |||
| 107 | + | } | |||
| 108 | + | return cliWriteLine(out, fmt.Sprintf("Next: gordon push %s --build", nextImage)) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func printBootstrapSummary(out io.Writer, resp *dto.BootstrapResponse) error { | |||
| 97 | if err := cliWriteLine(out, cliRenderTitle("Bootstrap")); err != nil { | 112 | if err := cliWriteLine(out, cliRenderTitle("Bootstrap")); err != nil { | ||
| 98 | return err | 113 | return err | ||
| 99 | } | 114 | } | ||
M internal/usecase/config/service.go +21 -0
| @@ -496,9 +496,30 @@ type configSnapshot struct { | |||
| 496 | 496 | values map[string]any | |
| 497 | 497 | } | |
| 498 | 498 | ||
| 499 | + | // mutableSections lists config sections that are intentionally modified by service operations. | |
| 500 | + | var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups."} | |
| 501 | + | ||
| 502 | + | func isMutableKey(key string) bool { | |
| 503 | + | for _, prefix := range mutableSections { | |
| 504 | + | if strings.HasPrefix(key, prefix) { | |
| 505 | + | return true | |
| 506 | + | } | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | switch key { | |
| 510 | + | case "routes", "external_routes", "attachments", "network_groups": | |
| 511 | + | return true | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | return false | |
| 515 | + | } | |
| 516 | + | ||
| 499 | 517 | func (s *Service) snapshotCriticalFields() configSnapshot { | |
| 500 | 518 | snap := configSnapshot{values: make(map[string]any)} | |
| 501 | 519 | for _, key := range s.viper.AllKeys() { | |
| 520 | + | if isMutableKey(key) { | |
| 521 | + | continue | |
| 522 | + | } | |
| 502 | 523 | snap.values[key] = s.viper.Get(key) | |
| 503 | 524 | } | |
| 504 | 525 | return snap | |
| @@ -496,9 +496,30 @@ type configSnapshot struct { | |||||
| 496 | values map[string]any | 496 | values map[string]any | ||
| 497 | } | 497 | } | ||
| 498 | 498 | ||||
| 499 | + | // mutableSections lists config sections that are intentionally modified by service operations. | |||
| 500 | + | var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups."} | |||
| 501 | + | ||||
| 502 | + | func isMutableKey(key string) bool { | |||
| 503 | + | for _, prefix := range mutableSections { | |||
| 504 | + | if strings.HasPrefix(key, prefix) { | |||
| 505 | + | return true | |||
| 506 | + | } | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | switch key { | |||
| 510 | + | case "routes", "external_routes", "attachments", "network_groups": | |||
| 511 | + | return true | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | return false | |||
| 515 | + | } | |||
| 516 | + | ||||
| 499 | func (s *Service) snapshotCriticalFields() configSnapshot { | 517 | func (s *Service) snapshotCriticalFields() configSnapshot { | ||
| 500 | snap := configSnapshot{values: make(map[string]any)} | 518 | snap := configSnapshot{values: make(map[string]any)} | ||
| 501 | for _, key := range s.viper.AllKeys() { | 519 | for _, key := range s.viper.AllKeys() { | ||
| 520 | + | if isMutableKey(key) { | |||
| 521 | + | continue | |||
| 522 | + | } | |||
| 502 | snap.values[key] = s.viper.Get(key) | 523 | snap.values[key] = s.viper.Get(key) | ||
| 503 | } | 524 | } | ||
| 504 | return snap | 525 | return snap | ||
M internal/usecase/config/service_test.go +94 -0
| @@ -1285,6 +1285,100 @@ port = 9999 | |||
| 1285 | 1285 | assert.NotContains(t, string(backupData), "new.example.com") | |
| 1286 | 1286 | }) | |
| 1287 | 1287 | ||
| 1288 | + | t.Run("AddRoute updates existing dotted domain route", func(t *testing.T) { | |
| 1289 | + | tmpDir := t.TempDir() | |
| 1290 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| 1291 | + | initialConfig := ` | |
| 1292 | + | [server] | |
| 1293 | + | port = 8088 | |
| 1294 | + | ||
| 1295 | + | [auth] | |
| 1296 | + | username = "testadmin" | |
| 1297 | + | token_secret = "testsecret123" | |
| 1298 | + | ||
| 1299 | + | [routes] | |
| 1300 | + | "pitlane.example.com" = "pitlane:latest" | |
| 1301 | + | ` | |
| 1302 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |
| 1303 | + | require.NoError(t, err) | |
| 1304 | + | ||
| 1305 | + | v := viper.New() | |
| 1306 | + | v.SetConfigFile(configFile) | |
| 1307 | + | err = v.ReadInConfig() | |
| 1308 | + | require.NoError(t, err) | |
| 1309 | + | ||
| 1310 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1311 | + | svc := NewService(v, eventBus) | |
| 1312 | + | ctx := testContext() | |
| 1313 | + | err = svc.Load(ctx) | |
| 1314 | + | require.NoError(t, err) | |
| 1315 | + | ||
| 1316 | + | err = svc.AddRoute(ctx, domain.Route{ | |
| 1317 | + | Domain: "pitlane.example.com", | |
| 1318 | + | Image: "reg.example.com/pitlane:latest", | |
| 1319 | + | }) | |
| 1320 | + | require.NoError(t, err, "AddRoute should succeed when updating an existing dotted-domain route") | |
| 1321 | + | ||
| 1322 | + | routes := svc.GetRoutes(ctx) | |
| 1323 | + | require.Len(t, routes, 1) | |
| 1324 | + | assert.Equal(t, "pitlane.example.com", routes[0].Domain) | |
| 1325 | + | assert.Equal(t, "reg.example.com/pitlane:latest", routes[0].Image) | |
| 1326 | + | ||
| 1327 | + | v2 := viper.New() | |
| 1328 | + | v2.SetConfigFile(configFile) | |
| 1329 | + | err = v2.ReadInConfig() | |
| 1330 | + | require.NoError(t, err) | |
| 1331 | + | assert.Equal(t, 8088, v2.GetInt("server.port")) | |
| 1332 | + | assert.Equal(t, "testadmin", v2.GetString("auth.username")) | |
| 1333 | + | assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret")) | |
| 1334 | + | }) | |
| 1335 | + | ||
| 1336 | + | t.Run("RemoveRoute removes dotted domain route", func(t *testing.T) { | |
| 1337 | + | tmpDir := t.TempDir() | |
| 1338 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| 1339 | + | initialConfig := ` | |
| 1340 | + | [server] | |
| 1341 | + | port = 8088 | |
| 1342 | + | ||
| 1343 | + | [auth] | |
| 1344 | + | username = "testadmin" | |
| 1345 | + | token_secret = "testsecret123" | |
| 1346 | + | ||
| 1347 | + | [routes] | |
| 1348 | + | "pitlane.example.com" = "reg.example.com/pitlane:latest" | |
| 1349 | + | "other.example.com" = "reg.example.com/other:latest" | |
| 1350 | + | ` | |
| 1351 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |
| 1352 | + | require.NoError(t, err) | |
| 1353 | + | ||
| 1354 | + | v := viper.New() | |
| 1355 | + | v.SetConfigFile(configFile) | |
| 1356 | + | err = v.ReadInConfig() | |
| 1357 | + | require.NoError(t, err) | |
| 1358 | + | ||
| 1359 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1360 | + | svc := NewService(v, eventBus) | |
| 1361 | + | ctx := testContext() | |
| 1362 | + | err = svc.Load(ctx) | |
| 1363 | + | require.NoError(t, err) | |
| 1364 | + | ||
| 1365 | + | err = svc.RemoveRoute(ctx, "pitlane.example.com") | |
| 1366 | + | require.NoError(t, err, "RemoveRoute should succeed for a dotted-domain route") | |
| 1367 | + | ||
| 1368 | + | routes := svc.GetRoutes(ctx) | |
| 1369 | + | require.Len(t, routes, 1) | |
| 1370 | + | assert.Equal(t, "other.example.com", routes[0].Domain, "other routes should be preserved") | |
| 1371 | + | assert.Equal(t, "reg.example.com/other:latest", routes[0].Image, "other routes should be preserved") | |
| 1372 | + | ||
| 1373 | + | v2 := viper.New() | |
| 1374 | + | v2.SetConfigFile(configFile) | |
| 1375 | + | err = v2.ReadInConfig() | |
| 1376 | + | require.NoError(t, err) | |
| 1377 | + | assert.Equal(t, 8088, v2.GetInt("server.port")) | |
| 1378 | + | assert.Equal(t, "testadmin", v2.GetString("auth.username")) | |
| 1379 | + | assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret")) | |
| 1380 | + | }) | |
| 1381 | + | ||
| 1288 | 1382 | t.Run("network_groups are persisted to disk", func(t *testing.T) { | |
| 1289 | 1383 | // Setup: config file with auth, server, and network_groups | |
| 1290 | 1384 | tmpDir := t.TempDir() | |
| @@ -1285,6 +1285,100 @@ port = 9999 | |||||
| 1285 | assert.NotContains(t, string(backupData), "new.example.com") | 1285 | assert.NotContains(t, string(backupData), "new.example.com") | ||
| 1286 | }) | 1286 | }) | ||
| 1287 | 1287 | ||||
| 1288 | + | t.Run("AddRoute updates existing dotted domain route", func(t *testing.T) { | |||
| 1289 | + | tmpDir := t.TempDir() | |||
| 1290 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 1291 | + | initialConfig := ` | |||
| 1292 | + | [server] | |||
| 1293 | + | port = 8088 | |||
| 1294 | + | ||||
| 1295 | + | [auth] | |||
| 1296 | + | username = "testadmin" | |||
| 1297 | + | token_secret = "testsecret123" | |||
| 1298 | + | ||||
| 1299 | + | [routes] | |||
| 1300 | + | "pitlane.example.com" = "pitlane:latest" | |||
| 1301 | + | ` | |||
| 1302 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |||
| 1303 | + | require.NoError(t, err) | |||
| 1304 | + | ||||
| 1305 | + | v := viper.New() | |||
| 1306 | + | v.SetConfigFile(configFile) | |||
| 1307 | + | err = v.ReadInConfig() | |||
| 1308 | + | require.NoError(t, err) | |||
| 1309 | + | ||||
| 1310 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1311 | + | svc := NewService(v, eventBus) | |||
| 1312 | + | ctx := testContext() | |||
| 1313 | + | err = svc.Load(ctx) | |||
| 1314 | + | require.NoError(t, err) | |||
| 1315 | + | ||||
| 1316 | + | err = svc.AddRoute(ctx, domain.Route{ | |||
| 1317 | + | Domain: "pitlane.example.com", | |||
| 1318 | + | Image: "reg.example.com/pitlane:latest", | |||
| 1319 | + | }) | |||
| 1320 | + | require.NoError(t, err, "AddRoute should succeed when updating an existing dotted-domain route") | |||
| 1321 | + | ||||
| 1322 | + | routes := svc.GetRoutes(ctx) | |||
| 1323 | + | require.Len(t, routes, 1) | |||
| 1324 | + | assert.Equal(t, "pitlane.example.com", routes[0].Domain) | |||
| 1325 | + | assert.Equal(t, "reg.example.com/pitlane:latest", routes[0].Image) | |||
| 1326 | + | ||||
| 1327 | + | v2 := viper.New() | |||
| 1328 | + | v2.SetConfigFile(configFile) | |||
| 1329 | + | err = v2.ReadInConfig() | |||
| 1330 | + | require.NoError(t, err) | |||
| 1331 | + | assert.Equal(t, 8088, v2.GetInt("server.port")) | |||
| 1332 | + | assert.Equal(t, "testadmin", v2.GetString("auth.username")) | |||
| 1333 | + | assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret")) | |||
| 1334 | + | }) | |||
| 1335 | + | ||||
| 1336 | + | t.Run("RemoveRoute removes dotted domain route", func(t *testing.T) { | |||
| 1337 | + | tmpDir := t.TempDir() | |||
| 1338 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 1339 | + | initialConfig := ` | |||
| 1340 | + | [server] | |||
| 1341 | + | port = 8088 | |||
| 1342 | + | ||||
| 1343 | + | [auth] | |||
| 1344 | + | username = "testadmin" | |||
| 1345 | + | token_secret = "testsecret123" | |||
| 1346 | + | ||||
| 1347 | + | [routes] | |||
| 1348 | + | "pitlane.example.com" = "reg.example.com/pitlane:latest" | |||
| 1349 | + | "other.example.com" = "reg.example.com/other:latest" | |||
| 1350 | + | ` | |||
| 1351 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |||
| 1352 | + | require.NoError(t, err) | |||
| 1353 | + | ||||
| 1354 | + | v := viper.New() | |||
| 1355 | + | v.SetConfigFile(configFile) | |||
| 1356 | + | err = v.ReadInConfig() | |||
| 1357 | + | require.NoError(t, err) | |||
| 1358 | + | ||||
| 1359 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1360 | + | svc := NewService(v, eventBus) | |||
| 1361 | + | ctx := testContext() | |||
| 1362 | + | err = svc.Load(ctx) | |||
| 1363 | + | require.NoError(t, err) | |||
| 1364 | + | ||||
| 1365 | + | err = svc.RemoveRoute(ctx, "pitlane.example.com") | |||
| 1366 | + | require.NoError(t, err, "RemoveRoute should succeed for a dotted-domain route") | |||
| 1367 | + | ||||
| 1368 | + | routes := svc.GetRoutes(ctx) | |||
| 1369 | + | require.Len(t, routes, 1) | |||
| 1370 | + | assert.Equal(t, "other.example.com", routes[0].Domain, "other routes should be preserved") | |||
| 1371 | + | assert.Equal(t, "reg.example.com/other:latest", routes[0].Image, "other routes should be preserved") | |||
| 1372 | + | ||||
| 1373 | + | v2 := viper.New() | |||
| 1374 | + | v2.SetConfigFile(configFile) | |||
| 1375 | + | err = v2.ReadInConfig() | |||
| 1376 | + | require.NoError(t, err) | |||
| 1377 | + | assert.Equal(t, 8088, v2.GetInt("server.port")) | |||
| 1378 | + | assert.Equal(t, "testadmin", v2.GetString("auth.username")) | |||
| 1379 | + | assert.Equal(t, "testsecret123", v2.GetString("auth.token_secret")) | |||
| 1380 | + | }) | |||
| 1381 | + | ||||
| 1288 | t.Run("network_groups are persisted to disk", func(t *testing.T) { | 1382 | t.Run("network_groups are persisted to disk", func(t *testing.T) { | ||
| 1289 | // Setup: config file with auth, server, and network_groups | 1383 | // Setup: config file with auth, server, and network_groups | ||
| 1290 | tmpDir := t.TempDir() | 1384 | tmpDir := t.TempDir() | ||