detached 64bf6f1

fix(cli): honor configured volume prefix in diagnose

commit
64bf6f171c900a5e20d558b1f96bf3a58ea7b193
author
brice · · 2026-05-22 16:48 UTC
parent
ea2fb16
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +105 -5
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 +5 -0
@@ -864,6 +864,11 @@ type Config struct {
864864 Enabled bool `json:"enabled"`
865865 Prefix string `json:"prefix"`
866866 } `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"`
867872 Routes []domain.Route `json:"routes"`
868873 ExternalRoutes []ExternalRoute `json:"external_routes"`
869874 }
@@ -864,6 +864,11 @@ type Config struct {
864 Enabled bool `json:"enabled"`864 Enabled bool `json:"enabled"`
865 Prefix string `json:"prefix"`865 Prefix string `json:"prefix"`
866 } `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"`
867 Routes []domain.Route `json:"routes"`872 Routes []domain.Route `json:"routes"`
868 ExternalRoutes []ExternalRoute `json:"external_routes"`873 ExternalRoutes []ExternalRoute `json:"external_routes"`
869 }874 }
M internal/adapters/in/cli/routes.go +20 -5
@@ -1166,11 +1166,23 @@ 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- scope := newRouteResourceScope(domainName, diag.Runtime, diag.OrphanedAttachments)
1169+ if len(volumes) == 0 {
1170+ return
1171+ }
1172+ scope := newRouteResourceScope(domainName, routeDiagnosisVolumePrefix(ctx, cp), diag.Runtime, diag.OrphanedAttachments)
11701173 diag.Volumes = volumesForRouteDiagnosis(volumes, scope)
11711174 }
11721175 }
11731176
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
1182+ }
1183+ return cfg.Volumes.Prefix
1184+}
1185+
11741186 func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
11751187 lister, ok := cp.(interface {
11761188 ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error)
@@ -1200,10 +1212,13 @@ type routeResourceScope struct {
12001212 volumePrefixes []string
12011213 }
12021214
1203-func newRouteResourceScope(domainName string, runtime *remote.RouteInfo, orphaned []domain.CleanupAttachment) routeResourceScope {
1215+func newRouteResourceScope(domainName, volumePrefix string, runtime *remote.RouteInfo, orphaned []domain.CleanupAttachment) routeResourceScope {
1216+ if volumePrefix == "" {
1217+ volumePrefix = "gordon"
1218+ }
12041219 scope := routeResourceScope{
12051220 containerNames: make(map[string]struct{}),
1206- volumePrefixes: []string{"gordon-" + strings.ReplaceAll(domainName, ".", "-") + "-"},
1221+ volumePrefixes: []string{volumePrefix + "-" + strings.ReplaceAll(domainName, ".", "-") + "-"},
12071222 }
12081223 for _, name := range []string{
12091224 fmt.Sprintf("gordon-%s", domainName),
@@ -1216,7 +1231,7 @@ func newRouteResourceScope(domainName string, runtime *remote.RouteInfo, orphane
12161231 for _, attachment := range runtime.Attachments {
12171232 for _, name := range routeAttachmentContainerNameCandidates(domainName, attachment.Name) {
12181233 scope.containerNames[name] = struct{}{}
1219- scope.volumePrefixes = append(scope.volumePrefixes, "gordon-"+name+"-")
1234+ scope.volumePrefixes = append(scope.volumePrefixes, volumePrefix+"-"+name+"-")
12201235 }
12211236 }
12221237 }
@@ -1225,7 +1240,7 @@ func newRouteResourceScope(domainName string, runtime *remote.RouteInfo, orphane
12251240 continue
12261241 }
12271242 scope.containerNames[attachment.Name] = struct{}{}
1228- scope.volumePrefixes = append(scope.volumePrefixes, "gordon-"+attachment.Name+"-")
1243+ scope.volumePrefixes = append(scope.volumePrefixes, volumePrefix+"-"+attachment.Name+"-")
12291244 }
12301245 return scope
12311246 }
@@ -1166,11 +1166,23 @@ 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- scope := newRouteResourceScope(domainName, diag.Runtime, diag.OrphanedAttachments)1169+ if len(volumes) == 0 {
1170+ return
1171+ }
1172+ scope := newRouteResourceScope(domainName, routeDiagnosisVolumePrefix(ctx, cp), diag.Runtime, diag.OrphanedAttachments)
1170 diag.Volumes = volumesForRouteDiagnosis(volumes, scope)1173 diag.Volumes = volumesForRouteDiagnosis(volumes, scope)
1171 }1174 }
1172 }1175 }
1173 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
1182+ }
1183+ return cfg.Volumes.Prefix
1184+}
1185+
1174 func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {1186 func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1175 lister, ok := cp.(interface {1187 lister, ok := cp.(interface {
1176 ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error)1188 ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error)
@@ -1200,10 +1212,13 @@ type routeResourceScope struct {
1200 volumePrefixes []string1212 volumePrefixes []string
1201 }1213 }
1202 1214
1203-func newRouteResourceScope(domainName string, runtime *remote.RouteInfo, orphaned []domain.CleanupAttachment) routeResourceScope {1215+func newRouteResourceScope(domainName, volumePrefix string, runtime *remote.RouteInfo, orphaned []domain.CleanupAttachment) routeResourceScope {
1216+ if volumePrefix == "" {
1217+ volumePrefix = "gordon"
1218+ }
1204 scope := routeResourceScope{1219 scope := routeResourceScope{
1205 containerNames: make(map[string]struct{}),1220 containerNames: make(map[string]struct{}),
1206- volumePrefixes: []string{"gordon-" + strings.ReplaceAll(domainName, ".", "-") + "-"},1221+ volumePrefixes: []string{volumePrefix + "-" + strings.ReplaceAll(domainName, ".", "-") + "-"},
1207 }1222 }
1208 for _, name := range []string{1223 for _, name := range []string{
1209 fmt.Sprintf("gordon-%s", domainName),1224 fmt.Sprintf("gordon-%s", domainName),
@@ -1216,7 +1231,7 @@ func newRouteResourceScope(domainName string, runtime *remote.RouteInfo, orphane
1216 for _, attachment := range runtime.Attachments {1231 for _, attachment := range runtime.Attachments {
1217 for _, name := range routeAttachmentContainerNameCandidates(domainName, attachment.Name) {1232 for _, name := range routeAttachmentContainerNameCandidates(domainName, attachment.Name) {
1218 scope.containerNames[name] = struct{}{}1233 scope.containerNames[name] = struct{}{}
1219- scope.volumePrefixes = append(scope.volumePrefixes, "gordon-"+name+"-")1234+ scope.volumePrefixes = append(scope.volumePrefixes, volumePrefix+"-"+name+"-")
1220 }1235 }
1221 }1236 }
1222 }1237 }
@@ -1225,7 +1240,7 @@ func newRouteResourceScope(domainName string, runtime *remote.RouteInfo, orphane
1225 continue1240 continue
1226 }1241 }
1227 scope.containerNames[attachment.Name] = struct{}{}1242 scope.containerNames[attachment.Name] = struct{}{}
1228- scope.volumePrefixes = append(scope.volumePrefixes, "gordon-"+attachment.Name+"-")1243+ scope.volumePrefixes = append(scope.volumePrefixes, volumePrefix+"-"+attachment.Name+"-")
1229 }1244 }
1230 return scope1245 return scope
1231 }1246 }
M internal/adapters/in/cli/routes_test.go +66 -0
@@ -914,6 +914,7 @@ func TestBuildRouteDiagnosis_VolumesAreScopedToExactRouteContainers(t *testing.T
914914 {Name: "gordon-example-test-data-docs", Containers: []string{"gordon-example.test"}},
915915 {Name: "gordon-api-example-test-cache", Containers: []string{"gordon-api.example.test"}},
916916 }, nil).Once()
917+ cpMock.EXPECT().GetConfig(context.Background()).Return(&remote.Config{}, nil).Once()
917918 cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once()
918919
919920 diag, err := buildRouteDiagnosis(context.Background(), cpMock, "example.test")
@@ -923,6 +924,38 @@ func TestBuildRouteDiagnosis_VolumesAreScopedToExactRouteContainers(t *testing.T
923924 assert.Equal(t, "gordon-example-test-data-docs", diag.Volumes[0].Name)
924925 }
925926
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+
926959 func TestBuildRouteDiagnosis_IncludesAttachmentVolumesWithDoubleUnderscoreOwnerEncoding(t *testing.T) {
927960 cpMock := climocks.NewMockControlPlane(t)
928961 cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once()
@@ -941,6 +974,7 @@ func TestBuildRouteDiagnosis_IncludesAttachmentVolumesWithDoubleUnderscoreOwnerE
941974 cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{
942975 {Name: "gordon-gordon-app__example__test-postgres-var-lib-postgresql", Containers: []string{"gordon-app__example__test-postgres"}},
943976 }, nil).Once()
977+ cpMock.EXPECT().GetConfig(context.Background()).Return(&remote.Config{}, nil).Once()
944978 cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once()
945979
946980 diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test")
@@ -980,6 +1014,38 @@ func TestBuildRouteDiagnosis_RemoteMissingRouteDoesNotWarnOnExpectedNotFound(t *
9801014 assert.Empty(t, diag.Warnings)
9811015 }
9821016
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+
9831049 func TestBuildRouteDiagnosis_UsesRouteScopedReviewHintForOrphanedAttachments(t *testing.T) {
9841050 cpMock := climocks.NewMockControlPlane(t)
9851051 cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once()
@@ -914,6 +914,7 @@ func TestBuildRouteDiagnosis_VolumesAreScopedToExactRouteContainers(t *testing.T
914 {Name: "gordon-example-test-data-docs", Containers: []string{"gordon-example.test"}},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"}},915 {Name: "gordon-api-example-test-cache", Containers: []string{"gordon-api.example.test"}},
916 }, nil).Once()916 }, nil).Once()
917+ cpMock.EXPECT().GetConfig(context.Background()).Return(&remote.Config{}, nil).Once()
917 cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once()918 cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once()
918 919
919 diag, err := buildRouteDiagnosis(context.Background(), cpMock, "example.test")920 diag, err := buildRouteDiagnosis(context.Background(), cpMock, "example.test")
@@ -923,6 +924,38 @@ func TestBuildRouteDiagnosis_VolumesAreScopedToExactRouteContainers(t *testing.T
923 assert.Equal(t, "gordon-example-test-data-docs", diag.Volumes[0].Name)924 assert.Equal(t, "gordon-example-test-data-docs", diag.Volumes[0].Name)
924 }925 }
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+
926 func TestBuildRouteDiagnosis_IncludesAttachmentVolumesWithDoubleUnderscoreOwnerEncoding(t *testing.T) {959 func TestBuildRouteDiagnosis_IncludesAttachmentVolumesWithDoubleUnderscoreOwnerEncoding(t *testing.T) {
927 cpMock := climocks.NewMockControlPlane(t)960 cpMock := climocks.NewMockControlPlane(t)
928 cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once()961 cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once()
@@ -941,6 +974,7 @@ func TestBuildRouteDiagnosis_IncludesAttachmentVolumesWithDoubleUnderscoreOwnerE
941 cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{974 cpMock.EXPECT().ListVolumes(context.Background()).Return([]dto.Volume{
942 {Name: "gordon-gordon-app__example__test-postgres-var-lib-postgresql", Containers: []string{"gordon-app__example__test-postgres"}},975 {Name: "gordon-gordon-app__example__test-postgres-var-lib-postgresql", Containers: []string{"gordon-app__example__test-postgres"}},
943 }, nil).Once()976 }, nil).Once()
977+ cpMock.EXPECT().GetConfig(context.Background()).Return(&remote.Config{}, nil).Once()
944 cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once()978 cpMock.EXPECT().ListOrphanedAttachments(context.Background()).Return(nil, nil).Once()
945 979
946 diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test")980 diag, err := buildRouteDiagnosis(context.Background(), cpMock, "app.example.test")
@@ -980,6 +1014,38 @@ func TestBuildRouteDiagnosis_RemoteMissingRouteDoesNotWarnOnExpectedNotFound(t *
980 assert.Empty(t, diag.Warnings)1014 assert.Empty(t, diag.Warnings)
981 }1015 }
982 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+
983 func TestBuildRouteDiagnosis_UsesRouteScopedReviewHintForOrphanedAttachments(t *testing.T) {1049 func TestBuildRouteDiagnosis_UsesRouteScopedReviewHintForOrphanedAttachments(t *testing.T) {
984 cpMock := climocks.NewMockControlPlane(t)1050 cpMock := climocks.NewMockControlPlane(t)
985 cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once()1051 cpMock.EXPECT().GetRoute(context.Background(), "app.example.test").Return(&domain.Route{Domain: "app.example.test", Image: "app:latest"}, nil).Once()
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