Merge pull request #199 from bnema/fix/local-routes-status-sync
Fix route status for configured local and remote routes
5 files · +174 -39
M internal/adapters/in/cli/controlplane_local.go +28 -1
| @@ -57,7 +57,14 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | |||
| 57 | 57 | ||
| 58 | 58 | func (l *localControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) { | |
| 59 | 59 | if l.containerSvc != nil { | |
| 60 | - | return toRemoteRouteInfos(l.containerSvc.ListRoutesWithDetails(ctx)), nil | |
| 60 | + | if err := l.containerSvc.SyncContainers(ctx); err != nil { | |
| 61 | + | return nil, err | |
| 62 | + | } | |
| 63 | + | detailed := l.containerSvc.ListRoutesWithDetails(ctx) | |
| 64 | + | if l.configSvc == nil { | |
| 65 | + | return toRemoteRouteInfos(detailed), nil | |
| 66 | + | } | |
| 67 | + | return mergeConfiguredRemoteRouteInfos(l.configSvc.GetRoutes(ctx), detailed), nil | |
| 61 | 68 | } | |
| 62 | 69 | ||
| 63 | 70 | if l.configSvc == nil { | |
| @@ -606,6 +613,26 @@ func (l *localControlPlane) PruneVolumes(ctx context.Context, req dto.VolumePrun | |||
| 606 | 613 | }, nil | |
| 607 | 614 | } | |
| 608 | 615 | ||
| 616 | + | func mergeConfiguredRemoteRouteInfos(configured []domain.Route, detailed []domain.RouteInfo) []remote.RouteInfo { | |
| 617 | + | detailedByDomain := make(map[string]domain.RouteInfo, len(detailed)) | |
| 618 | + | for _, info := range detailed { | |
| 619 | + | detailedByDomain[info.Domain] = info | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | merged := make([]domain.RouteInfo, 0, len(configured)) | |
| 623 | + | for _, route := range configured { | |
| 624 | + | info, ok := detailedByDomain[route.Domain] | |
| 625 | + | if !ok { | |
| 626 | + | info = domain.RouteInfo{Domain: route.Domain, Image: route.Image} | |
| 627 | + | } else if info.Image == "" { | |
| 628 | + | info.Image = route.Image | |
| 629 | + | } | |
| 630 | + | merged = append(merged, info) | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | return toRemoteRouteInfos(merged) | |
| 634 | + | } | |
| 635 | + | ||
| 609 | 636 | func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo { | |
| 610 | 637 | out := make([]remote.RouteInfo, 0, len(routes)) | |
| 611 | 638 | for _, route := range routes { | |
| @@ -57,7 +57,14 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | |||||
| 57 | 57 | ||||
| 58 | func (l *localControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) { | 58 | func (l *localControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) { | ||
| 59 | if l.containerSvc != nil { | 59 | if l.containerSvc != nil { | ||
| 60 | - | return toRemoteRouteInfos(l.containerSvc.ListRoutesWithDetails(ctx)), nil | 60 | + | if err := l.containerSvc.SyncContainers(ctx); err != nil { |
| 61 | + | return nil, err | |||
| 62 | + | } | |||
| 63 | + | detailed := l.containerSvc.ListRoutesWithDetails(ctx) | |||
| 64 | + | if l.configSvc == nil { | |||
| 65 | + | return toRemoteRouteInfos(detailed), nil | |||
| 66 | + | } | |||
| 67 | + | return mergeConfiguredRemoteRouteInfos(l.configSvc.GetRoutes(ctx), detailed), nil | |||
| 61 | } | 68 | } | ||
| 62 | 69 | ||||
| 63 | if l.configSvc == nil { | 70 | if l.configSvc == nil { | ||
| @@ -606,6 +613,26 @@ func (l *localControlPlane) PruneVolumes(ctx context.Context, req dto.VolumePrun | |||||
| 606 | }, nil | 613 | }, nil | ||
| 607 | } | 614 | } | ||
| 608 | 615 | ||||
| 616 | + | func mergeConfiguredRemoteRouteInfos(configured []domain.Route, detailed []domain.RouteInfo) []remote.RouteInfo { | |||
| 617 | + | detailedByDomain := make(map[string]domain.RouteInfo, len(detailed)) | |||
| 618 | + | for _, info := range detailed { | |||
| 619 | + | detailedByDomain[info.Domain] = info | |||
| 620 | + | } | |||
| 621 | + | ||||
| 622 | + | merged := make([]domain.RouteInfo, 0, len(configured)) | |||
| 623 | + | for _, route := range configured { | |||
| 624 | + | info, ok := detailedByDomain[route.Domain] | |||
| 625 | + | if !ok { | |||
| 626 | + | info = domain.RouteInfo{Domain: route.Domain, Image: route.Image} | |||
| 627 | + | } else if info.Image == "" { | |||
| 628 | + | info.Image = route.Image | |||
| 629 | + | } | |||
| 630 | + | merged = append(merged, info) | |||
| 631 | + | } | |||
| 632 | + | ||||
| 633 | + | return toRemoteRouteInfos(merged) | |||
| 634 | + | } | |||
| 635 | + | ||||
| 609 | func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo { | 636 | func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo { | ||
| 610 | out := make([]remote.RouteInfo, 0, len(routes)) | 637 | out := make([]remote.RouteInfo, 0, len(routes)) | ||
| 611 | for _, route := range routes { | 638 | for _, route := range routes { | ||
M internal/adapters/in/cli/controlplane_local_test.go +46 -0
| @@ -123,6 +123,52 @@ func TestLocalControlPlane_RestartWithAttachments(t *testing.T) { | |||
| 123 | 123 | require.Equal(t, "app.local", result.Domain) | |
| 124 | 124 | } | |
| 125 | 125 | ||
| 126 | + | func TestLocalControlPlane_ListRoutesWithDetailsSyncsBeforeListing(t *testing.T) { | |
| 127 | + | t.Parallel() | |
| 128 | + | ||
| 129 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 130 | + | syncCall := containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once() | |
| 131 | + | listCall := containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return([]domain.RouteInfo{{ | |
| 132 | + | Domain: "app.local", | |
| 133 | + | Image: "repo/app:latest", | |
| 134 | + | ContainerID: "container-1", | |
| 135 | + | ContainerStatus: "running", | |
| 136 | + | }}).Once() | |
| 137 | + | mock.InOrder(syncCall, listCall) | |
| 138 | + | ||
| 139 | + | cp := &localControlPlane{containerSvc: containerSvc} | |
| 140 | + | routes, err := cp.ListRoutesWithDetails(context.Background()) | |
| 141 | + | require.NoError(t, err) | |
| 142 | + | require.Len(t, routes, 1) | |
| 143 | + | require.Equal(t, "app.local", routes[0].Domain) | |
| 144 | + | require.Equal(t, "repo/app:latest", routes[0].Image) | |
| 145 | + | require.Equal(t, "container-1", routes[0].ContainerID) | |
| 146 | + | require.Equal(t, "running", routes[0].ContainerStatus) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestLocalControlPlane_ListRoutesWithDetails_IncludesConfiguredRouteWithoutRuntimeDetail(t *testing.T) { | |
| 150 | + | t.Parallel() | |
| 151 | + | ||
| 152 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 153 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 154 | + | ||
| 155 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{ | |
| 156 | + | Domain: "app.local", | |
| 157 | + | Image: "repo/app:latest", | |
| 158 | + | }}).Once() | |
| 159 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once() | |
| 160 | + | containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return(nil).Once() | |
| 161 | + | ||
| 162 | + | cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc} | |
| 163 | + | routes, err := cp.ListRoutesWithDetails(context.Background()) | |
| 164 | + | require.NoError(t, err) | |
| 165 | + | require.Len(t, routes, 1) | |
| 166 | + | require.Equal(t, "app.local", routes[0].Domain) | |
| 167 | + | require.Equal(t, "repo/app:latest", routes[0].Image) | |
| 168 | + | require.Empty(t, routes[0].ContainerID) | |
| 169 | + | require.Empty(t, routes[0].ContainerStatus) | |
| 170 | + | } | |
| 171 | + | ||
| 126 | 172 | func TestLocalControlPlane_GetTLSStatusWithoutService(t *testing.T) { | |
| 127 | 173 | t.Parallel() | |
| 128 | 174 | ||
| @@ -123,6 +123,52 @@ func TestLocalControlPlane_RestartWithAttachments(t *testing.T) { | |||||
| 123 | require.Equal(t, "app.local", result.Domain) | 123 | require.Equal(t, "app.local", result.Domain) | ||
| 124 | } | 124 | } | ||
| 125 | 125 | ||||
| 126 | + | func TestLocalControlPlane_ListRoutesWithDetailsSyncsBeforeListing(t *testing.T) { | |||
| 127 | + | t.Parallel() | |||
| 128 | + | ||||
| 129 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 130 | + | syncCall := containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once() | |||
| 131 | + | listCall := containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return([]domain.RouteInfo{{ | |||
| 132 | + | Domain: "app.local", | |||
| 133 | + | Image: "repo/app:latest", | |||
| 134 | + | ContainerID: "container-1", | |||
| 135 | + | ContainerStatus: "running", | |||
| 136 | + | }}).Once() | |||
| 137 | + | mock.InOrder(syncCall, listCall) | |||
| 138 | + | ||||
| 139 | + | cp := &localControlPlane{containerSvc: containerSvc} | |||
| 140 | + | routes, err := cp.ListRoutesWithDetails(context.Background()) | |||
| 141 | + | require.NoError(t, err) | |||
| 142 | + | require.Len(t, routes, 1) | |||
| 143 | + | require.Equal(t, "app.local", routes[0].Domain) | |||
| 144 | + | require.Equal(t, "repo/app:latest", routes[0].Image) | |||
| 145 | + | require.Equal(t, "container-1", routes[0].ContainerID) | |||
| 146 | + | require.Equal(t, "running", routes[0].ContainerStatus) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestLocalControlPlane_ListRoutesWithDetails_IncludesConfiguredRouteWithoutRuntimeDetail(t *testing.T) { | |||
| 150 | + | t.Parallel() | |||
| 151 | + | ||||
| 152 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 153 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 154 | + | ||||
| 155 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{ | |||
| 156 | + | Domain: "app.local", | |||
| 157 | + | Image: "repo/app:latest", | |||
| 158 | + | }}).Once() | |||
| 159 | + | containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once() | |||
| 160 | + | containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return(nil).Once() | |||
| 161 | + | ||||
| 162 | + | cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc} | |||
| 163 | + | routes, err := cp.ListRoutesWithDetails(context.Background()) | |||
| 164 | + | require.NoError(t, err) | |||
| 165 | + | require.Len(t, routes, 1) | |||
| 166 | + | require.Equal(t, "app.local", routes[0].Domain) | |||
| 167 | + | require.Equal(t, "repo/app:latest", routes[0].Image) | |||
| 168 | + | require.Empty(t, routes[0].ContainerID) | |||
| 169 | + | require.Empty(t, routes[0].ContainerStatus) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 126 | func TestLocalControlPlane_GetTLSStatusWithoutService(t *testing.T) { | 172 | func TestLocalControlPlane_GetTLSStatusWithoutService(t *testing.T) { | ||
| 127 | t.Parallel() | 173 | t.Parallel() | ||
| 128 | 174 | ||||
M internal/adapters/in/cli/routes.go +35 -37
| @@ -835,47 +835,45 @@ func loadRoutesStatusRemoteSection(ctx context.Context, name string, entry remot | |||
| 835 | 835 | func routeStatusItemsFromInfos(routes []remote.RouteInfo, health map[string]*remote.RouteHealth) []routeStatusItem { | |
| 836 | 836 | items := make([]routeStatusItem, 0, len(routes)) | |
| 837 | 837 | for _, route := range routes { | |
| 838 | - | item := routeStatusItem{ | |
| 839 | - | Domain: route.Domain, | |
| 840 | - | Image: route.Image, | |
| 841 | - | ContainerID: route.ContainerID, | |
| 842 | - | ContainerStatus: route.ContainerStatus, | |
| 843 | - | Network: route.Network, | |
| 844 | - | } | |
| 845 | - | if item.ContainerStatus == "" { | |
| 846 | - | if routeHealth := health[route.Domain]; routeHealth != nil { | |
| 847 | - | item.ContainerStatus = routeHealth.ContainerStatus | |
| 848 | - | item.HTTPStatus = routeHealth.HTTPStatus | |
| 849 | - | item.HealthError = routeHealth.Error | |
| 850 | - | } | |
| 851 | - | } | |
| 852 | - | if item.ContainerStatus == "" { | |
| 853 | - | item.ContainerStatus = "unknown" | |
| 854 | - | } | |
| 855 | - | if item.HTTPStatus == 0 { | |
| 856 | - | if routeHealth := health[route.Domain]; routeHealth != nil { | |
| 857 | - | item.HTTPStatus = routeHealth.HTTPStatus | |
| 858 | - | item.HealthError = routeHealth.Error | |
| 859 | - | } | |
| 860 | - | } | |
| 861 | - | if item.HealthError == "" { | |
| 862 | - | if routeHealth := health[route.Domain]; routeHealth != nil { | |
| 863 | - | item.HealthError = routeHealth.Error | |
| 864 | - | } | |
| 865 | - | } | |
| 866 | - | item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments)) | |
| 867 | - | for _, attachment := range route.Attachments { | |
| 868 | - | status := attachment.Status | |
| 869 | - | if status == "" { | |
| 870 | - | status = "unknown" | |
| 871 | - | } | |
| 872 | - | item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status}) | |
| 873 | - | } | |
| 874 | - | items = append(items, item) | |
| 838 | + | items = append(items, routeStatusItemFromInfo(route, health[route.Domain])) | |
| 875 | 839 | } | |
| 876 | 840 | return items | |
| 877 | 841 | } | |
| 878 | 842 | ||
| 843 | + | func routeStatusItemFromInfo(route remote.RouteInfo, routeHealth *remote.RouteHealth) routeStatusItem { | |
| 844 | + | item := routeStatusItem{ | |
| 845 | + | Domain: route.Domain, | |
| 846 | + | Image: route.Image, | |
| 847 | + | ContainerID: route.ContainerID, | |
| 848 | + | ContainerStatus: route.ContainerStatus, | |
| 849 | + | Network: route.Network, | |
| 850 | + | } | |
| 851 | + | if item.ContainerStatus == "" && routeHealth != nil { | |
| 852 | + | item.ContainerStatus = routeHealth.ContainerStatus | |
| 853 | + | item.HTTPStatus = routeHealth.HTTPStatus | |
| 854 | + | item.HealthError = routeHealth.Error | |
| 855 | + | } | |
| 856 | + | if item.ContainerStatus == "" { | |
| 857 | + | item.ContainerStatus = "unknown" | |
| 858 | + | } | |
| 859 | + | if item.HTTPStatus == 0 && routeHealth != nil { | |
| 860 | + | item.HTTPStatus = routeHealth.HTTPStatus | |
| 861 | + | item.HealthError = routeHealth.Error | |
| 862 | + | } | |
| 863 | + | if item.HealthError == "" && routeHealth != nil { | |
| 864 | + | item.HealthError = routeHealth.Error | |
| 865 | + | } | |
| 866 | + | item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments)) | |
| 867 | + | for _, attachment := range route.Attachments { | |
| 868 | + | status := attachment.Status | |
| 869 | + | if status == "" { | |
| 870 | + | status = "unknown" | |
| 871 | + | } | |
| 872 | + | item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status}) | |
| 873 | + | } | |
| 874 | + | return item | |
| 875 | + | } | |
| 876 | + | ||
| 879 | 877 | func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem { | |
| 880 | 878 | items := make([]routeListItem, 0, len(routes)) | |
| 881 | 879 | for _, route := range routes { | |
| @@ -835,47 +835,45 @@ func loadRoutesStatusRemoteSection(ctx context.Context, name string, entry remot | |||||
| 835 | func routeStatusItemsFromInfos(routes []remote.RouteInfo, health map[string]*remote.RouteHealth) []routeStatusItem { | 835 | func routeStatusItemsFromInfos(routes []remote.RouteInfo, health map[string]*remote.RouteHealth) []routeStatusItem { | ||
| 836 | items := make([]routeStatusItem, 0, len(routes)) | 836 | items := make([]routeStatusItem, 0, len(routes)) | ||
| 837 | for _, route := range routes { | 837 | for _, route := range routes { | ||
| 838 | - | item := routeStatusItem{ | 838 | + | items = append(items, routeStatusItemFromInfo(route, health[route.Domain])) |
| 839 | - | Domain: route.Domain, | |||
| 840 | - | Image: route.Image, | |||
| 841 | - | ContainerID: route.ContainerID, | |||
| 842 | - | ContainerStatus: route.ContainerStatus, | |||
| 843 | - | Network: route.Network, | |||
| 844 | - | } | |||
| 845 | - | if item.ContainerStatus == "" { | |||
| 846 | - | if routeHealth := health[route.Domain]; routeHealth != nil { | |||
| 847 | - | item.ContainerStatus = routeHealth.ContainerStatus | |||
| 848 | - | item.HTTPStatus = routeHealth.HTTPStatus | |||
| 849 | - | item.HealthError = routeHealth.Error | |||
| 850 | - | } | |||
| 851 | - | } | |||
| 852 | - | if item.ContainerStatus == "" { | |||
| 853 | - | item.ContainerStatus = "unknown" | |||
| 854 | - | } | |||
| 855 | - | if item.HTTPStatus == 0 { | |||
| 856 | - | if routeHealth := health[route.Domain]; routeHealth != nil { | |||
| 857 | - | item.HTTPStatus = routeHealth.HTTPStatus | |||
| 858 | - | item.HealthError = routeHealth.Error | |||
| 859 | - | } | |||
| 860 | - | } | |||
| 861 | - | if item.HealthError == "" { | |||
| 862 | - | if routeHealth := health[route.Domain]; routeHealth != nil { | |||
| 863 | - | item.HealthError = routeHealth.Error | |||
| 864 | - | } | |||
| 865 | - | } | |||
| 866 | - | item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments)) | |||
| 867 | - | for _, attachment := range route.Attachments { | |||
| 868 | - | status := attachment.Status | |||
| 869 | - | if status == "" { | |||
| 870 | - | status = "unknown" | |||
| 871 | - | } | |||
| 872 | - | item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status}) | |||
| 873 | - | } | |||
| 874 | - | items = append(items, item) | |||
| 875 | } | 839 | } | ||
| 876 | return items | 840 | return items | ||
| 877 | } | 841 | } | ||
| 878 | 842 | ||||
| 843 | + | func routeStatusItemFromInfo(route remote.RouteInfo, routeHealth *remote.RouteHealth) routeStatusItem { | |||
| 844 | + | item := routeStatusItem{ | |||
| 845 | + | Domain: route.Domain, | |||
| 846 | + | Image: route.Image, | |||
| 847 | + | ContainerID: route.ContainerID, | |||
| 848 | + | ContainerStatus: route.ContainerStatus, | |||
| 849 | + | Network: route.Network, | |||
| 850 | + | } | |||
| 851 | + | if item.ContainerStatus == "" && routeHealth != nil { | |||
| 852 | + | item.ContainerStatus = routeHealth.ContainerStatus | |||
| 853 | + | item.HTTPStatus = routeHealth.HTTPStatus | |||
| 854 | + | item.HealthError = routeHealth.Error | |||
| 855 | + | } | |||
| 856 | + | if item.ContainerStatus == "" { | |||
| 857 | + | item.ContainerStatus = "unknown" | |||
| 858 | + | } | |||
| 859 | + | if item.HTTPStatus == 0 && routeHealth != nil { | |||
| 860 | + | item.HTTPStatus = routeHealth.HTTPStatus | |||
| 861 | + | item.HealthError = routeHealth.Error | |||
| 862 | + | } | |||
| 863 | + | if item.HealthError == "" && routeHealth != nil { | |||
| 864 | + | item.HealthError = routeHealth.Error | |||
| 865 | + | } | |||
| 866 | + | item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments)) | |||
| 867 | + | for _, attachment := range route.Attachments { | |||
| 868 | + | status := attachment.Status | |||
| 869 | + | if status == "" { | |||
| 870 | + | status = "unknown" | |||
| 871 | + | } | |||
| 872 | + | item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status}) | |||
| 873 | + | } | |||
| 874 | + | return item | |||
| 875 | + | } | |||
| 876 | + | ||||
| 879 | func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem { | 877 | func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem { | ||
| 880 | items := make([]routeListItem, 0, len(routes)) | 878 | items := make([]routeListItem, 0, len(routes)) | ||
| 881 | for _, route := range routes { | 879 | for _, route := range routes { | ||
M internal/adapters/in/http/admin/handler.go +26 -1
| @@ -97,6 +97,25 @@ func toRouteResponse(r domain.Route) dto.Route { | |||
| 97 | 97 | } | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | + | func mergeConfiguredRouteDetails(configured []domain.Route, detailed []domain.RouteInfo) []domain.RouteInfo { | |
| 101 | + | detailedByDomain := make(map[string]domain.RouteInfo, len(detailed)) | |
| 102 | + | for _, info := range detailed { | |
| 103 | + | detailedByDomain[info.Domain] = info | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | merged := make([]domain.RouteInfo, 0, len(configured)) | |
| 107 | + | for _, route := range configured { | |
| 108 | + | info, ok := detailedByDomain[route.Domain] | |
| 109 | + | if !ok { | |
| 110 | + | info = domain.RouteInfo{Domain: route.Domain, Image: route.Image} | |
| 111 | + | } else if info.Image == "" { | |
| 112 | + | info.Image = route.Image | |
| 113 | + | } | |
| 114 | + | merged = append(merged, info) | |
| 115 | + | } | |
| 116 | + | return merged | |
| 117 | + | } | |
| 118 | + | ||
| 100 | 119 | func toBackupJobResponse(job domain.BackupJob) dto.BackupJob { | |
| 101 | 120 | var startedAt *time.Time | |
| 102 | 121 | if !job.StartedAt.IsZero() { | |
| @@ -527,7 +546,13 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD | |||
| 527 | 546 | ||
| 528 | 547 | if routeDomain == "" { | |
| 529 | 548 | if r.URL.Query().Get("detailed") == "true" { | |
| 530 | - | routes := h.containerSvc.ListRoutesWithDetails(ctx) | |
| 549 | + | if err := h.containerSvc.SyncContainers(ctx); err != nil { | |
| 550 | + | h.log.Error().Err(err).Msg("failed to sync containers before detailed route listing") | |
| 551 | + | h.sendError(w, http.StatusInternalServerError, "failed to list routes") | |
| 552 | + | return | |
| 553 | + | } | |
| 554 | + | configured := h.configSvc.GetRoutes(ctx) | |
| 555 | + | routes := mergeConfiguredRouteDetails(configured, h.containerSvc.ListRoutesWithDetails(ctx)) | |
| 531 | 556 | response := make([]routeInfoResponse, 0, len(routes)) | |
| 532 | 557 | for _, route := range routes { | |
| 533 | 558 | response = append(response, toRouteInfoResponse(route)) | |
| @@ -97,6 +97,25 @@ func toRouteResponse(r domain.Route) dto.Route { | |||||
| 97 | } | 97 | } | ||
| 98 | } | 98 | } | ||
| 99 | 99 | ||||
| 100 | + | func mergeConfiguredRouteDetails(configured []domain.Route, detailed []domain.RouteInfo) []domain.RouteInfo { | |||
| 101 | + | detailedByDomain := make(map[string]domain.RouteInfo, len(detailed)) | |||
| 102 | + | for _, info := range detailed { | |||
| 103 | + | detailedByDomain[info.Domain] = info | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | merged := make([]domain.RouteInfo, 0, len(configured)) | |||
| 107 | + | for _, route := range configured { | |||
| 108 | + | info, ok := detailedByDomain[route.Domain] | |||
| 109 | + | if !ok { | |||
| 110 | + | info = domain.RouteInfo{Domain: route.Domain, Image: route.Image} | |||
| 111 | + | } else if info.Image == "" { | |||
| 112 | + | info.Image = route.Image | |||
| 113 | + | } | |||
| 114 | + | merged = append(merged, info) | |||
| 115 | + | } | |||
| 116 | + | return merged | |||
| 117 | + | } | |||
| 118 | + | ||||
| 100 | func toBackupJobResponse(job domain.BackupJob) dto.BackupJob { | 119 | func toBackupJobResponse(job domain.BackupJob) dto.BackupJob { | ||
| 101 | var startedAt *time.Time | 120 | var startedAt *time.Time | ||
| 102 | if !job.StartedAt.IsZero() { | 121 | if !job.StartedAt.IsZero() { | ||
| @@ -527,7 +546,13 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD | |||||
| 527 | 546 | ||||
| 528 | if routeDomain == "" { | 547 | if routeDomain == "" { | ||
| 529 | if r.URL.Query().Get("detailed") == "true" { | 548 | if r.URL.Query().Get("detailed") == "true" { | ||
| 530 | - | routes := h.containerSvc.ListRoutesWithDetails(ctx) | 549 | + | if err := h.containerSvc.SyncContainers(ctx); err != nil { |
| 550 | + | h.log.Error().Err(err).Msg("failed to sync containers before detailed route listing") | |||
| 551 | + | h.sendError(w, http.StatusInternalServerError, "failed to list routes") | |||
| 552 | + | return | |||
| 553 | + | } | |||
| 554 | + | configured := h.configSvc.GetRoutes(ctx) | |||
| 555 | + | routes := mergeConfiguredRouteDetails(configured, h.containerSvc.ListRoutesWithDetails(ctx)) | |||
| 531 | response := make([]routeInfoResponse, 0, len(routes)) | 556 | response := make([]routeInfoResponse, 0, len(routes)) | ||
| 532 | for _, route := range routes { | 557 | for _, route := range routes { | ||
| 533 | response = append(response, toRouteInfoResponse(route)) | 558 | response = append(response, toRouteInfoResponse(route)) | ||
M internal/adapters/in/http/admin/handler_test.go +39 -0
| @@ -1357,6 +1357,45 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) { | |||
| 1357 | 1357 | assert.Len(t, response.Routes, 2) | |
| 1358 | 1358 | } | |
| 1359 | 1359 | ||
| 1360 | + | func TestHandler_RoutesGet_DetailedSyncsAndIncludesConfiguredRouteWithoutContainer(t *testing.T) { | |
| 1361 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 1362 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 1363 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 1364 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 1365 | + | ||
| 1366 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 1367 | + | d.ConfigSvc = configSvc | |
| 1368 | + | d.AuthSvc = authSvc | |
| 1369 | + | d.ContainerSvc = containerSvc | |
| 1370 | + | d.SecretSvc = secretSvc | |
| 1371 | + | }) | |
| 1372 | + | ||
| 1373 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{ | |
| 1374 | + | Domain: "app.example.com", | |
| 1375 | + | Image: "app:latest", | |
| 1376 | + | }}).Once() | |
| 1377 | + | syncCall := containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once() | |
| 1378 | + | listCall := containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return(nil).Once() | |
| 1379 | + | mock.InOrder(syncCall, listCall) | |
| 1380 | + | ||
| 1381 | + | req := httptest.NewRequest("GET", "/admin/routes?detailed=true", nil) | |
| 1382 | + | req = req.WithContext(ctxWithScopes("admin:routes:read")) | |
| 1383 | + | rec := httptest.NewRecorder() | |
| 1384 | + | ||
| 1385 | + | handler.ServeHTTP(rec, req) | |
| 1386 | + | ||
| 1387 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 1388 | + | ||
| 1389 | + | var response dto.RoutesDetailResponse | |
| 1390 | + | err := json.NewDecoder(rec.Body).Decode(&response) | |
| 1391 | + | require.NoError(t, err) | |
| 1392 | + | require.Len(t, response.Routes, 1) | |
| 1393 | + | assert.Equal(t, "app.example.com", response.Routes[0].Domain) | |
| 1394 | + | assert.Equal(t, "app:latest", response.Routes[0].Image) | |
| 1395 | + | assert.Empty(t, response.Routes[0].ContainerID) | |
| 1396 | + | assert.Empty(t, response.Routes[0].ContainerStatus) | |
| 1397 | + | } | |
| 1398 | + | ||
| 1360 | 1399 | func TestHandler_RoutesGet_SingleRoute(t *testing.T) { | |
| 1361 | 1400 | configSvc := inmocks.NewMockConfigService(t) | |
| 1362 | 1401 | authSvc := inmocks.NewMockAuthService(t) | |
| @@ -1357,6 +1357,45 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) { | |||||
| 1357 | assert.Len(t, response.Routes, 2) | 1357 | assert.Len(t, response.Routes, 2) | ||
| 1358 | } | 1358 | } | ||
| 1359 | 1359 | ||||
| 1360 | + | func TestHandler_RoutesGet_DetailedSyncsAndIncludesConfiguredRouteWithoutContainer(t *testing.T) { | |||
| 1361 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1362 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 1363 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1364 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1365 | + | ||||
| 1366 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 1367 | + | d.ConfigSvc = configSvc | |||
| 1368 | + | d.AuthSvc = authSvc | |||
| 1369 | + | d.ContainerSvc = containerSvc | |||
| 1370 | + | d.SecretSvc = secretSvc | |||
| 1371 | + | }) | |||
| 1372 | + | ||||
| 1373 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{ | |||
| 1374 | + | Domain: "app.example.com", | |||
| 1375 | + | Image: "app:latest", | |||
| 1376 | + | }}).Once() | |||
| 1377 | + | syncCall := containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once() | |||
| 1378 | + | listCall := containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return(nil).Once() | |||
| 1379 | + | mock.InOrder(syncCall, listCall) | |||
| 1380 | + | ||||
| 1381 | + | req := httptest.NewRequest("GET", "/admin/routes?detailed=true", nil) | |||
| 1382 | + | req = req.WithContext(ctxWithScopes("admin:routes:read")) | |||
| 1383 | + | rec := httptest.NewRecorder() | |||
| 1384 | + | ||||
| 1385 | + | handler.ServeHTTP(rec, req) | |||
| 1386 | + | ||||
| 1387 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 1388 | + | ||||
| 1389 | + | var response dto.RoutesDetailResponse | |||
| 1390 | + | err := json.NewDecoder(rec.Body).Decode(&response) | |||
| 1391 | + | require.NoError(t, err) | |||
| 1392 | + | require.Len(t, response.Routes, 1) | |||
| 1393 | + | assert.Equal(t, "app.example.com", response.Routes[0].Domain) | |||
| 1394 | + | assert.Equal(t, "app:latest", response.Routes[0].Image) | |||
| 1395 | + | assert.Empty(t, response.Routes[0].ContainerID) | |||
| 1396 | + | assert.Empty(t, response.Routes[0].ContainerStatus) | |||
| 1397 | + | } | |||
| 1398 | + | ||||
| 1360 | func TestHandler_RoutesGet_SingleRoute(t *testing.T) { | 1399 | func TestHandler_RoutesGet_SingleRoute(t *testing.T) { | ||
| 1361 | configSvc := inmocks.NewMockConfigService(t) | 1400 | configSvc := inmocks.NewMockConfigService(t) | ||
| 1362 | authSvc := inmocks.NewMockAuthService(t) | 1401 | authSvc := inmocks.NewMockAuthService(t) | ||