detached 35467d7

Merge pull request #202 from bnema/fix/cleanup-diagnostics-lifecycle

fix(cli): tighten cleanup diagnose and remote error handling
commit
35467d734954bc98c1eabc07bcef4cdf775a1152
author
bnema · · 2026-05-22 16:55 UTC
committer
GitHub · · 2026-05-22 16:55 UTC
merge of
7b9923f + 64bf6f1 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.31.0-dev19
7 files · +369 -8
M internal/adapters/dto/admin_config.go +8 -0
@@ -5,6 +5,7 @@ type ConfigResponse struct {
55 Server ServerConfig `json:"server"`
66 AutoRoute AutoRouteConfig `json:"auto_route"`
77 NetworkIsolation NetworkIsolationConfig `json:"network_isolation"`
8+ Volumes VolumesConfig `json:"volumes"`
89 Routes []Route `json:"routes"`
910 ExternalRoutes []ExternalRoute `json:"external_routes"`
1011 }
@@ -28,6 +29,13 @@ type NetworkIsolationConfig struct {
2829 Prefix string `json:"prefix"`
2930 }
3031
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+
3139 // ExternalRoute represents an external route config entry.
3240 type ExternalRoute struct {
3341 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
452452 cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled()
453453 cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled()
454454 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+ }
455458 return cfg, nil
456459 }
457460
@@ -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, nil458 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
504504
505505 var route domain.Route
506506 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+ }
507511 return nil, err
508512 }
509513
@@ -860,6 +864,11 @@ type Config struct {
860864 Enabled bool `json:"enabled"`
861865 Prefix string `json:"prefix"`
862866 } `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"`
863872 Routes []domain.Route `json:"routes"`
864873 ExternalRoutes []ExternalRoute `json:"external_routes"`
865874 }
@@ -504,6 +504,10 @@ func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Rout
504 504
505 var route domain.Route505 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, err511 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 (
1313 "testing"
1414 "time"
1515
16+ "github.com/bnema/gordon/internal/domain"
1617 "github.com/stretchr/testify/assert"
1718 "github.com/stretchr/testify/require"
1819
@@ -200,6 +201,22 @@ func TestClientFindAttachmentTargetsByImage(t *testing.T) {
200201 assert.Equal(t, []string{"app.example.com", "workers"}, targets)
201202 }
202203
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+
203220 func TestClientFindAttachmentTargetsByImage_WithSlashContainingImage(t *testing.T) {
204221 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
205222 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
11251125 loadRouteDiagnosisConfig(ctx, cp, domainName, diag)
11261126 loadRouteDiagnosisRuntime(ctx, cp, domainName, diag)
11271127 loadRouteDiagnosisHealth(ctx, cp, domainName, diag)
1128- loadRouteDiagnosisVolumes(ctx, cp, domainName, diag)
11291128 loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag)
1129+ loadRouteDiagnosisVolumes(ctx, cp, domainName, diag)
11301130 finalizeRouteDiagnosis(diag)
11311131 return diag, nil
11321132 }
@@ -1166,8 +1166,21 @@ func loadRouteDiagnosisHealth(ctx context.Context, cp ControlPlane, domainName s
11661166
11671167 func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
11681168 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
11701182 }
1183+ return cfg.Volumes.Prefix
11711184 }
11721185
11731186 func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
@@ -1190,21 +1203,82 @@ func finalizeRouteDiagnosis(diag *routeDiagnosis) {
11901203 diag.Hints = append(diag.Hints, "persistent volumes are preserved by default")
11911204 }
11921205 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))
11941207 }
11951208 }
11961209
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 {
11991259 var out []dto.Volume
12001260 for _, volume := range volumes {
1201- if strings.Contains(strings.ToLower(volume.Name), needle) {
1261+ if scope.matchesVolume(volume) {
12021262 out = append(out, volume)
12031263 }
12041264 }
12051265 return out
12061266 }
12071267
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+
12081282 func attachmentsForRouteDiagnosis(attachments []domain.CleanupAttachment, domainName string) []domain.CleanupAttachment {
12091283 var out []domain.CleanupAttachment
12101284 for _, attachment := range attachments {
@@ -1239,12 +1313,37 @@ func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error {
12391313 if diag.Runtime == nil {
12401314 return nil
12411315 }
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
12431338 }
12441339
12451340 func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error {
1341+ volumeLabel := "Volume:"
1342+ if !diag.Configured {
1343+ volumeLabel = "Preserved volume:"
1344+ }
12461345 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 {
12481347 return err
12491348 }
12501349 }
@@ -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, nil1131 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.Volume1259 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 out1265 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.CleanupAttachment1283 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 nil1314 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 err1347 return err
1249 }1348 }
1250 }1349 }
M internal/adapters/in/cli/routes_test.go +222 -0
@@ -6,6 +6,8 @@ import (
66 "encoding/json"
77 "errors"
88 "io"
9+ "net/http"
10+ "net/http/httptest"
911 "os"
1012 "path/filepath"
1113 "strconv"
@@ -899,6 +901,226 @@ func TestAttachmentsForRouteDiagnosisUsesOwner(t *testing.T) {
899901 assert.Equal(t, "match", matched[0].ContainerID)
900902 }
901903
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+
9021124 func TestRunRoutePurge_AttachmentsForceIsScopedToRoute(t *testing.T) {
9031125 cpMock := climocks.NewMockControlPlane(t)
9041126 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) {
13771377 Routes: routeResponses,
13781378 ExternalRoutes: externalResponses,
13791379 }
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+ }
13801383
13811384 h.sendJSON(w, http.StatusOK, config)
13821385 }
@@ -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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close