Merge pull request #202 from bnema/fix/cleanup-diagnostics-lifecycle
fix(cli): tighten cleanup diagnose and remote error handling
7 files · +369 -8
M internal/adapters/dto/admin_config.go +8 -0
| @@ -5,6 +5,7 @@ type ConfigResponse struct { | |||
| 5 | 5 | Server ServerConfig `json:"server"` | |
| 6 | 6 | AutoRoute AutoRouteConfig `json:"auto_route"` | |
| 7 | 7 | NetworkIsolation NetworkIsolationConfig `json:"network_isolation"` | |
| 8 | + | Volumes VolumesConfig `json:"volumes"` | |
| 8 | 9 | Routes []Route `json:"routes"` | |
| 9 | 10 | ExternalRoutes []ExternalRoute `json:"external_routes"` | |
| 10 | 11 | } | |
| @@ -28,6 +29,13 @@ type NetworkIsolationConfig struct { | |||
| 28 | 29 | Prefix string `json:"prefix"` | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 32 | + | // VolumesConfig represents volume settings relevant to runtime-managed volumes. | |
| 33 | + | type VolumesConfig struct { | |
| 34 | + | AutoCreate bool `json:"auto_create"` | |
| 35 | + | Prefix string `json:"prefix"` | |
| 36 | + | Preserve bool `json:"preserve"` | |
| 37 | + | } | |
| 38 | + | ||
| 31 | 39 | // ExternalRoute represents an external route config entry. | |
| 32 | 40 | type ExternalRoute struct { | |
| 33 | 41 | Domain string `json:"domain"` | |
| @@ -5,6 +5,7 @@ type ConfigResponse struct { | |||||
| 5 | Server ServerConfig `json:"server"` | 5 | Server ServerConfig `json:"server"` | ||
| 6 | AutoRoute AutoRouteConfig `json:"auto_route"` | 6 | AutoRoute AutoRouteConfig `json:"auto_route"` | ||
| 7 | NetworkIsolation NetworkIsolationConfig `json:"network_isolation"` | 7 | NetworkIsolation NetworkIsolationConfig `json:"network_isolation"` | ||
| 8 | + | Volumes VolumesConfig `json:"volumes"` | |||
| 8 | Routes []Route `json:"routes"` | 9 | Routes []Route `json:"routes"` | ||
| 9 | ExternalRoutes []ExternalRoute `json:"external_routes"` | 10 | ExternalRoutes []ExternalRoute `json:"external_routes"` | ||
| 10 | } | 11 | } | ||
| @@ -28,6 +29,13 @@ type NetworkIsolationConfig struct { | |||||
| 28 | Prefix string `json:"prefix"` | 29 | Prefix string `json:"prefix"` | ||
| 29 | } | 30 | } | ||
| 30 | 31 | ||||
| 32 | + | // VolumesConfig represents volume settings relevant to runtime-managed volumes. | |||
| 33 | + | type VolumesConfig struct { | |||
| 34 | + | AutoCreate bool `json:"auto_create"` | |||
| 35 | + | Prefix string `json:"prefix"` | |||
| 36 | + | Preserve bool `json:"preserve"` | |||
| 37 | + | } | |||
| 38 | + | ||||
| 31 | // ExternalRoute represents an external route config entry. | 39 | // ExternalRoute represents an external route config entry. | ||
| 32 | type ExternalRoute struct { | 40 | type ExternalRoute struct { | ||
| 33 | Domain string `json:"domain"` | 41 | Domain string `json:"domain"` | ||
M internal/adapters/in/cli/controlplane_local.go +3 -0
| @@ -452,6 +452,9 @@ func (l *localControlPlane) GetConfig(ctx context.Context) (*remote.Config, erro | |||
| 452 | 452 | cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled() | |
| 453 | 453 | cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled() | |
| 454 | 454 | cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix() | |
| 455 | + | if volumeCfg, ok := any(l.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok { | |
| 456 | + | cfg.Volumes.AutoCreate, cfg.Volumes.Prefix, cfg.Volumes.Preserve = volumeCfg.GetVolumeConfig() | |
| 457 | + | } | |
| 455 | 458 | return cfg, nil | |
| 456 | 459 | } | |
| 457 | 460 | ||
| @@ -452,6 +452,9 @@ func (l *localControlPlane) GetConfig(ctx context.Context) (*remote.Config, erro | |||||
| 452 | cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled() | 452 | cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled() | ||
| 453 | cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled() | 453 | cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled() | ||
| 454 | cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix() | 454 | cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix() | ||
| 455 | + | if volumeCfg, ok := any(l.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok { | |||
| 456 | + | cfg.Volumes.AutoCreate, cfg.Volumes.Prefix, cfg.Volumes.Preserve = volumeCfg.GetVolumeConfig() | |||
| 457 | + | } | |||
| 455 | return cfg, nil | 458 | return cfg, nil | ||
| 456 | } | 459 | } | ||
| 457 | 460 | ||||
M internal/adapters/in/cli/remote/client.go +9 -0
| @@ -504,6 +504,10 @@ func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Rout | |||
| 504 | 504 | ||
| 505 | 505 | var route domain.Route | |
| 506 | 506 | if err := parseResponse(resp, &route); err != nil { | |
| 507 | + | var httpErr *HTTPError | |
| 508 | + | if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { | |
| 509 | + | return nil, domain.ErrRouteNotFound | |
| 510 | + | } | |
| 507 | 511 | return nil, err | |
| 508 | 512 | } | |
| 509 | 513 | ||
| @@ -860,6 +864,11 @@ type Config struct { | |||
| 860 | 864 | Enabled bool `json:"enabled"` | |
| 861 | 865 | Prefix string `json:"prefix"` | |
| 862 | 866 | } `json:"network_isolation"` | |
| 867 | + | Volumes struct { | |
| 868 | + | AutoCreate bool `json:"auto_create"` | |
| 869 | + | Prefix string `json:"prefix"` | |
| 870 | + | Preserve bool `json:"preserve"` | |
| 871 | + | } `json:"volumes"` | |
| 863 | 872 | Routes []domain.Route `json:"routes"` | |
| 864 | 873 | ExternalRoutes []ExternalRoute `json:"external_routes"` | |
| 865 | 874 | } | |
| @@ -504,6 +504,10 @@ func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Rout | |||||
| 504 | 504 | ||||
| 505 | var route domain.Route | 505 | var route domain.Route | ||
| 506 | if err := parseResponse(resp, &route); err != nil { | 506 | if err := parseResponse(resp, &route); err != nil { | ||
| 507 | + | var httpErr *HTTPError | |||
| 508 | + | if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { | |||
| 509 | + | return nil, domain.ErrRouteNotFound | |||
| 510 | + | } | |||
| 507 | return nil, err | 511 | return nil, err | ||
| 508 | } | 512 | } | ||
| 509 | 513 | ||||
| @@ -860,6 +864,11 @@ type Config struct { | |||||
| 860 | Enabled bool `json:"enabled"` | 864 | Enabled bool `json:"enabled"` | ||
| 861 | Prefix string `json:"prefix"` | 865 | Prefix string `json:"prefix"` | ||
| 862 | } `json:"network_isolation"` | 866 | } `json:"network_isolation"` | ||
| 867 | + | Volumes struct { | |||
| 868 | + | AutoCreate bool `json:"auto_create"` | |||
| 869 | + | Prefix string `json:"prefix"` | |||
| 870 | + | Preserve bool `json:"preserve"` | |||
| 871 | + | } `json:"volumes"` | |||
| 863 | Routes []domain.Route `json:"routes"` | 872 | Routes []domain.Route `json:"routes"` | ||
| 864 | ExternalRoutes []ExternalRoute `json:"external_routes"` | 873 | ExternalRoutes []ExternalRoute `json:"external_routes"` | ||
| 865 | } | 874 | } | ||
M internal/adapters/in/cli/remote/client_test.go +17 -0
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "testing" | |
| 14 | 14 | "time" | |
| 15 | 15 | ||
| 16 | + | "github.com/bnema/gordon/internal/domain" | |
| 16 | 17 | "github.com/stretchr/testify/assert" | |
| 17 | 18 | "github.com/stretchr/testify/require" | |
| 18 | 19 | ||
| @@ -200,6 +201,22 @@ func TestClientFindAttachmentTargetsByImage(t *testing.T) { | |||
| 200 | 201 | assert.Equal(t, []string{"app.example.com", "workers"}, targets) | |
| 201 | 202 | } | |
| 202 | 203 | ||
| 204 | + | func TestClientGetRoute_Maps404ToDomainErrRouteNotFound(t *testing.T) { | |
| 205 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 206 | + | require.Equal(t, "/admin/routes/missing.example.test", r.URL.Path) | |
| 207 | + | require.Equal(t, http.MethodGet, r.Method) | |
| 208 | + | w.WriteHeader(http.StatusNotFound) | |
| 209 | + | _, _ = w.Write([]byte(`{"error":"route not found"}`)) | |
| 210 | + | })) | |
| 211 | + | defer srv.Close() | |
| 212 | + | ||
| 213 | + | client := NewClient(srv.URL) | |
| 214 | + | route, err := client.GetRoute(context.Background(), "missing.example.test") | |
| 215 | + | require.Nil(t, route) | |
| 216 | + | require.Error(t, err) | |
| 217 | + | assert.ErrorIs(t, err, domain.ErrRouteNotFound) | |
| 218 | + | } | |
| 219 | + | ||
| 203 | 220 | func TestClientFindAttachmentTargetsByImage_WithSlashContainingImage(t *testing.T) { | |
| 204 | 221 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 205 | 222 | require.Equal(t, "/admin/attachments/by-image/registry/org/image:tag", r.URL.Path) | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "testing" | 13 | "testing" | ||
| 14 | "time" | 14 | "time" | ||
| 15 | 15 | ||||
| 16 | + | "github.com/bnema/gordon/internal/domain" | |||
| 16 | "github.com/stretchr/testify/assert" | 17 | "github.com/stretchr/testify/assert" | ||
| 17 | "github.com/stretchr/testify/require" | 18 | "github.com/stretchr/testify/require" | ||
| 18 | 19 | ||||
| @@ -200,6 +201,22 @@ func TestClientFindAttachmentTargetsByImage(t *testing.T) { | |||||
| 200 | assert.Equal(t, []string{"app.example.com", "workers"}, targets) | 201 | assert.Equal(t, []string{"app.example.com", "workers"}, targets) | ||
| 201 | } | 202 | } | ||
| 202 | 203 | ||||
| 204 | + | func TestClientGetRoute_Maps404ToDomainErrRouteNotFound(t *testing.T) { | |||
| 205 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 206 | + | require.Equal(t, "/admin/routes/missing.example.test", r.URL.Path) | |||
| 207 | + | require.Equal(t, http.MethodGet, r.Method) | |||
| 208 | + | w.WriteHeader(http.StatusNotFound) | |||
| 209 | + | _, _ = w.Write([]byte(`{"error":"route not found"}`)) | |||
| 210 | + | })) | |||
| 211 | + | defer srv.Close() | |||
| 212 | + | ||||
| 213 | + | client := NewClient(srv.URL) | |||
| 214 | + | route, err := client.GetRoute(context.Background(), "missing.example.test") | |||
| 215 | + | require.Nil(t, route) | |||
| 216 | + | require.Error(t, err) | |||
| 217 | + | assert.ErrorIs(t, err, domain.ErrRouteNotFound) | |||
| 218 | + | } | |||
| 219 | + | ||||
| 203 | func TestClientFindAttachmentTargetsByImage_WithSlashContainingImage(t *testing.T) { | 220 | func TestClientFindAttachmentTargetsByImage_WithSlashContainingImage(t *testing.T) { | ||
| 204 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 221 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 205 | require.Equal(t, "/admin/attachments/by-image/registry/org/image:tag", r.URL.Path) | 222 | require.Equal(t, "/admin/attachments/by-image/registry/org/image:tag", r.URL.Path) | ||
M internal/adapters/in/cli/routes.go +107 -8
| @@ -1125,8 +1125,8 @@ func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string | |||
| 1125 | 1125 | loadRouteDiagnosisConfig(ctx, cp, domainName, diag) | |
| 1126 | 1126 | loadRouteDiagnosisRuntime(ctx, cp, domainName, diag) | |
| 1127 | 1127 | loadRouteDiagnosisHealth(ctx, cp, domainName, diag) | |
| 1128 | - | loadRouteDiagnosisVolumes(ctx, cp, domainName, diag) | |
| 1129 | 1128 | loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag) | |
| 1129 | + | loadRouteDiagnosisVolumes(ctx, cp, domainName, diag) | |
| 1130 | 1130 | finalizeRouteDiagnosis(diag) | |
| 1131 | 1131 | return diag, nil | |
| 1132 | 1132 | } | |
| @@ -1166,8 +1166,21 @@ func loadRouteDiagnosisHealth(ctx context.Context, cp ControlPlane, domainName s | |||
| 1166 | 1166 | ||
| 1167 | 1167 | func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | |
| 1168 | 1168 | if volumes, err := cp.ListVolumes(ctx); err == nil { | |
| 1169 | - | diag.Volumes = volumesForRouteDiagnosis(volumes, domainName) | |
| 1169 | + | if len(volumes) == 0 { | |
| 1170 | + | return | |
| 1171 | + | } | |
| 1172 | + | scope := newRouteResourceScope(domainName, routeDiagnosisVolumePrefix(ctx, cp), diag.Runtime, diag.OrphanedAttachments) | |
| 1173 | + | diag.Volumes = volumesForRouteDiagnosis(volumes, scope) | |
| 1174 | + | } | |
| 1175 | + | } | |
| 1176 | + | ||
| 1177 | + | func routeDiagnosisVolumePrefix(ctx context.Context, cp ControlPlane) string { | |
| 1178 | + | const defaultPrefix = "gordon" | |
| 1179 | + | cfg, err := cp.GetConfig(ctx) | |
| 1180 | + | if err != nil || cfg == nil || cfg.Volumes.Prefix == "" { | |
| 1181 | + | return defaultPrefix | |
| 1170 | 1182 | } | |
| 1183 | + | return cfg.Volumes.Prefix | |
| 1171 | 1184 | } | |
| 1172 | 1185 | ||
| 1173 | 1186 | func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | |
| @@ -1190,21 +1203,82 @@ func finalizeRouteDiagnosis(diag *routeDiagnosis) { | |||
| 1190 | 1203 | diag.Hints = append(diag.Hints, "persistent volumes are preserved by default") | |
| 1191 | 1204 | } | |
| 1192 | 1205 | if len(diag.OrphanedAttachments) > 0 { | |
| 1193 | - | diag.Hints = append(diag.Hints, "run 'gordon attachments prune --stop' to stop orphaned attachment containers while preserving volumes") | |
| 1206 | + | diag.Hints = append(diag.Hints, fmt.Sprintf("run 'gordon routes purge %s --attachments' to review orphaned attachment cleanup for this route", diag.Domain)) | |
| 1194 | 1207 | } | |
| 1195 | 1208 | } | |
| 1196 | 1209 | ||
| 1197 | - | func volumesForRouteDiagnosis(volumes []dto.Volume, domainName string) []dto.Volume { | |
| 1198 | - | needle := strings.ReplaceAll(strings.ToLower(domainName), ".", "-") | |
| 1210 | + | type routeResourceScope struct { | |
| 1211 | + | containerNames map[string]struct{} | |
| 1212 | + | volumePrefixes []string | |
| 1213 | + | } | |
| 1214 | + | ||
| 1215 | + | func newRouteResourceScope(domainName, volumePrefix string, runtime *remote.RouteInfo, orphaned []domain.CleanupAttachment) routeResourceScope { | |
| 1216 | + | if volumePrefix == "" { | |
| 1217 | + | volumePrefix = "gordon" | |
| 1218 | + | } | |
| 1219 | + | scope := routeResourceScope{ | |
| 1220 | + | containerNames: make(map[string]struct{}), | |
| 1221 | + | volumePrefixes: []string{volumePrefix + "-" + strings.ReplaceAll(domainName, ".", "-") + "-"}, | |
| 1222 | + | } | |
| 1223 | + | for _, name := range []string{ | |
| 1224 | + | fmt.Sprintf("gordon-%s", domainName), | |
| 1225 | + | fmt.Sprintf("gordon-%s-new", domainName), | |
| 1226 | + | fmt.Sprintf("gordon-%s-next", domainName), | |
| 1227 | + | } { | |
| 1228 | + | scope.containerNames[name] = struct{}{} | |
| 1229 | + | } | |
| 1230 | + | if runtime != nil { | |
| 1231 | + | for _, attachment := range runtime.Attachments { | |
| 1232 | + | for _, name := range routeAttachmentContainerNameCandidates(domainName, attachment.Name) { | |
| 1233 | + | scope.containerNames[name] = struct{}{} | |
| 1234 | + | scope.volumePrefixes = append(scope.volumePrefixes, volumePrefix+"-"+name+"-") | |
| 1235 | + | } | |
| 1236 | + | } | |
| 1237 | + | } | |
| 1238 | + | for _, attachment := range orphaned { | |
| 1239 | + | if attachment.Name == "" { | |
| 1240 | + | continue | |
| 1241 | + | } | |
| 1242 | + | scope.containerNames[attachment.Name] = struct{}{} | |
| 1243 | + | scope.volumePrefixes = append(scope.volumePrefixes, volumePrefix+"-"+attachment.Name+"-") | |
| 1244 | + | } | |
| 1245 | + | return scope | |
| 1246 | + | } | |
| 1247 | + | ||
| 1248 | + | func routeAttachmentContainerNameCandidates(domainName, serviceName string) []string { | |
| 1249 | + | if serviceName == "" { | |
| 1250 | + | return nil | |
| 1251 | + | } | |
| 1252 | + | return []string{ | |
| 1253 | + | fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainer(domainName), serviceName), | |
| 1254 | + | fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainerLegacy(domainName), serviceName), | |
| 1255 | + | } | |
| 1256 | + | } | |
| 1257 | + | ||
| 1258 | + | func volumesForRouteDiagnosis(volumes []dto.Volume, scope routeResourceScope) []dto.Volume { | |
| 1199 | 1259 | var out []dto.Volume | |
| 1200 | 1260 | for _, volume := range volumes { | |
| 1201 | - | if strings.Contains(strings.ToLower(volume.Name), needle) { | |
| 1261 | + | if scope.matchesVolume(volume) { | |
| 1202 | 1262 | out = append(out, volume) | |
| 1203 | 1263 | } | |
| 1204 | 1264 | } | |
| 1205 | 1265 | return out | |
| 1206 | 1266 | } | |
| 1207 | 1267 | ||
| 1268 | + | func (s routeResourceScope) matchesVolume(volume dto.Volume) bool { | |
| 1269 | + | for _, containerName := range volume.Containers { | |
| 1270 | + | if _, ok := s.containerNames[containerName]; ok { | |
| 1271 | + | return true | |
| 1272 | + | } | |
| 1273 | + | } | |
| 1274 | + | for _, prefix := range s.volumePrefixes { | |
| 1275 | + | if strings.HasPrefix(volume.Name, prefix) { | |
| 1276 | + | return true | |
| 1277 | + | } | |
| 1278 | + | } | |
| 1279 | + | return false | |
| 1280 | + | } | |
| 1281 | + | ||
| 1208 | 1282 | func attachmentsForRouteDiagnosis(attachments []domain.CleanupAttachment, domainName string) []domain.CleanupAttachment { | |
| 1209 | 1283 | var out []domain.CleanupAttachment | |
| 1210 | 1284 | for _, attachment := range attachments { | |
| @@ -1239,12 +1313,37 @@ func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error { | |||
| 1239 | 1313 | if diag.Runtime == nil { | |
| 1240 | 1314 | return nil | |
| 1241 | 1315 | } | |
| 1242 | - | return cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID))) | |
| 1316 | + | if err := cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID))); err != nil { | |
| 1317 | + | return err | |
| 1318 | + | } | |
| 1319 | + | if len(diag.Runtime.Attachments) == 0 { | |
| 1320 | + | return nil | |
| 1321 | + | } | |
| 1322 | + | if err := cliWriteLine(out, cliRenderMeta("Active attachments:", "")); err != nil { | |
| 1323 | + | return err | |
| 1324 | + | } | |
| 1325 | + | for _, attachment := range diag.Runtime.Attachments { | |
| 1326 | + | label := attachment.Name | |
| 1327 | + | if label == "" { | |
| 1328 | + | label = attachment.ContainerID | |
| 1329 | + | } | |
| 1330 | + | if attachment.Status != "" { | |
| 1331 | + | label = fmt.Sprintf("%s (%s)", label, attachment.Status) | |
| 1332 | + | } | |
| 1333 | + | if err := cliWriteLine(out, " "+label); err != nil { | |
| 1334 | + | return err | |
| 1335 | + | } | |
| 1336 | + | } | |
| 1337 | + | return nil | |
| 1243 | 1338 | } | |
| 1244 | 1339 | ||
| 1245 | 1340 | func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error { | |
| 1341 | + | volumeLabel := "Volume:" | |
| 1342 | + | if !diag.Configured { | |
| 1343 | + | volumeLabel = "Preserved volume:" | |
| 1344 | + | } | |
| 1246 | 1345 | for _, volume := range diag.Volumes { | |
| 1247 | - | if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil { | |
| 1346 | + | if err := cliWriteLine(out, cliRenderMeta(volumeLabel, volume.Name)); err != nil { | |
| 1248 | 1347 | return err | |
| 1249 | 1348 | } | |
| 1250 | 1349 | } | |
| @@ -1125,8 +1125,8 @@ func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string | |||||
| 1125 | loadRouteDiagnosisConfig(ctx, cp, domainName, diag) | 1125 | loadRouteDiagnosisConfig(ctx, cp, domainName, diag) | ||
| 1126 | loadRouteDiagnosisRuntime(ctx, cp, domainName, diag) | 1126 | loadRouteDiagnosisRuntime(ctx, cp, domainName, diag) | ||
| 1127 | loadRouteDiagnosisHealth(ctx, cp, domainName, diag) | 1127 | loadRouteDiagnosisHealth(ctx, cp, domainName, diag) | ||
| 1128 | - | loadRouteDiagnosisVolumes(ctx, cp, domainName, diag) | |||
| 1129 | loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag) | 1128 | loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag) | ||
| 1129 | + | loadRouteDiagnosisVolumes(ctx, cp, domainName, diag) | |||
| 1130 | finalizeRouteDiagnosis(diag) | 1130 | finalizeRouteDiagnosis(diag) | ||
| 1131 | return diag, nil | 1131 | return diag, nil | ||
| 1132 | } | 1132 | } | ||
| @@ -1166,8 +1166,21 @@ func loadRouteDiagnosisHealth(ctx context.Context, cp ControlPlane, domainName s | |||||
| 1166 | 1166 | ||||
| 1167 | func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | 1167 | func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | ||
| 1168 | if volumes, err := cp.ListVolumes(ctx); err == nil { | 1168 | if volumes, err := cp.ListVolumes(ctx); err == nil { | ||
| 1169 | - | diag.Volumes = volumesForRouteDiagnosis(volumes, domainName) | 1169 | + | if len(volumes) == 0 { |
| 1170 | + | return | |||
| 1171 | + | } | |||
| 1172 | + | scope := newRouteResourceScope(domainName, routeDiagnosisVolumePrefix(ctx, cp), diag.Runtime, diag.OrphanedAttachments) | |||
| 1173 | + | diag.Volumes = volumesForRouteDiagnosis(volumes, scope) | |||
| 1174 | + | } | |||
| 1175 | + | } | |||
| 1176 | + | ||||
| 1177 | + | func routeDiagnosisVolumePrefix(ctx context.Context, cp ControlPlane) string { | |||
| 1178 | + | const defaultPrefix = "gordon" | |||
| 1179 | + | cfg, err := cp.GetConfig(ctx) | |||
| 1180 | + | if err != nil || cfg == nil || cfg.Volumes.Prefix == "" { | |||
| 1181 | + | return defaultPrefix | |||
| 1170 | } | 1182 | } | ||
| 1183 | + | return cfg.Volumes.Prefix | |||
| 1171 | } | 1184 | } | ||
| 1172 | 1185 | ||||
| 1173 | func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | 1186 | func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) { | ||
| @@ -1190,21 +1203,82 @@ func finalizeRouteDiagnosis(diag *routeDiagnosis) { | |||||
| 1190 | diag.Hints = append(diag.Hints, "persistent volumes are preserved by default") | 1203 | diag.Hints = append(diag.Hints, "persistent volumes are preserved by default") | ||
| 1191 | } | 1204 | } | ||
| 1192 | if len(diag.OrphanedAttachments) > 0 { | 1205 | if len(diag.OrphanedAttachments) > 0 { | ||
| 1193 | - | diag.Hints = append(diag.Hints, "run 'gordon attachments prune --stop' to stop orphaned attachment containers while preserving volumes") | 1206 | + | diag.Hints = append(diag.Hints, fmt.Sprintf("run 'gordon routes purge %s --attachments' to review orphaned attachment cleanup for this route", diag.Domain)) |
| 1194 | } | 1207 | } | ||
| 1195 | } | 1208 | } | ||
| 1196 | 1209 | ||||
| 1197 | - | func volumesForRouteDiagnosis(volumes []dto.Volume, domainName string) []dto.Volume { | 1210 | + | type routeResourceScope struct { |
| 1198 | - | needle := strings.ReplaceAll(strings.ToLower(domainName), ".", "-") | 1211 | + | containerNames map[string]struct{} |
| 1212 | + | volumePrefixes []string | |||
| 1213 | + | } | |||
| 1214 | + | ||||
| 1215 | + | func newRouteResourceScope(domainName, volumePrefix string, runtime *remote.RouteInfo, orphaned []domain.CleanupAttachment) routeResourceScope { | |||
| 1216 | + | if volumePrefix == "" { | |||
| 1217 | + | volumePrefix = "gordon" | |||
| 1218 | + | } | |||
| 1219 | + | scope := routeResourceScope{ | |||
| 1220 | + | containerNames: make(map[string]struct{}), | |||
| 1221 | + | volumePrefixes: []string{volumePrefix + "-" + strings.ReplaceAll(domainName, ".", "-") + "-"}, | |||
| 1222 | + | } | |||
| 1223 | + | for _, name := range []string{ | |||
| 1224 | + | fmt.Sprintf("gordon-%s", domainName), | |||
| 1225 | + | fmt.Sprintf("gordon-%s-new", domainName), | |||
| 1226 | + | fmt.Sprintf("gordon-%s-next", domainName), | |||
| 1227 | + | } { | |||
| 1228 | + | scope.containerNames[name] = struct{}{} | |||
| 1229 | + | } | |||
| 1230 | + | if runtime != nil { | |||
| 1231 | + | for _, attachment := range runtime.Attachments { | |||
| 1232 | + | for _, name := range routeAttachmentContainerNameCandidates(domainName, attachment.Name) { | |||
| 1233 | + | scope.containerNames[name] = struct{}{} | |||
| 1234 | + | scope.volumePrefixes = append(scope.volumePrefixes, volumePrefix+"-"+name+"-") | |||
| 1235 | + | } | |||
| 1236 | + | } | |||
| 1237 | + | } | |||
| 1238 | + | for _, attachment := range orphaned { | |||
| 1239 | + | if attachment.Name == "" { | |||
| 1240 | + | continue | |||
| 1241 | + | } | |||
| 1242 | + | scope.containerNames[attachment.Name] = struct{}{} | |||
| 1243 | + | scope.volumePrefixes = append(scope.volumePrefixes, volumePrefix+"-"+attachment.Name+"-") | |||
| 1244 | + | } | |||
| 1245 | + | return scope | |||
| 1246 | + | } | |||
| 1247 | + | ||||
| 1248 | + | func routeAttachmentContainerNameCandidates(domainName, serviceName string) []string { | |||
| 1249 | + | if serviceName == "" { | |||
| 1250 | + | return nil | |||
| 1251 | + | } | |||
| 1252 | + | return []string{ | |||
| 1253 | + | fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainer(domainName), serviceName), | |||
| 1254 | + | fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainerLegacy(domainName), serviceName), | |||
| 1255 | + | } | |||
| 1256 | + | } | |||
| 1257 | + | ||||
| 1258 | + | func volumesForRouteDiagnosis(volumes []dto.Volume, scope routeResourceScope) []dto.Volume { | |||
| 1199 | var out []dto.Volume | 1259 | var out []dto.Volume | ||
| 1200 | for _, volume := range volumes { | 1260 | for _, volume := range volumes { | ||
| 1201 | - | if strings.Contains(strings.ToLower(volume.Name), needle) { | 1261 | + | if scope.matchesVolume(volume) { |
| 1202 | out = append(out, volume) | 1262 | out = append(out, volume) | ||
| 1203 | } | 1263 | } | ||
| 1204 | } | 1264 | } | ||
| 1205 | return out | 1265 | return out | ||
| 1206 | } | 1266 | } | ||
| 1207 | 1267 | ||||
| 1268 | + | func (s routeResourceScope) matchesVolume(volume dto.Volume) bool { | |||
| 1269 | + | for _, containerName := range volume.Containers { | |||
| 1270 | + | if _, ok := s.containerNames[containerName]; ok { | |||
| 1271 | + | return true | |||
| 1272 | + | } | |||
| 1273 | + | } | |||
| 1274 | + | for _, prefix := range s.volumePrefixes { | |||
| 1275 | + | if strings.HasPrefix(volume.Name, prefix) { | |||
| 1276 | + | return true | |||
| 1277 | + | } | |||
| 1278 | + | } | |||
| 1279 | + | return false | |||
| 1280 | + | } | |||
| 1281 | + | ||||
| 1208 | func attachmentsForRouteDiagnosis(attachments []domain.CleanupAttachment, domainName string) []domain.CleanupAttachment { | 1282 | func attachmentsForRouteDiagnosis(attachments []domain.CleanupAttachment, domainName string) []domain.CleanupAttachment { | ||
| 1209 | var out []domain.CleanupAttachment | 1283 | var out []domain.CleanupAttachment | ||
| 1210 | for _, attachment := range attachments { | 1284 | for _, attachment := range attachments { | ||
| @@ -1239,12 +1313,37 @@ func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error { | |||||
| 1239 | if diag.Runtime == nil { | 1313 | if diag.Runtime == nil { | ||
| 1240 | return nil | 1314 | return nil | ||
| 1241 | } | 1315 | } | ||
| 1242 | - | return cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID))) | 1316 | + | if err := cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID))); err != nil { |
| 1317 | + | return err | |||
| 1318 | + | } | |||
| 1319 | + | if len(diag.Runtime.Attachments) == 0 { | |||
| 1320 | + | return nil | |||
| 1321 | + | } | |||
| 1322 | + | if err := cliWriteLine(out, cliRenderMeta("Active attachments:", "")); err != nil { | |||
| 1323 | + | return err | |||
| 1324 | + | } | |||
| 1325 | + | for _, attachment := range diag.Runtime.Attachments { | |||
| 1326 | + | label := attachment.Name | |||
| 1327 | + | if label == "" { | |||
| 1328 | + | label = attachment.ContainerID | |||
| 1329 | + | } | |||
| 1330 | + | if attachment.Status != "" { | |||
| 1331 | + | label = fmt.Sprintf("%s (%s)", label, attachment.Status) | |||
| 1332 | + | } | |||
| 1333 | + | if err := cliWriteLine(out, " "+label); err != nil { | |||
| 1334 | + | return err | |||
| 1335 | + | } | |||
| 1336 | + | } | |||
| 1337 | + | return nil | |||
| 1243 | } | 1338 | } | ||
| 1244 | 1339 | ||||
| 1245 | func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error { | 1340 | func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error { | ||
| 1341 | + | volumeLabel := "Volume:" | |||
| 1342 | + | if !diag.Configured { | |||
| 1343 | + | volumeLabel = "Preserved volume:" | |||
| 1344 | + | } | |||
| 1246 | for _, volume := range diag.Volumes { | 1345 | for _, volume := range diag.Volumes { | ||
| 1247 | - | if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil { | 1346 | + | if err := cliWriteLine(out, cliRenderMeta(volumeLabel, volume.Name)); err != nil { |
| 1248 | return err | 1347 | return err | ||
| 1249 | } | 1348 | } | ||
| 1250 | } | 1349 | } | ||
M internal/adapters/in/cli/routes_test.go +222 -0
| @@ -6,6 +6,8 @@ import ( | |||
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "errors" | |
| 8 | 8 | "io" | |
| 9 | + | "net/http" | |
| 10 | + | "net/http/httptest" | |
| 9 | 11 | "os" | |
| 10 | 12 | "path/filepath" | |
| 11 | 13 | "strconv" | |
| @@ -899,6 +901,226 @@ func TestAttachmentsForRouteDiagnosisUsesOwner(t *testing.T) { | |||
| 899 | 901 | assert.Equal(t, "match", matched[0].ContainerID) | |
| 900 | 902 | } | |
| 901 | 903 | ||
| 904 | + | func TestBuildRouteDiagnosis_VolumesAreScopedToExactRouteContainers(t *testing.T) { | |
| 905 | + | cpMock := climocks.NewMockControlPlane(t) | |
| 906 | + | cpMock.EXPECT().GetRoute(context.Background(), "example.test").Return(&domain.Route{Domain: "example.test", Image: "site:latest"}, nil).Once() | |
| 907 | + | cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{ | |
| 908 | + | Domain: "example.test", | |
| 909 | + | ContainerID: "route-1", | |
| 910 | + | ContainerStatus: "running", | |
| 911 | + | }}, nil).Once() | |
| 912 | + | cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once() | |
| 913 | + | cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{ | |
| 914 | + | {Name: "gordon-example-test-data-docs", Containers: []string{"gordon-example.test"}}, | |
| 915 | + | {Name: "gordon-api-example-test-cache", Containers: []string{"gordon-api.example.test"}}, | |
| 916 | + | }, nil).Once() | |
| 917 | + | cpMock.EXPECT().GetConfig(context.Background()).Return(&remote.Config{}, nil).Once() | |
| 918 | + | cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once() | |
| 919 | + | ||
| 920 | + | diag, err := buildRouteDiagnosis(context.Background(), cpMock, "example.test") | |
| 921 | + | ||
| 922 | + | require.NoError(t, err) | |
| 923 | + | require.Len(t, diag.Volumes, 1) | |
| 924 | + | assert.Equal(t, "gordon-example-test-data-docs", diag.Volumes[0].Name) | |
| 925 | + | } | |
| 926 | + | ||
| 927 | + | func TestBuildRouteDiagnosis_UsesConfiguredVolumePrefixForPreservedVolumes(t *testing.T) { | |
| 928 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 929 | + | w.Header().Set("Content-Type", "application/json") | |
| 930 | + | switch r.URL.Path { | |
| 931 | + | case "/admin/routes/example.test": | |
| 932 | + | w.WriteHeader(http.StatusNotFound) | |
| 933 | + | _, _ = w.Write([]byte(`{"error":"route not found"}`)) | |
| 934 | + | case "/admin/routes": | |
| 935 | + | require.Equal(t, "detailed=true", r.URL.RawQuery) | |
| 936 | + | _, _ = w.Write([]byte(`{"routes":[]}`)) | |
| 937 | + | case "/admin/health": | |
| 938 | + | _, _ = w.Write([]byte(`{"health":{}}`)) | |
| 939 | + | case "/admin/volumes": | |
| 940 | + | _, _ = w.Write([]byte(`[{"name":"custom-example-test-data-docs","containers":[]}]`)) | |
| 941 | + | case "/admin/attachments/orphans": | |
| 942 | + | _, _ = w.Write([]byte(`{"attachments":[]}`)) | |
| 943 | + | case "/admin/config": | |
| 944 | + | _, _ = w.Write([]byte(`{"server":{"port":80,"registry_port":5000,"registry_domain":"registry.example.test"},"auto_route":{"enabled":false},"network_isolation":{"enabled":true,"prefix":"custom-network"},"volumes":{"auto_create":true,"prefix":"custom","preserve":true},"routes":[],"external_routes":[]}`)) | |
| 945 | + | default: | |
| 946 | + | t.Fatalf("unexpected path: %s", r.URL.Path) | |
| 947 | + | } | |
| 948 | + | })) | |
| 949 | + | defer srv.Close() | |
| 950 | + | ||
| 951 | + | cp := NewRemoteControlPlane(remote.NewClient(srv.URL)) | |
| 952 | + | diag, err := buildRouteDiagnosis(context.Background(), cp, "example.test") | |
| 953 | + | ||
| 954 | + | require.NoError(t, err) | |
| 955 | + | require.Len(t, diag.Volumes, 1) | |
| 956 | + | assert.Equal(t, "custom-example-test-data-docs", diag.Volumes[0].Name) | |
| 957 | + | } | |
| 958 | + | ||
| 959 | + | func TestBuildRouteDiagnosis_IncludesAttachmentVolumesWithDoubleUnderscoreOwnerEncoding(t *testing.T) { | |
| 960 | + | cpMock := climocks.NewMockControlPlane(t) | |
| 961 | + | cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once() | |
| 962 | + | cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{ | |
| 963 | + | Domain: "app.example.test", | |
| 964 | + | ContainerID: "route-1", | |
| 965 | + | ContainerStatus: "running", | |
| 966 | + | Attachments: []dto.Attachment{{ | |
| 967 | + | Name: "postgres", | |
| 968 | + | Image: "postgres:16", | |
| 969 | + | ContainerID: "attachment-1", | |
| 970 | + | Status: "running", | |
| 971 | + | }}, | |
| 972 | + | }}, nil).Once() | |
| 973 | + | cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once() | |
| 974 | + | cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{ | |
| 975 | + | {Name: "gordon-gordon-app__example__test-postgres-var-lib-postgresql", Containers: []string{"gordon-app__example__test-postgres"}}, | |
| 976 | + | }, nil).Once() | |
| 977 | + | cpMock.EXPECT().GetConfig(context.Background()).Return(&remote.Config{}, nil).Once() | |
| 978 | + | cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once() | |
| 979 | + | ||
| 980 | + | diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test") | |
| 981 | + | ||
| 982 | + | require.NoError(t, err) | |
| 983 | + | require.Len(t, diag.Volumes, 1) | |
| 984 | + | assert.Equal(t, "gordon-gordon-app__example__test-postgres-var-lib-postgresql", diag.Volumes[0].Name) | |
| 985 | + | } | |
| 986 | + | ||
| 987 | + | func TestBuildRouteDiagnosis_RemoteMissingRouteDoesNotWarnOnExpectedNotFound(t *testing.T) { | |
| 988 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 989 | + | w.Header().Set("Content-Type", "application/json") | |
| 990 | + | switch r.URL.Path { | |
| 991 | + | case "/admin/routes/missing.example.test": | |
| 992 | + | w.WriteHeader(http.StatusNotFound) | |
| 993 | + | _, _ = w.Write([]byte(`{"error":"route not found"}`)) | |
| 994 | + | case "/admin/routes": | |
| 995 | + | require.Equal(t, "detailed=true", r.URL.RawQuery) | |
| 996 | + | _, _ = w.Write([]byte(`{"routes":[]}`)) | |
| 997 | + | case "/admin/health": | |
| 998 | + | _, _ = w.Write([]byte(`{"health":{}}`)) | |
| 999 | + | case "/admin/volumes": | |
| 1000 | + | _, _ = w.Write([]byte(`[]`)) | |
| 1001 | + | case "/admin/attachments/orphans": | |
| 1002 | + | _, _ = w.Write([]byte(`{"attachments":[]}`)) | |
| 1003 | + | default: | |
| 1004 | + | t.Fatalf("unexpected path: %s", r.URL.Path) | |
| 1005 | + | } | |
| 1006 | + | })) | |
| 1007 | + | defer srv.Close() | |
| 1008 | + | ||
| 1009 | + | cp := NewRemoteControlPlane(remote.NewClient(srv.URL)) | |
| 1010 | + | diag, err := buildRouteDiagnosis(context.Background(), cp, "missing.example.test") | |
| 1011 | + | ||
| 1012 | + | require.NoError(t, err) | |
| 1013 | + | assert.False(t, diag.Configured) | |
| 1014 | + | assert.Empty(t, diag.Warnings) | |
| 1015 | + | } | |
| 1016 | + | ||
| 1017 | + | func TestRunRoutePurge_VolumesUsesConfiguredVolumePrefix(t *testing.T) { | |
| 1018 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 1019 | + | w.Header().Set("Content-Type", "application/json") | |
| 1020 | + | switch r.URL.Path { | |
| 1021 | + | case "/admin/routes/example.test": | |
| 1022 | + | w.WriteHeader(http.StatusNotFound) | |
| 1023 | + | _, _ = w.Write([]byte(`{"error":"route not found"}`)) | |
| 1024 | + | case "/admin/routes": | |
| 1025 | + | require.Equal(t, "detailed=true", r.URL.RawQuery) | |
| 1026 | + | _, _ = w.Write([]byte(`{"routes":[]}`)) | |
| 1027 | + | case "/admin/health": | |
| 1028 | + | _, _ = w.Write([]byte(`{"health":{}}`)) | |
| 1029 | + | case "/admin/volumes": | |
| 1030 | + | _, _ = w.Write([]byte(`[{"name":"custom-example-test-data-docs","containers":[]}]`)) | |
| 1031 | + | case "/admin/attachments/orphans": | |
| 1032 | + | _, _ = w.Write([]byte(`{"attachments":[]}`)) | |
| 1033 | + | case "/admin/config": | |
| 1034 | + | _, _ = w.Write([]byte(`{"server":{"port":80,"registry_port":5000,"registry_domain":"registry.example.test"},"auto_route":{"enabled":false},"network_isolation":{"enabled":true,"prefix":"custom-network"},"volumes":{"auto_create":true,"prefix":"custom","preserve":true},"routes":[],"external_routes":[]}`)) | |
| 1035 | + | default: | |
| 1036 | + | t.Fatalf("unexpected path: %s", r.URL.Path) | |
| 1037 | + | } | |
| 1038 | + | })) | |
| 1039 | + | defer srv.Close() | |
| 1040 | + | ||
| 1041 | + | cp := NewRemoteControlPlane(remote.NewClient(srv.URL)) | |
| 1042 | + | report, err := runRoutePurge(context.Background(), cp, "example.test", routePurgeOptions{Volumes: true}) | |
| 1043 | + | ||
| 1044 | + | require.NoError(t, err) | |
| 1045 | + | require.Len(t, report.PreservedVolumes, 1) | |
| 1046 | + | assert.Equal(t, "custom-example-test-data-docs", report.PreservedVolumes[0].Name) | |
| 1047 | + | } | |
| 1048 | + | ||
| 1049 | + | func TestBuildRouteDiagnosis_UsesRouteScopedReviewHintForOrphanedAttachments(t *testing.T) { | |
| 1050 | + | cpMock := climocks.NewMockControlPlane(t) | |
| 1051 | + | cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once() | |
| 1052 | + | cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{ | |
| 1053 | + | Domain: "app.example.test", | |
| 1054 | + | ContainerID: "route-1", | |
| 1055 | + | ContainerStatus: "running", | |
| 1056 | + | }}, nil).Once() | |
| 1057 | + | cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once() | |
| 1058 | + | cpMock.EXPECT().ListVolumes(context.Background()).Return(nil, nil).Once() | |
| 1059 | + | cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return([]domain.CleanupAttachment{{ | |
| 1060 | + | Name: "old-postgres", | |
| 1061 | + | ContainerID: "attachment-1", | |
| 1062 | + | Owner: "app.example.test", | |
| 1063 | + | }}, nil).Once() | |
| 1064 | + | ||
| 1065 | + | diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test") | |
| 1066 | + | ||
| 1067 | + | require.NoError(t, err) | |
| 1068 | + | assert.Contains(t, diag.Hints, "run 'gordon routes purge app.example.test --attachments' to review orphaned attachment cleanup for this route") | |
| 1069 | + | } | |
| 1070 | + | ||
| 1071 | + | func TestWriteRouteDiagnosisText_UsesNeutralVolumeLabelForConfiguredRoutes(t *testing.T) { | |
| 1072 | + | var out bytes.Buffer | |
| 1073 | + | diag := &routeDiagnosis{ | |
| 1074 | + | Domain: "app.example.test", | |
| 1075 | + | Configured: true, | |
| 1076 | + | Route: &domain.Route{Domain: "app.example.test", Image: "app:latest"}, | |
| 1077 | + | Runtime: &remote.RouteInfo{ | |
| 1078 | + | Domain: "app.example.test", | |
| 1079 | + | ContainerID: "route-1", | |
| 1080 | + | ContainerStatus: "running", | |
| 1081 | + | }, | |
| 1082 | + | Volumes: []dto.Volume{{Name: "gordon-app-example-test-data"}}, | |
| 1083 | + | } | |
| 1084 | + | ||
| 1085 | + | err := writeRouteDiagnosisText(&out, diag) | |
| 1086 | + | require.NoError(t, err) | |
| 1087 | + | rendered := stripANSI(out.String()) | |
| 1088 | + | assert.Contains(t, rendered, "Volume: gordon-app-example-test-data") | |
| 1089 | + | assert.NotContains(t, rendered, "Preserved volume:") | |
| 1090 | + | } | |
| 1091 | + | ||
| 1092 | + | func TestWriteRouteDiagnosisText_ShowsActiveAttachmentsSeparatelyFromOrphans(t *testing.T) { | |
| 1093 | + | var out bytes.Buffer | |
| 1094 | + | diag := &routeDiagnosis{ | |
| 1095 | + | Domain: "app.example.test", | |
| 1096 | + | Configured: true, | |
| 1097 | + | Route: &domain.Route{Domain: "app.example.test", Image: "app:latest"}, | |
| 1098 | + | Runtime: &remote.RouteInfo{ | |
| 1099 | + | Domain: "app.example.test", | |
| 1100 | + | ContainerID: "route-1", | |
| 1101 | + | ContainerStatus: "running", | |
| 1102 | + | Attachments: []dto.Attachment{{ | |
| 1103 | + | Name: "postgres", | |
| 1104 | + | Image: "postgres:16", | |
| 1105 | + | ContainerID: "attachment-1", | |
| 1106 | + | Status: "running", | |
| 1107 | + | }}, | |
| 1108 | + | }, | |
| 1109 | + | OrphanedAttachments: []domain.CleanupAttachment{{ | |
| 1110 | + | Name: "old-postgres", | |
| 1111 | + | ContainerID: "attachment-2", | |
| 1112 | + | Owner: "app.example.test", | |
| 1113 | + | }}, | |
| 1114 | + | } | |
| 1115 | + | ||
| 1116 | + | err := writeRouteDiagnosisText(&out, diag) | |
| 1117 | + | require.NoError(t, err) | |
| 1118 | + | rendered := stripANSI(out.String()) | |
| 1119 | + | assert.Contains(t, rendered, "Active attachments:") | |
| 1120 | + | assert.Contains(t, rendered, "postgres (running)") | |
| 1121 | + | assert.Contains(t, rendered, "Orphaned attachment: old-postgres") | |
| 1122 | + | } | |
| 1123 | + | ||
| 902 | 1124 | func TestRunRoutePurge_AttachmentsForceIsScopedToRoute(t *testing.T) { | |
| 903 | 1125 | cpMock := climocks.NewMockControlPlane(t) | |
| 904 | 1126 | orphanedAttachments := []domain.CleanupAttachment{ | |
| @@ -6,6 +6,8 @@ import ( | |||||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | "io" | 8 | "io" | ||
| 9 | + | "net/http" | |||
| 10 | + | "net/http/httptest" | |||
| 9 | "os" | 11 | "os" | ||
| 10 | "path/filepath" | 12 | "path/filepath" | ||
| 11 | "strconv" | 13 | "strconv" | ||
| @@ -899,6 +901,226 @@ func TestAttachmentsForRouteDiagnosisUsesOwner(t *testing.T) { | |||||
| 899 | assert.Equal(t, "match", matched[0].ContainerID) | 901 | assert.Equal(t, "match", matched[0].ContainerID) | ||
| 900 | } | 902 | } | ||
| 901 | 903 | ||||
| 904 | + | func TestBuildRouteDiagnosis_VolumesAreScopedToExactRouteContainers(t *testing.T) { | |||
| 905 | + | cpMock := climocks.NewMockControlPlane(t) | |||
| 906 | + | cpMock.EXPECT().GetRoute(context.Background(), "example.test").Return(&domain.Route{Domain: "example.test", Image: "site:latest"}, nil).Once() | |||
| 907 | + | cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{ | |||
| 908 | + | Domain: "example.test", | |||
| 909 | + | ContainerID: "route-1", | |||
| 910 | + | ContainerStatus: "running", | |||
| 911 | + | }}, nil).Once() | |||
| 912 | + | cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once() | |||
| 913 | + | cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{ | |||
| 914 | + | {Name: "gordon-example-test-data-docs", Containers: []string{"gordon-example.test"}}, | |||
| 915 | + | {Name: "gordon-api-example-test-cache", Containers: []string{"gordon-api.example.test"}}, | |||
| 916 | + | }, nil).Once() | |||
| 917 | + | cpMock.EXPECT().GetConfig(context.Background()).Return(&remote.Config{}, nil).Once() | |||
| 918 | + | cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once() | |||
| 919 | + | ||||
| 920 | + | diag, err := buildRouteDiagnosis(context.Background(), cpMock, "example.test") | |||
| 921 | + | ||||
| 922 | + | require.NoError(t, err) | |||
| 923 | + | require.Len(t, diag.Volumes, 1) | |||
| 924 | + | assert.Equal(t, "gordon-example-test-data-docs", diag.Volumes[0].Name) | |||
| 925 | + | } | |||
| 926 | + | ||||
| 927 | + | func TestBuildRouteDiagnosis_UsesConfiguredVolumePrefixForPreservedVolumes(t *testing.T) { | |||
| 928 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 929 | + | w.Header().Set("Content-Type", "application/json") | |||
| 930 | + | switch r.URL.Path { | |||
| 931 | + | case "/admin/routes/example.test": | |||
| 932 | + | w.WriteHeader(http.StatusNotFound) | |||
| 933 | + | _, _ = w.Write([]byte(`{"error":"route not found"}`)) | |||
| 934 | + | case "/admin/routes": | |||
| 935 | + | require.Equal(t, "detailed=true", r.URL.RawQuery) | |||
| 936 | + | _, _ = w.Write([]byte(`{"routes":[]}`)) | |||
| 937 | + | case "/admin/health": | |||
| 938 | + | _, _ = w.Write([]byte(`{"health":{}}`)) | |||
| 939 | + | case "/admin/volumes": | |||
| 940 | + | _, _ = w.Write([]byte(`[{"name":"custom-example-test-data-docs","containers":[]}]`)) | |||
| 941 | + | case "/admin/attachments/orphans": | |||
| 942 | + | _, _ = w.Write([]byte(`{"attachments":[]}`)) | |||
| 943 | + | case "/admin/config": | |||
| 944 | + | _, _ = w.Write([]byte(`{"server":{"port":80,"registry_port":5000,"registry_domain":"registry.example.test"},"auto_route":{"enabled":false},"network_isolation":{"enabled":true,"prefix":"custom-network"},"volumes":{"auto_create":true,"prefix":"custom","preserve":true},"routes":[],"external_routes":[]}`)) | |||
| 945 | + | default: | |||
| 946 | + | t.Fatalf("unexpected path: %s", r.URL.Path) | |||
| 947 | + | } | |||
| 948 | + | })) | |||
| 949 | + | defer srv.Close() | |||
| 950 | + | ||||
| 951 | + | cp := NewRemoteControlPlane(remote.NewClient(srv.URL)) | |||
| 952 | + | diag, err := buildRouteDiagnosis(context.Background(), cp, "example.test") | |||
| 953 | + | ||||
| 954 | + | require.NoError(t, err) | |||
| 955 | + | require.Len(t, diag.Volumes, 1) | |||
| 956 | + | assert.Equal(t, "custom-example-test-data-docs", diag.Volumes[0].Name) | |||
| 957 | + | } | |||
| 958 | + | ||||
| 959 | + | func TestBuildRouteDiagnosis_IncludesAttachmentVolumesWithDoubleUnderscoreOwnerEncoding(t *testing.T) { | |||
| 960 | + | cpMock := climocks.NewMockControlPlane(t) | |||
| 961 | + | cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once() | |||
| 962 | + | cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{ | |||
| 963 | + | Domain: "app.example.test", | |||
| 964 | + | ContainerID: "route-1", | |||
| 965 | + | ContainerStatus: "running", | |||
| 966 | + | Attachments: []dto.Attachment{{ | |||
| 967 | + | Name: "postgres", | |||
| 968 | + | Image: "postgres:16", | |||
| 969 | + | ContainerID: "attachment-1", | |||
| 970 | + | Status: "running", | |||
| 971 | + | }}, | |||
| 972 | + | }}, nil).Once() | |||
| 973 | + | cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once() | |||
| 974 | + | cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{ | |||
| 975 | + | {Name: "gordon-gordon-app__example__test-postgres-var-lib-postgresql", Containers: []string{"gordon-app__example__test-postgres"}}, | |||
| 976 | + | }, nil).Once() | |||
| 977 | + | cpMock.EXPECT().GetConfig(context.Background()).Return(&remote.Config{}, nil).Once() | |||
| 978 | + | cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once() | |||
| 979 | + | ||||
| 980 | + | diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test") | |||
| 981 | + | ||||
| 982 | + | require.NoError(t, err) | |||
| 983 | + | require.Len(t, diag.Volumes, 1) | |||
| 984 | + | assert.Equal(t, "gordon-gordon-app__example__test-postgres-var-lib-postgresql", diag.Volumes[0].Name) | |||
| 985 | + | } | |||
| 986 | + | ||||
| 987 | + | func TestBuildRouteDiagnosis_RemoteMissingRouteDoesNotWarnOnExpectedNotFound(t *testing.T) { | |||
| 988 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 989 | + | w.Header().Set("Content-Type", "application/json") | |||
| 990 | + | switch r.URL.Path { | |||
| 991 | + | case "/admin/routes/missing.example.test": | |||
| 992 | + | w.WriteHeader(http.StatusNotFound) | |||
| 993 | + | _, _ = w.Write([]byte(`{"error":"route not found"}`)) | |||
| 994 | + | case "/admin/routes": | |||
| 995 | + | require.Equal(t, "detailed=true", r.URL.RawQuery) | |||
| 996 | + | _, _ = w.Write([]byte(`{"routes":[]}`)) | |||
| 997 | + | case "/admin/health": | |||
| 998 | + | _, _ = w.Write([]byte(`{"health":{}}`)) | |||
| 999 | + | case "/admin/volumes": | |||
| 1000 | + | _, _ = w.Write([]byte(`[]`)) | |||
| 1001 | + | case "/admin/attachments/orphans": | |||
| 1002 | + | _, _ = w.Write([]byte(`{"attachments":[]}`)) | |||
| 1003 | + | default: | |||
| 1004 | + | t.Fatalf("unexpected path: %s", r.URL.Path) | |||
| 1005 | + | } | |||
| 1006 | + | })) | |||
| 1007 | + | defer srv.Close() | |||
| 1008 | + | ||||
| 1009 | + | cp := NewRemoteControlPlane(remote.NewClient(srv.URL)) | |||
| 1010 | + | diag, err := buildRouteDiagnosis(context.Background(), cp, "missing.example.test") | |||
| 1011 | + | ||||
| 1012 | + | require.NoError(t, err) | |||
| 1013 | + | assert.False(t, diag.Configured) | |||
| 1014 | + | assert.Empty(t, diag.Warnings) | |||
| 1015 | + | } | |||
| 1016 | + | ||||
| 1017 | + | func TestRunRoutePurge_VolumesUsesConfiguredVolumePrefix(t *testing.T) { | |||
| 1018 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 1019 | + | w.Header().Set("Content-Type", "application/json") | |||
| 1020 | + | switch r.URL.Path { | |||
| 1021 | + | case "/admin/routes/example.test": | |||
| 1022 | + | w.WriteHeader(http.StatusNotFound) | |||
| 1023 | + | _, _ = w.Write([]byte(`{"error":"route not found"}`)) | |||
| 1024 | + | case "/admin/routes": | |||
| 1025 | + | require.Equal(t, "detailed=true", r.URL.RawQuery) | |||
| 1026 | + | _, _ = w.Write([]byte(`{"routes":[]}`)) | |||
| 1027 | + | case "/admin/health": | |||
| 1028 | + | _, _ = w.Write([]byte(`{"health":{}}`)) | |||
| 1029 | + | case "/admin/volumes": | |||
| 1030 | + | _, _ = w.Write([]byte(`[{"name":"custom-example-test-data-docs","containers":[]}]`)) | |||
| 1031 | + | case "/admin/attachments/orphans": | |||
| 1032 | + | _, _ = w.Write([]byte(`{"attachments":[]}`)) | |||
| 1033 | + | case "/admin/config": | |||
| 1034 | + | _, _ = w.Write([]byte(`{"server":{"port":80,"registry_port":5000,"registry_domain":"registry.example.test"},"auto_route":{"enabled":false},"network_isolation":{"enabled":true,"prefix":"custom-network"},"volumes":{"auto_create":true,"prefix":"custom","preserve":true},"routes":[],"external_routes":[]}`)) | |||
| 1035 | + | default: | |||
| 1036 | + | t.Fatalf("unexpected path: %s", r.URL.Path) | |||
| 1037 | + | } | |||
| 1038 | + | })) | |||
| 1039 | + | defer srv.Close() | |||
| 1040 | + | ||||
| 1041 | + | cp := NewRemoteControlPlane(remote.NewClient(srv.URL)) | |||
| 1042 | + | report, err := runRoutePurge(context.Background(), cp, "example.test", routePurgeOptions{Volumes: true}) | |||
| 1043 | + | ||||
| 1044 | + | require.NoError(t, err) | |||
| 1045 | + | require.Len(t, report.PreservedVolumes, 1) | |||
| 1046 | + | assert.Equal(t, "custom-example-test-data-docs", report.PreservedVolumes[0].Name) | |||
| 1047 | + | } | |||
| 1048 | + | ||||
| 1049 | + | func TestBuildRouteDiagnosis_UsesRouteScopedReviewHintForOrphanedAttachments(t *testing.T) { | |||
| 1050 | + | cpMock := climocks.NewMockControlPlane(t) | |||
| 1051 | + | cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once() | |||
| 1052 | + | cpMock.EXPECT().ListRoutesWithDetails(context.Background()).Return([]remote.RouteInfo{{ | |||
| 1053 | + | Domain: "app.example.test", | |||
| 1054 | + | ContainerID: "route-1", | |||
| 1055 | + | ContainerStatus: "running", | |||
| 1056 | + | }}, nil).Once() | |||
| 1057 | + | cpMock.EXPECT().GetHealth(context.Background()).Return(nil, nil).Once() | |||
| 1058 | + | cpMock.EXPECT().ListVolumes(context.Background()).Return(nil, nil).Once() | |||
| 1059 | + | cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return([]domain.CleanupAttachment{{ | |||
| 1060 | + | Name: "old-postgres", | |||
| 1061 | + | ContainerID: "attachment-1", | |||
| 1062 | + | Owner: "app.example.test", | |||
| 1063 | + | }}, nil).Once() | |||
| 1064 | + | ||||
| 1065 | + | diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test") | |||
| 1066 | + | ||||
| 1067 | + | require.NoError(t, err) | |||
| 1068 | + | assert.Contains(t, diag.Hints, "run 'gordon routes purge app.example.test --attachments' to review orphaned attachment cleanup for this route") | |||
| 1069 | + | } | |||
| 1070 | + | ||||
| 1071 | + | func TestWriteRouteDiagnosisText_UsesNeutralVolumeLabelForConfiguredRoutes(t *testing.T) { | |||
| 1072 | + | var out bytes.Buffer | |||
| 1073 | + | diag := &routeDiagnosis{ | |||
| 1074 | + | Domain: "app.example.test", | |||
| 1075 | + | Configured: true, | |||
| 1076 | + | Route: &domain.Route{Domain: "app.example.test", Image: "app:latest"}, | |||
| 1077 | + | Runtime: &remote.RouteInfo{ | |||
| 1078 | + | Domain: "app.example.test", | |||
| 1079 | + | ContainerID: "route-1", | |||
| 1080 | + | ContainerStatus: "running", | |||
| 1081 | + | }, | |||
| 1082 | + | Volumes: []dto.Volume{{Name: "gordon-app-example-test-data"}}, | |||
| 1083 | + | } | |||
| 1084 | + | ||||
| 1085 | + | err := writeRouteDiagnosisText(&out, diag) | |||
| 1086 | + | require.NoError(t, err) | |||
| 1087 | + | rendered := stripANSI(out.String()) | |||
| 1088 | + | assert.Contains(t, rendered, "Volume: gordon-app-example-test-data") | |||
| 1089 | + | assert.NotContains(t, rendered, "Preserved volume:") | |||
| 1090 | + | } | |||
| 1091 | + | ||||
| 1092 | + | func TestWriteRouteDiagnosisText_ShowsActiveAttachmentsSeparatelyFromOrphans(t *testing.T) { | |||
| 1093 | + | var out bytes.Buffer | |||
| 1094 | + | diag := &routeDiagnosis{ | |||
| 1095 | + | Domain: "app.example.test", | |||
| 1096 | + | Configured: true, | |||
| 1097 | + | Route: &domain.Route{Domain: "app.example.test", Image: "app:latest"}, | |||
| 1098 | + | Runtime: &remote.RouteInfo{ | |||
| 1099 | + | Domain: "app.example.test", | |||
| 1100 | + | ContainerID: "route-1", | |||
| 1101 | + | ContainerStatus: "running", | |||
| 1102 | + | Attachments: []dto.Attachment{{ | |||
| 1103 | + | Name: "postgres", | |||
| 1104 | + | Image: "postgres:16", | |||
| 1105 | + | ContainerID: "attachment-1", | |||
| 1106 | + | Status: "running", | |||
| 1107 | + | }}, | |||
| 1108 | + | }, | |||
| 1109 | + | OrphanedAttachments: []domain.CleanupAttachment{{ | |||
| 1110 | + | Name: "old-postgres", | |||
| 1111 | + | ContainerID: "attachment-2", | |||
| 1112 | + | Owner: "app.example.test", | |||
| 1113 | + | }}, | |||
| 1114 | + | } | |||
| 1115 | + | ||||
| 1116 | + | err := writeRouteDiagnosisText(&out, diag) | |||
| 1117 | + | require.NoError(t, err) | |||
| 1118 | + | rendered := stripANSI(out.String()) | |||
| 1119 | + | assert.Contains(t, rendered, "Active attachments:") | |||
| 1120 | + | assert.Contains(t, rendered, "postgres (running)") | |||
| 1121 | + | assert.Contains(t, rendered, "Orphaned attachment: old-postgres") | |||
| 1122 | + | } | |||
| 1123 | + | ||||
| 902 | func TestRunRoutePurge_AttachmentsForceIsScopedToRoute(t *testing.T) { | 1124 | func TestRunRoutePurge_AttachmentsForceIsScopedToRoute(t *testing.T) { | ||
| 903 | cpMock := climocks.NewMockControlPlane(t) | 1125 | cpMock := climocks.NewMockControlPlane(t) | ||
| 904 | orphanedAttachments := []domain.CleanupAttachment{ | 1126 | orphanedAttachments := []domain.CleanupAttachment{ | ||
M internal/adapters/in/http/admin/handler.go +3 -0
| @@ -1377,6 +1377,9 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { | |||
| 1377 | 1377 | Routes: routeResponses, | |
| 1378 | 1378 | ExternalRoutes: externalResponses, | |
| 1379 | 1379 | } | |
| 1380 | + | if volumeCfg, ok := any(h.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok { | |
| 1381 | + | config.Volumes.AutoCreate, config.Volumes.Prefix, config.Volumes.Preserve = volumeCfg.GetVolumeConfig() | |
| 1382 | + | } | |
| 1380 | 1383 | ||
| 1381 | 1384 | h.sendJSON(w, http.StatusOK, config) | |
| 1382 | 1385 | } | |
| @@ -1377,6 +1377,9 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { | |||||
| 1377 | Routes: routeResponses, | 1377 | Routes: routeResponses, | ||
| 1378 | ExternalRoutes: externalResponses, | 1378 | ExternalRoutes: externalResponses, | ||
| 1379 | } | 1379 | } | ||
| 1380 | + | if volumeCfg, ok := any(h.configSvc).(interface{ GetVolumeConfig() (bool, string, bool) }); ok { | |||
| 1381 | + | config.Volumes.AutoCreate, config.Volumes.Prefix, config.Volumes.Preserve = volumeCfg.GetVolumeConfig() | |||
| 1382 | + | } | |||
| 1380 | 1383 | ||||
| 1381 | h.sendJSON(w, http.StatusOK, config) | 1384 | h.sendJSON(w, http.StatusOK, config) | ||
| 1382 | } | 1385 | } | ||