detached 43db1bb

Merge pull request #50 from bnema/feature/restore-admin-restart-tags

restore admin restart and tags endpoints
commit
43db1bbc8a551e2f088271f51ce6c67330d4f143
author
bnema · · 2026-02-02 19:11 UTC
committer
GitHub · · 2026-02-02 19:11 UTC
merge of
893d8fa + 9cfe7dc · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.10.1
10 files · +562 -0
A internal/adapters/dto/admin_repository_tags.go +7 -0
@@ -0,0 +1,7 @@
1+package dto
2+
3+// RepositoryTagsResponse represents a repository tags listing response.
4+type RepositoryTagsResponse struct {
5+ Repository string `json:"repository"`
6+ Tags []string `json:"tags"`
7+}
@@ -0,0 +1,7 @@
1+package dto
2+
3+// RepositoryTagsResponse represents a repository tags listing response.
4+type RepositoryTagsResponse struct {
5+ Repository string `json:"repository"`
6+ Tags []string `json:"tags"`
7+}
A internal/adapters/dto/admin_restart.go +7 -0
@@ -0,0 +1,7 @@
1+package dto
2+
3+// RestartResponse represents a restart response.
4+type RestartResponse struct {
5+ Status string `json:"status"`
6+ Domain string `json:"domain"`
7+}
@@ -0,0 +1,7 @@
1+package dto
2+
3+// RestartResponse represents a restart response.
4+type RestartResponse struct {
5+ Status string `json:"status"`
6+ Domain string `json:"domain"`
7+}
A internal/adapters/in/cli/push_test.go +62 -0
@@ -0,0 +1,62 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestValidateBuildArg(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ arg string
13+ wantErr bool
14+ }{
15+ {"valid simple", "FOO=bar", false},
16+ {"valid with underscore key", "_FOO=bar", false},
17+ {"valid with numbers in key", "FOO123=bar", false},
18+ {"valid empty value", "FOO=", false},
19+ {"valid complex value", "FOO=bar baz=qux", false},
20+ {"invalid no equals", "FOO", true},
21+ {"invalid starts with number", "1FOO=bar", true},
22+ {"invalid starts with dash", "-FOO=bar", true},
23+ {"invalid special chars in key", "FOO-BAR=baz", true},
24+ {"invalid empty string", "", true},
25+ {"invalid equals only", "=value", true},
26+ }
27+
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ err := validateBuildArg(tt.arg)
31+ if tt.wantErr {
32+ assert.Error(t, err)
33+ } else {
34+ assert.NoError(t, err)
35+ }
36+ })
37+ }
38+}
39+
40+func TestParseImageRef(t *testing.T) {
41+ tests := []struct {
42+ name string
43+ image string
44+ wantRegistry string
45+ wantName string
46+ wantTag string
47+ }{
48+ {"full ref", "reg.example.com/myapp:v1.0.0", "reg.example.com", "myapp", "v1.0.0"},
49+ {"no tag", "reg.example.com/myapp", "reg.example.com", "myapp", "latest"},
50+ {"latest tag", "reg.example.com/myapp:latest", "reg.example.com", "myapp", "latest"},
51+ {"no slash", "myapp:v1.0.0", "", "", ""},
52+ }
53+
54+ for _, tt := range tests {
55+ t.Run(tt.name, func(t *testing.T) {
56+ registry, name, tag := parseImageRef(tt.image)
57+ assert.Equal(t, tt.wantRegistry, registry)
58+ assert.Equal(t, tt.wantName, name)
59+ assert.Equal(t, tt.wantTag, tag)
60+ })
61+ }
62+}
@@ -0,0 +1,62 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestValidateBuildArg(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ arg string
13+ wantErr bool
14+ }{
15+ {"valid simple", "FOO=bar", false},
16+ {"valid with underscore key", "_FOO=bar", false},
17+ {"valid with numbers in key", "FOO123=bar", false},
18+ {"valid empty value", "FOO=", false},
19+ {"valid complex value", "FOO=bar baz=qux", false},
20+ {"invalid no equals", "FOO", true},
21+ {"invalid starts with number", "1FOO=bar", true},
22+ {"invalid starts with dash", "-FOO=bar", true},
23+ {"invalid special chars in key", "FOO-BAR=baz", true},
24+ {"invalid empty string", "", true},
25+ {"invalid equals only", "=value", true},
26+ }
27+
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ err := validateBuildArg(tt.arg)
31+ if tt.wantErr {
32+ assert.Error(t, err)
33+ } else {
34+ assert.NoError(t, err)
35+ }
36+ })
37+ }
38+}
39+
40+func TestParseImageRef(t *testing.T) {
41+ tests := []struct {
42+ name string
43+ image string
44+ wantRegistry string
45+ wantName string
46+ wantTag string
47+ }{
48+ {"full ref", "reg.example.com/myapp:v1.0.0", "reg.example.com", "myapp", "v1.0.0"},
49+ {"no tag", "reg.example.com/myapp", "reg.example.com", "myapp", "latest"},
50+ {"latest tag", "reg.example.com/myapp:latest", "reg.example.com", "myapp", "latest"},
51+ {"no slash", "myapp:v1.0.0", "", "", ""},
52+ }
53+
54+ for _, tt := range tests {
55+ t.Run(tt.name, func(t *testing.T) {
56+ registry, name, tag := parseImageRef(tt.image)
57+ assert.Equal(t, tt.wantRegistry, registry)
58+ assert.Equal(t, tt.wantName, name)
59+ assert.Equal(t, tt.wantTag, tag)
60+ })
61+ }
62+}
M internal/adapters/in/http/admin/handler.go +92 -0
@@ -15,6 +15,7 @@ import (
1515 "github.com/bnema/gordon/internal/boundaries/in"
1616 "github.com/bnema/gordon/internal/boundaries/out"
1717 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/internal/usecase/registry"
1819 "github.com/bnema/gordon/pkg/validation"
1920 )
2021
@@ -160,6 +161,8 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
160161 {"/routes", h.handleRoutes},
161162 {"/secrets", h.handleSecrets},
162163 {"/deploy", h.handleDeploy},
164+ {"/restart", h.handleRestart},
165+ {"/tags", h.handleTags},
163166 {"/logs", h.handleLogs},
164167 }
165168 for _, route := range prefixRoutes {
@@ -760,6 +763,95 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
760763 })
761764 }
762765
766+// handleRestart handles /admin/restart/:domain endpoint.
767+func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path string) {
768+ if r.Method != http.MethodPost {
769+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
770+ return
771+ }
772+
773+ ctx := r.Context()
774+ log := zerowrap.FromCtx(ctx)
775+
776+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
777+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
778+ return
779+ }
780+
781+ restartDomain := strings.TrimPrefix(path, "/restart/")
782+ if restartDomain == "" || restartDomain == "/restart" {
783+ h.sendError(w, http.StatusBadRequest, "domain required in path")
784+ return
785+ }
786+ if strings.Contains(restartDomain, "..") || strings.ContainsAny(restartDomain, "\x00\n\r") {
787+ h.sendError(w, http.StatusBadRequest, "invalid domain")
788+ return
789+ }
790+
791+ withAttachments := r.URL.Query().Get("attachments") == "true"
792+
793+ if err := h.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil {
794+ log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container")
795+ if errors.Is(err, domain.ErrContainerNotFound) {
796+ h.sendError(w, http.StatusNotFound, "container not found")
797+ return
798+ }
799+ h.sendError(w, http.StatusInternalServerError, "failed to restart container")
800+ return
801+ }
802+
803+ log.Info().Str("domain", restartDomain).Bool("with_attachments", withAttachments).Msg("container restarted via admin API")
804+ h.sendJSON(w, http.StatusOK, dto.RestartResponse{
805+ Status: "restarted",
806+ Domain: restartDomain,
807+ })
808+}
809+
810+// handleTags handles /admin/tags/:repository endpoint.
811+func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string) {
812+ if r.Method != http.MethodGet {
813+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
814+ return
815+ }
816+
817+ ctx := r.Context()
818+
819+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
820+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
821+ return
822+ }
823+
824+ repository := strings.TrimPrefix(path, "/tags/")
825+ if repository == "" || repository == "/tags" {
826+ h.sendError(w, http.StatusBadRequest, "repository name required in path")
827+ return
828+ }
829+ if err := validation.ValidateRepositoryName(repository); err != nil {
830+ h.sendError(w, http.StatusBadRequest, "invalid repository name")
831+ return
832+ }
833+
834+ if h.registrySvc == nil {
835+ h.sendError(w, http.StatusServiceUnavailable, "registry service not available")
836+ return
837+ }
838+
839+ tags, err := h.registrySvc.ListTags(ctx, repository)
840+ if err != nil {
841+ if errors.Is(err, registry.ErrRepositoryNotFound) {
842+ h.sendError(w, http.StatusNotFound, "repository not found")
843+ return
844+ }
845+ h.sendError(w, http.StatusInternalServerError, "failed to list tags")
846+ return
847+ }
848+
849+ h.sendJSON(w, http.StatusOK, dto.RepositoryTagsResponse{
850+ Repository: repository,
851+ Tags: tags,
852+ })
853+}
854+
763855 // handleLogs handles /admin/logs endpoints.
764856 // GET /admin/logs - Gordon process logs
765857 // GET /admin/logs/:domain - Container logs for a specific domain
@@ -15,6 +15,7 @@ import (
15 "github.com/bnema/gordon/internal/boundaries/in"15 "github.com/bnema/gordon/internal/boundaries/in"
16 "github.com/bnema/gordon/internal/boundaries/out"16 "github.com/bnema/gordon/internal/boundaries/out"
17 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/internal/usecase/registry"
18 "github.com/bnema/gordon/pkg/validation"19 "github.com/bnema/gordon/pkg/validation"
19 )20 )
20 21
@@ -160,6 +161,8 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
160 {"/routes", h.handleRoutes},161 {"/routes", h.handleRoutes},
161 {"/secrets", h.handleSecrets},162 {"/secrets", h.handleSecrets},
162 {"/deploy", h.handleDeploy},163 {"/deploy", h.handleDeploy},
164+ {"/restart", h.handleRestart},
165+ {"/tags", h.handleTags},
163 {"/logs", h.handleLogs},166 {"/logs", h.handleLogs},
164 }167 }
165 for _, route := range prefixRoutes {168 for _, route := range prefixRoutes {
@@ -760,6 +763,95 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
760 })763 })
761 }764 }
762 765
766+// handleRestart handles /admin/restart/:domain endpoint.
767+func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path string) {
768+ if r.Method != http.MethodPost {
769+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
770+ return
771+ }
772+
773+ ctx := r.Context()
774+ log := zerowrap.FromCtx(ctx)
775+
776+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
777+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
778+ return
779+ }
780+
781+ restartDomain := strings.TrimPrefix(path, "/restart/")
782+ if restartDomain == "" || restartDomain == "/restart" {
783+ h.sendError(w, http.StatusBadRequest, "domain required in path")
784+ return
785+ }
786+ if strings.Contains(restartDomain, "..") || strings.ContainsAny(restartDomain, "\x00\n\r") {
787+ h.sendError(w, http.StatusBadRequest, "invalid domain")
788+ return
789+ }
790+
791+ withAttachments := r.URL.Query().Get("attachments") == "true"
792+
793+ if err := h.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil {
794+ log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container")
795+ if errors.Is(err, domain.ErrContainerNotFound) {
796+ h.sendError(w, http.StatusNotFound, "container not found")
797+ return
798+ }
799+ h.sendError(w, http.StatusInternalServerError, "failed to restart container")
800+ return
801+ }
802+
803+ log.Info().Str("domain", restartDomain).Bool("with_attachments", withAttachments).Msg("container restarted via admin API")
804+ h.sendJSON(w, http.StatusOK, dto.RestartResponse{
805+ Status: "restarted",
806+ Domain: restartDomain,
807+ })
808+}
809+
810+// handleTags handles /admin/tags/:repository endpoint.
811+func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string) {
812+ if r.Method != http.MethodGet {
813+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
814+ return
815+ }
816+
817+ ctx := r.Context()
818+
819+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
820+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
821+ return
822+ }
823+
824+ repository := strings.TrimPrefix(path, "/tags/")
825+ if repository == "" || repository == "/tags" {
826+ h.sendError(w, http.StatusBadRequest, "repository name required in path")
827+ return
828+ }
829+ if err := validation.ValidateRepositoryName(repository); err != nil {
830+ h.sendError(w, http.StatusBadRequest, "invalid repository name")
831+ return
832+ }
833+
834+ if h.registrySvc == nil {
835+ h.sendError(w, http.StatusServiceUnavailable, "registry service not available")
836+ return
837+ }
838+
839+ tags, err := h.registrySvc.ListTags(ctx, repository)
840+ if err != nil {
841+ if errors.Is(err, registry.ErrRepositoryNotFound) {
842+ h.sendError(w, http.StatusNotFound, "repository not found")
843+ return
844+ }
845+ h.sendError(w, http.StatusInternalServerError, "failed to list tags")
846+ return
847+ }
848+
849+ h.sendJSON(w, http.StatusOK, dto.RepositoryTagsResponse{
850+ Repository: repository,
851+ Tags: tags,
852+ })
853+}
854+
763 // handleLogs handles /admin/logs endpoints.855 // handleLogs handles /admin/logs endpoints.
764 // GET /admin/logs - Gordon process logs856 // GET /admin/logs - Gordon process logs
765 // GET /admin/logs/:domain - Container logs for a specific domain857 // GET /admin/logs/:domain - Container logs for a specific domain
M internal/adapters/in/http/admin/handler_test.go +196 -0
@@ -844,6 +844,202 @@ func TestHandler_MethodNotAllowed(t *testing.T) {
844844 }
845845 }
846846
847+func TestHandler_Restart_InvalidDomain(t *testing.T) {
848+ configSvc := inmocks.NewMockConfigService(t)
849+ authSvc := inmocks.NewMockAuthService(t)
850+ containerSvc := inmocks.NewMockContainerService(t)
851+ secretSvc := inmocks.NewMockSecretService(t)
852+
853+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
854+
855+ tests := []struct {
856+ name string
857+ path string
858+ status int
859+ }{
860+ {"path traversal", "/admin/restart/../../etc/passwd", http.StatusBadRequest},
861+ {"empty domain", "/admin/restart/", http.StatusBadRequest},
862+ }
863+
864+ for _, tt := range tests {
865+ t.Run(tt.name, func(t *testing.T) {
866+ req := httptest.NewRequest("POST", tt.path, nil)
867+ req = req.WithContext(ctxWithScopes("admin:config:write"))
868+ rec := httptest.NewRecorder()
869+
870+ handler.ServeHTTP(rec, req)
871+
872+ assert.Equal(t, tt.status, rec.Code)
873+ })
874+ }
875+}
876+
877+func TestHandler_Restart_ContainerNotFound(t *testing.T) {
878+ configSvc := inmocks.NewMockConfigService(t)
879+ authSvc := inmocks.NewMockAuthService(t)
880+ containerSvc := inmocks.NewMockContainerService(t)
881+ secretSvc := inmocks.NewMockSecretService(t)
882+
883+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
884+
885+ containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound)
886+
887+ req := httptest.NewRequest("POST", "/admin/restart/nonexistent.example.com", nil)
888+ req = req.WithContext(ctxWithScopes("admin:config:write"))
889+ rec := httptest.NewRecorder()
890+
891+ handler.ServeHTTP(rec, req)
892+
893+ assert.Equal(t, http.StatusNotFound, rec.Code)
894+}
895+
896+func TestHandler_Restart_GenericError(t *testing.T) {
897+ configSvc := inmocks.NewMockConfigService(t)
898+ authSvc := inmocks.NewMockAuthService(t)
899+ containerSvc := inmocks.NewMockContainerService(t)
900+ secretSvc := inmocks.NewMockSecretService(t)
901+
902+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
903+
904+ containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details"))
905+
906+ req := httptest.NewRequest("POST", "/admin/restart/test.example.com", nil)
907+ req = req.WithContext(ctxWithScopes("admin:config:write"))
908+ rec := httptest.NewRecorder()
909+
910+ handler.ServeHTTP(rec, req)
911+
912+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
913+ // Verify internal error details are NOT leaked
914+ assert.NotContains(t, rec.Body.String(), "docker daemon error")
915+ assert.NotContains(t, rec.Body.String(), "secret details")
916+}
917+
918+func TestHandler_Restart_Success(t *testing.T) {
919+ configSvc := inmocks.NewMockConfigService(t)
920+ authSvc := inmocks.NewMockAuthService(t)
921+ containerSvc := inmocks.NewMockContainerService(t)
922+ secretSvc := inmocks.NewMockSecretService(t)
923+
924+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
925+
926+ tests := []struct {
927+ name string
928+ path string
929+ withAttachments bool
930+ }{
931+ {
932+ name: "without attachments",
933+ path: "/admin/restart/test.example.com",
934+ withAttachments: false,
935+ },
936+ {
937+ name: "with attachments",
938+ path: "/admin/restart/test.example.com?attachments=true",
939+ withAttachments: true,
940+ },
941+ }
942+
943+ for _, tt := range tests {
944+ t.Run(tt.name, func(t *testing.T) {
945+ containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", tt.withAttachments).Return(nil)
946+
947+ req := httptest.NewRequest("POST", tt.path, nil)
948+ req = req.WithContext(ctxWithScopes("admin:config:write"))
949+ rec := httptest.NewRecorder()
950+
951+ handler.ServeHTTP(rec, req)
952+
953+ assert.Equal(t, http.StatusOK, rec.Code)
954+ var response struct {
955+ Status string `json:"status"`
956+ Domain string `json:"domain"`
957+ }
958+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
959+ assert.Equal(t, "restarted", response.Status)
960+ assert.Equal(t, "test.example.com", response.Domain)
961+ })
962+ }
963+}
964+
965+func TestHandler_Tags_InvalidRepository(t *testing.T) {
966+ configSvc := inmocks.NewMockConfigService(t)
967+ authSvc := inmocks.NewMockAuthService(t)
968+ containerSvc := inmocks.NewMockContainerService(t)
969+ secretSvc := inmocks.NewMockSecretService(t)
970+
971+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
972+
973+ tests := []struct {
974+ name string
975+ path string
976+ status int
977+ }{
978+ {"path traversal", "/admin/tags/../../etc/passwd", http.StatusBadRequest},
979+ {"empty repo", "/admin/tags/", http.StatusBadRequest},
980+ }
981+
982+ for _, tt := range tests {
983+ t.Run(tt.name, func(t *testing.T) {
984+ req := httptest.NewRequest("GET", tt.path, nil)
985+ req = req.WithContext(ctxWithScopes("admin:status:read"))
986+ rec := httptest.NewRecorder()
987+
988+ handler.ServeHTTP(rec, req)
989+
990+ assert.Equal(t, tt.status, rec.Code)
991+ })
992+ }
993+}
994+
995+func TestHandler_Tags_Success(t *testing.T) {
996+ configSvc := inmocks.NewMockConfigService(t)
997+ authSvc := inmocks.NewMockAuthService(t)
998+ containerSvc := inmocks.NewMockContainerService(t)
999+ secretSvc := inmocks.NewMockSecretService(t)
1000+ registrySvc := inmocks.NewMockRegistryService(t)
1001+
1002+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1003+
1004+ registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
1005+
1006+ req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1007+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1008+ rec := httptest.NewRecorder()
1009+
1010+ handler.ServeHTTP(rec, req)
1011+
1012+ assert.Equal(t, http.StatusOK, rec.Code)
1013+ var response struct {
1014+ Repository string `json:"repository"`
1015+ Tags []string `json:"tags"`
1016+ }
1017+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1018+ assert.Equal(t, "myapp", response.Repository)
1019+ assert.Equal(t, []string{"latest", "v1.0"}, response.Tags)
1020+}
1021+
1022+func TestHandler_Tags_Error(t *testing.T) {
1023+ configSvc := inmocks.NewMockConfigService(t)
1024+ authSvc := inmocks.NewMockAuthService(t)
1025+ containerSvc := inmocks.NewMockContainerService(t)
1026+ secretSvc := inmocks.NewMockSecretService(t)
1027+ registrySvc := inmocks.NewMockRegistryService(t)
1028+
1029+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1030+
1031+ registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
1032+
1033+ req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1034+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1035+ rec := httptest.NewRecorder()
1036+
1037+ handler.ServeHTTP(rec, req)
1038+
1039+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
1040+ assert.NotContains(t, rec.Body.String(), "registry error")
1041+}
1042+
8471043 func TestHandler_NotFound(t *testing.T) {
8481044 configSvc := inmocks.NewMockConfigService(t)
8491045 authSvc := inmocks.NewMockAuthService(t)
@@ -844,6 +844,202 @@ func TestHandler_MethodNotAllowed(t *testing.T) {
844 }844 }
845 }845 }
846 846
847+func TestHandler_Restart_InvalidDomain(t *testing.T) {
848+ configSvc := inmocks.NewMockConfigService(t)
849+ authSvc := inmocks.NewMockAuthService(t)
850+ containerSvc := inmocks.NewMockContainerService(t)
851+ secretSvc := inmocks.NewMockSecretService(t)
852+
853+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
854+
855+ tests := []struct {
856+ name string
857+ path string
858+ status int
859+ }{
860+ {"path traversal", "/admin/restart/../../etc/passwd", http.StatusBadRequest},
861+ {"empty domain", "/admin/restart/", http.StatusBadRequest},
862+ }
863+
864+ for _, tt := range tests {
865+ t.Run(tt.name, func(t *testing.T) {
866+ req := httptest.NewRequest("POST", tt.path, nil)
867+ req = req.WithContext(ctxWithScopes("admin:config:write"))
868+ rec := httptest.NewRecorder()
869+
870+ handler.ServeHTTP(rec, req)
871+
872+ assert.Equal(t, tt.status, rec.Code)
873+ })
874+ }
875+}
876+
877+func TestHandler_Restart_ContainerNotFound(t *testing.T) {
878+ configSvc := inmocks.NewMockConfigService(t)
879+ authSvc := inmocks.NewMockAuthService(t)
880+ containerSvc := inmocks.NewMockContainerService(t)
881+ secretSvc := inmocks.NewMockSecretService(t)
882+
883+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
884+
885+ containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound)
886+
887+ req := httptest.NewRequest("POST", "/admin/restart/nonexistent.example.com", nil)
888+ req = req.WithContext(ctxWithScopes("admin:config:write"))
889+ rec := httptest.NewRecorder()
890+
891+ handler.ServeHTTP(rec, req)
892+
893+ assert.Equal(t, http.StatusNotFound, rec.Code)
894+}
895+
896+func TestHandler_Restart_GenericError(t *testing.T) {
897+ configSvc := inmocks.NewMockConfigService(t)
898+ authSvc := inmocks.NewMockAuthService(t)
899+ containerSvc := inmocks.NewMockContainerService(t)
900+ secretSvc := inmocks.NewMockSecretService(t)
901+
902+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
903+
904+ containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details"))
905+
906+ req := httptest.NewRequest("POST", "/admin/restart/test.example.com", nil)
907+ req = req.WithContext(ctxWithScopes("admin:config:write"))
908+ rec := httptest.NewRecorder()
909+
910+ handler.ServeHTTP(rec, req)
911+
912+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
913+ // Verify internal error details are NOT leaked
914+ assert.NotContains(t, rec.Body.String(), "docker daemon error")
915+ assert.NotContains(t, rec.Body.String(), "secret details")
916+}
917+
918+func TestHandler_Restart_Success(t *testing.T) {
919+ configSvc := inmocks.NewMockConfigService(t)
920+ authSvc := inmocks.NewMockAuthService(t)
921+ containerSvc := inmocks.NewMockContainerService(t)
922+ secretSvc := inmocks.NewMockSecretService(t)
923+
924+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
925+
926+ tests := []struct {
927+ name string
928+ path string
929+ withAttachments bool
930+ }{
931+ {
932+ name: "without attachments",
933+ path: "/admin/restart/test.example.com",
934+ withAttachments: false,
935+ },
936+ {
937+ name: "with attachments",
938+ path: "/admin/restart/test.example.com?attachments=true",
939+ withAttachments: true,
940+ },
941+ }
942+
943+ for _, tt := range tests {
944+ t.Run(tt.name, func(t *testing.T) {
945+ containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", tt.withAttachments).Return(nil)
946+
947+ req := httptest.NewRequest("POST", tt.path, nil)
948+ req = req.WithContext(ctxWithScopes("admin:config:write"))
949+ rec := httptest.NewRecorder()
950+
951+ handler.ServeHTTP(rec, req)
952+
953+ assert.Equal(t, http.StatusOK, rec.Code)
954+ var response struct {
955+ Status string `json:"status"`
956+ Domain string `json:"domain"`
957+ }
958+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
959+ assert.Equal(t, "restarted", response.Status)
960+ assert.Equal(t, "test.example.com", response.Domain)
961+ })
962+ }
963+}
964+
965+func TestHandler_Tags_InvalidRepository(t *testing.T) {
966+ configSvc := inmocks.NewMockConfigService(t)
967+ authSvc := inmocks.NewMockAuthService(t)
968+ containerSvc := inmocks.NewMockContainerService(t)
969+ secretSvc := inmocks.NewMockSecretService(t)
970+
971+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
972+
973+ tests := []struct {
974+ name string
975+ path string
976+ status int
977+ }{
978+ {"path traversal", "/admin/tags/../../etc/passwd", http.StatusBadRequest},
979+ {"empty repo", "/admin/tags/", http.StatusBadRequest},
980+ }
981+
982+ for _, tt := range tests {
983+ t.Run(tt.name, func(t *testing.T) {
984+ req := httptest.NewRequest("GET", tt.path, nil)
985+ req = req.WithContext(ctxWithScopes("admin:status:read"))
986+ rec := httptest.NewRecorder()
987+
988+ handler.ServeHTTP(rec, req)
989+
990+ assert.Equal(t, tt.status, rec.Code)
991+ })
992+ }
993+}
994+
995+func TestHandler_Tags_Success(t *testing.T) {
996+ configSvc := inmocks.NewMockConfigService(t)
997+ authSvc := inmocks.NewMockAuthService(t)
998+ containerSvc := inmocks.NewMockContainerService(t)
999+ secretSvc := inmocks.NewMockSecretService(t)
1000+ registrySvc := inmocks.NewMockRegistryService(t)
1001+
1002+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1003+
1004+ registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
1005+
1006+ req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1007+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1008+ rec := httptest.NewRecorder()
1009+
1010+ handler.ServeHTTP(rec, req)
1011+
1012+ assert.Equal(t, http.StatusOK, rec.Code)
1013+ var response struct {
1014+ Repository string `json:"repository"`
1015+ Tags []string `json:"tags"`
1016+ }
1017+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1018+ assert.Equal(t, "myapp", response.Repository)
1019+ assert.Equal(t, []string{"latest", "v1.0"}, response.Tags)
1020+}
1021+
1022+func TestHandler_Tags_Error(t *testing.T) {
1023+ configSvc := inmocks.NewMockConfigService(t)
1024+ authSvc := inmocks.NewMockAuthService(t)
1025+ containerSvc := inmocks.NewMockContainerService(t)
1026+ secretSvc := inmocks.NewMockSecretService(t)
1027+ registrySvc := inmocks.NewMockRegistryService(t)
1028+
1029+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1030+
1031+ registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
1032+
1033+ req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1034+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1035+ rec := httptest.NewRecorder()
1036+
1037+ handler.ServeHTTP(rec, req)
1038+
1039+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
1040+ assert.NotContains(t, rec.Body.String(), "registry error")
1041+}
1042+
847 func TestHandler_NotFound(t *testing.T) {1043 func TestHandler_NotFound(t *testing.T) {
848 configSvc := inmocks.NewMockConfigService(t)1044 configSvc := inmocks.NewMockConfigService(t)
849 authSvc := inmocks.NewMockAuthService(t)1045 authSvc := inmocks.NewMockAuthService(t)
M internal/boundaries/in/container.go +4 -0
@@ -23,6 +23,10 @@ type ContainerService interface {
2323 // Get retrieves a container by domain name.
2424 Get(ctx context.Context, domain string) (*domain.Container, bool)
2525
26+ // Restart restarts a running container for the given domain.
27+ // If withAttachments is true, also restarts attachment containers.
28+ Restart(ctx context.Context, domain string, withAttachments bool) error
29+
2630 // List returns all managed containers.
2731 List(ctx context.Context) map[string]*domain.Container
2832
@@ -23,6 +23,10 @@ type ContainerService interface {
23 // Get retrieves a container by domain name.23 // Get retrieves a container by domain name.
24 Get(ctx context.Context, domain string) (*domain.Container, bool)24 Get(ctx context.Context, domain string) (*domain.Container, bool)
25 25
26+ // Restart restarts a running container for the given domain.
27+ // If withAttachments is true, also restarts attachment containers.
28+ Restart(ctx context.Context, domain string, withAttachments bool) error
29+
26 // List returns all managed containers.30 // List returns all managed containers.
27 List(ctx context.Context) map[string]*domain.Container31 List(ctx context.Context) map[string]*domain.Container
28 32
M internal/boundaries/in/mocks/mock_container_service.go +63 -0
@@ -574,6 +574,69 @@ func (_c *MockContainerService_Remove_Call) RunAndReturn(run func(ctx context.Co
574574 return _c
575575 }
576576
577+// Restart provides a mock function for the type MockContainerService
578+func (_mock *MockContainerService) Restart(ctx context.Context, domain1 string, withAttachments bool) error {
579+ ret := _mock.Called(ctx, domain1, withAttachments)
580+
581+ if len(ret) == 0 {
582+ panic("no return value specified for Restart")
583+ }
584+
585+ var r0 error
586+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) error); ok {
587+ r0 = returnFunc(ctx, domain1, withAttachments)
588+ } else {
589+ r0 = ret.Error(0)
590+ }
591+ return r0
592+}
593+
594+// MockContainerService_Restart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Restart'
595+type MockContainerService_Restart_Call struct {
596+ *mock.Call
597+}
598+
599+// Restart is a helper method to define mock.On call
600+// - ctx context.Context
601+// - domain1 string
602+// - withAttachments bool
603+func (_e *MockContainerService_Expecter) Restart(ctx interface{}, domain1 interface{}, withAttachments interface{}) *MockContainerService_Restart_Call {
604+ return &MockContainerService_Restart_Call{Call: _e.mock.On("Restart", ctx, domain1, withAttachments)}
605+}
606+
607+func (_c *MockContainerService_Restart_Call) Run(run func(ctx context.Context, domain1 string, withAttachments bool)) *MockContainerService_Restart_Call {
608+ _c.Call.Run(func(args mock.Arguments) {
609+ var arg0 context.Context
610+ if args[0] != nil {
611+ arg0 = args[0].(context.Context)
612+ }
613+ var arg1 string
614+ if args[1] != nil {
615+ arg1 = args[1].(string)
616+ }
617+ var arg2 bool
618+ if args[2] != nil {
619+ arg2 = args[2].(bool)
620+ }
621+ run(
622+ arg0,
623+ arg1,
624+ arg2,
625+ )
626+ })
627+ return _c
628+}
629+
630+func (_c *MockContainerService_Restart_Call) Return(err error) *MockContainerService_Restart_Call {
631+ _c.Call.Return(err)
632+ return _c
633+}
634+
635+func (_c *MockContainerService_Restart_Call) RunAndReturn(run func(ctx context.Context, domain1 string, withAttachments bool) error) *MockContainerService_Restart_Call {
636+ _c.Call.Return(run)
637+ return _c
638+}
639+
577640 // Shutdown provides a mock function for the type MockContainerService
578641 func (_mock *MockContainerService) Shutdown(ctx context.Context) error {
579642 ret := _mock.Called(ctx)
@@ -574,6 +574,69 @@ func (_c *MockContainerService_Remove_Call) RunAndReturn(run func(ctx context.Co
574 return _c574 return _c
575 }575 }
576 576
577+// Restart provides a mock function for the type MockContainerService
578+func (_mock *MockContainerService) Restart(ctx context.Context, domain1 string, withAttachments bool) error {
579+ ret := _mock.Called(ctx, domain1, withAttachments)
580+
581+ if len(ret) == 0 {
582+ panic("no return value specified for Restart")
583+ }
584+
585+ var r0 error
586+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, bool) error); ok {
587+ r0 = returnFunc(ctx, domain1, withAttachments)
588+ } else {
589+ r0 = ret.Error(0)
590+ }
591+ return r0
592+}
593+
594+// MockContainerService_Restart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Restart'
595+type MockContainerService_Restart_Call struct {
596+ *mock.Call
597+}
598+
599+// Restart is a helper method to define mock.On call
600+// - ctx context.Context
601+// - domain1 string
602+// - withAttachments bool
603+func (_e *MockContainerService_Expecter) Restart(ctx interface{}, domain1 interface{}, withAttachments interface{}) *MockContainerService_Restart_Call {
604+ return &MockContainerService_Restart_Call{Call: _e.mock.On("Restart", ctx, domain1, withAttachments)}
605+}
606+
607+func (_c *MockContainerService_Restart_Call) Run(run func(ctx context.Context, domain1 string, withAttachments bool)) *MockContainerService_Restart_Call {
608+ _c.Call.Run(func(args mock.Arguments) {
609+ var arg0 context.Context
610+ if args[0] != nil {
611+ arg0 = args[0].(context.Context)
612+ }
613+ var arg1 string
614+ if args[1] != nil {
615+ arg1 = args[1].(string)
616+ }
617+ var arg2 bool
618+ if args[2] != nil {
619+ arg2 = args[2].(bool)
620+ }
621+ run(
622+ arg0,
623+ arg1,
624+ arg2,
625+ )
626+ })
627+ return _c
628+}
629+
630+func (_c *MockContainerService_Restart_Call) Return(err error) *MockContainerService_Restart_Call {
631+ _c.Call.Return(err)
632+ return _c
633+}
634+
635+func (_c *MockContainerService_Restart_Call) RunAndReturn(run func(ctx context.Context, domain1 string, withAttachments bool) error) *MockContainerService_Restart_Call {
636+ _c.Call.Return(run)
637+ return _c
638+}
639+
577 // Shutdown provides a mock function for the type MockContainerService640 // Shutdown provides a mock function for the type MockContainerService
578 func (_mock *MockContainerService) Shutdown(ctx context.Context) error {641 func (_mock *MockContainerService) Shutdown(ctx context.Context) error {
579 ret := _mock.Called(ctx)642 ret := _mock.Called(ctx)
M internal/usecase/container/service.go +43 -0
@@ -212,6 +212,49 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
212212 return newContainer, nil
213213 }
214214
215+// Restart restarts a running container for the given domain.
216+func (s *Service) Restart(ctx context.Context, domainName string, withAttachments bool) error {
217+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
218+ zerowrap.FieldLayer: "usecase",
219+ zerowrap.FieldUseCase: "Restart",
220+ "domain": domainName,
221+ })
222+ log := zerowrap.FromCtx(ctx)
223+
224+ s.mu.RLock()
225+ container, exists := s.containers[domainName]
226+ attachments := s.attachments[domainName]
227+ var attachmentIDs []string
228+ if len(attachments) > 0 {
229+ attachmentIDs = make([]string, len(attachments))
230+ copy(attachmentIDs, attachments)
231+ }
232+ s.mu.RUnlock()
233+
234+ if !exists || container == nil {
235+ return domain.ErrContainerNotFound
236+ }
237+
238+ // Restart the main container
239+ if err := s.runtime.RestartContainer(ctx, container.ID); err != nil {
240+ return log.WrapErr(err, "failed to restart container")
241+ }
242+ log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted")
243+
244+ // Restart attachments if requested
245+ if withAttachments && len(attachmentIDs) > 0 {
246+ for _, attachID := range attachmentIDs {
247+ if err := s.runtime.RestartContainer(ctx, attachID); err != nil {
248+ log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to restart attachment")
249+ continue // Don't fail the whole operation for one attachment
250+ }
251+ log.Info().Str("attachment_id", attachID).Msg("attachment restarted")
252+ }
253+ }
254+
255+ return nil
256+}
257+
215258 // Stop stops a running container.
216259 func (s *Service) Stop(ctx context.Context, containerID string) error {
217260 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -212,6 +212,49 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
212 return newContainer, nil212 return newContainer, nil
213 }213 }
214 214
215+// Restart restarts a running container for the given domain.
216+func (s *Service) Restart(ctx context.Context, domainName string, withAttachments bool) error {
217+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
218+ zerowrap.FieldLayer: "usecase",
219+ zerowrap.FieldUseCase: "Restart",
220+ "domain": domainName,
221+ })
222+ log := zerowrap.FromCtx(ctx)
223+
224+ s.mu.RLock()
225+ container, exists := s.containers[domainName]
226+ attachments := s.attachments[domainName]
227+ var attachmentIDs []string
228+ if len(attachments) > 0 {
229+ attachmentIDs = make([]string, len(attachments))
230+ copy(attachmentIDs, attachments)
231+ }
232+ s.mu.RUnlock()
233+
234+ if !exists || container == nil {
235+ return domain.ErrContainerNotFound
236+ }
237+
238+ // Restart the main container
239+ if err := s.runtime.RestartContainer(ctx, container.ID); err != nil {
240+ return log.WrapErr(err, "failed to restart container")
241+ }
242+ log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted")
243+
244+ // Restart attachments if requested
245+ if withAttachments && len(attachmentIDs) > 0 {
246+ for _, attachID := range attachmentIDs {
247+ if err := s.runtime.RestartContainer(ctx, attachID); err != nil {
248+ log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to restart attachment")
249+ continue // Don't fail the whole operation for one attachment
250+ }
251+ log.Info().Str("attachment_id", attachID).Msg("attachment restarted")
252+ }
253+ }
254+
255+ return nil
256+}
257+
215 // Stop stops a running container.258 // Stop stops a running container.
216 func (s *Service) Stop(ctx context.Context, containerID string) error {259 func (s *Service) Stop(ctx context.Context, containerID string) error {
217 ctx = zerowrap.CtxWithFields(ctx, map[string]any{260 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/usecase/container/service_test.go +82 -0
@@ -3,6 +3,7 @@ package container
33 import (
44 "context"
55 "errors"
6+ "fmt"
67 "testing"
78 "time"
89
@@ -566,6 +567,87 @@ func TestService_Shutdown_PartialFailure(t *testing.T) {
566567 assert.Len(t, svc.containers, 1)
567568 }
568569
570+func TestService_Restart_NotFound(t *testing.T) {
571+ runtime := mocks.NewMockContainerRuntime(t)
572+ envLoader := mocks.NewMockEnvLoader(t)
573+ eventBus := mocks.NewMockEventPublisher(t)
574+
575+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
576+ ctx := testContext()
577+
578+ err := svc.Restart(ctx, "nonexistent.example.com", false)
579+
580+ assert.Error(t, err)
581+ assert.True(t, errors.Is(err, domain.ErrContainerNotFound))
582+}
583+
584+func TestService_Restart_Success(t *testing.T) {
585+ runtime := mocks.NewMockContainerRuntime(t)
586+ envLoader := mocks.NewMockEnvLoader(t)
587+ eventBus := mocks.NewMockEventPublisher(t)
588+
589+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
590+ ctx := testContext()
591+
592+ svc.containers["test.example.com"] = &domain.Container{
593+ ID: "container-123",
594+ Name: "gordon-test.example.com",
595+ Status: "running",
596+ }
597+
598+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
599+
600+ err := svc.Restart(ctx, "test.example.com", false)
601+
602+ assert.NoError(t, err)
603+}
604+
605+func TestService_Restart_Error(t *testing.T) {
606+ runtime := mocks.NewMockContainerRuntime(t)
607+ envLoader := mocks.NewMockEnvLoader(t)
608+ eventBus := mocks.NewMockEventPublisher(t)
609+
610+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
611+ ctx := testContext()
612+
613+ svc.containers["test.example.com"] = &domain.Container{
614+ ID: "container-123",
615+ Name: "gordon-test.example.com",
616+ Status: "running",
617+ }
618+
619+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(errors.New("restart failed"))
620+
621+ err := svc.Restart(ctx, "test.example.com", false)
622+
623+ assert.Error(t, err)
624+ assert.Contains(t, err.Error(), "failed to restart container")
625+}
626+
627+func TestService_Restart_WithAttachments(t *testing.T) {
628+ runtime := mocks.NewMockContainerRuntime(t)
629+ envLoader := mocks.NewMockEnvLoader(t)
630+ eventBus := mocks.NewMockEventPublisher(t)
631+
632+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
633+ ctx := testContext()
634+
635+ svc.containers["test.example.com"] = &domain.Container{
636+ ID: "container-123",
637+ Name: "gordon-test.example.com",
638+ Status: "running",
639+ }
640+ svc.attachments["test.example.com"] = []string{"container-attach-1", "container-attach-2"}
641+
642+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
643+ runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil)
644+ runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-2").Return(fmt.Errorf("boom"))
645+
646+ err := svc.Restart(ctx, "test.example.com", true)
647+
648+ assert.NoError(t, err)
649+}
650+
569651 func TestNormalizeImageRef(t *testing.T) {
570652 tests := []struct {
571653 name string
@@ -3,6 +3,7 @@ package container
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "fmt"
6 "testing"7 "testing"
7 "time"8 "time"
8 9
@@ -566,6 +567,87 @@ func TestService_Shutdown_PartialFailure(t *testing.T) {
566 assert.Len(t, svc.containers, 1)567 assert.Len(t, svc.containers, 1)
567 }568 }
568 569
570+func TestService_Restart_NotFound(t *testing.T) {
571+ runtime := mocks.NewMockContainerRuntime(t)
572+ envLoader := mocks.NewMockEnvLoader(t)
573+ eventBus := mocks.NewMockEventPublisher(t)
574+
575+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
576+ ctx := testContext()
577+
578+ err := svc.Restart(ctx, "nonexistent.example.com", false)
579+
580+ assert.Error(t, err)
581+ assert.True(t, errors.Is(err, domain.ErrContainerNotFound))
582+}
583+
584+func TestService_Restart_Success(t *testing.T) {
585+ runtime := mocks.NewMockContainerRuntime(t)
586+ envLoader := mocks.NewMockEnvLoader(t)
587+ eventBus := mocks.NewMockEventPublisher(t)
588+
589+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
590+ ctx := testContext()
591+
592+ svc.containers["test.example.com"] = &domain.Container{
593+ ID: "container-123",
594+ Name: "gordon-test.example.com",
595+ Status: "running",
596+ }
597+
598+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
599+
600+ err := svc.Restart(ctx, "test.example.com", false)
601+
602+ assert.NoError(t, err)
603+}
604+
605+func TestService_Restart_Error(t *testing.T) {
606+ runtime := mocks.NewMockContainerRuntime(t)
607+ envLoader := mocks.NewMockEnvLoader(t)
608+ eventBus := mocks.NewMockEventPublisher(t)
609+
610+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
611+ ctx := testContext()
612+
613+ svc.containers["test.example.com"] = &domain.Container{
614+ ID: "container-123",
615+ Name: "gordon-test.example.com",
616+ Status: "running",
617+ }
618+
619+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(errors.New("restart failed"))
620+
621+ err := svc.Restart(ctx, "test.example.com", false)
622+
623+ assert.Error(t, err)
624+ assert.Contains(t, err.Error(), "failed to restart container")
625+}
626+
627+func TestService_Restart_WithAttachments(t *testing.T) {
628+ runtime := mocks.NewMockContainerRuntime(t)
629+ envLoader := mocks.NewMockEnvLoader(t)
630+ eventBus := mocks.NewMockEventPublisher(t)
631+
632+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
633+ ctx := testContext()
634+
635+ svc.containers["test.example.com"] = &domain.Container{
636+ ID: "container-123",
637+ Name: "gordon-test.example.com",
638+ Status: "running",
639+ }
640+ svc.attachments["test.example.com"] = []string{"container-attach-1", "container-attach-2"}
641+
642+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
643+ runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil)
644+ runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-2").Return(fmt.Errorf("boom"))
645+
646+ err := svc.Restart(ctx, "test.example.com", true)
647+
648+ assert.NoError(t, err)
649+}
650+
569 func TestNormalizeImageRef(t *testing.T) {651 func TestNormalizeImageRef(t *testing.T) {
570 tests := []struct {652 tests := []struct {
571 name string653 name string
A internal/usecase/registry/errors.go +6 -0
@@ -0,0 +1,6 @@
1+package registry
2+
3+import "errors"
4+
5+// ErrRepositoryNotFound indicates a registry repository does not exist.
6+var ErrRepositoryNotFound = errors.New("repository not found")
@@ -0,0 +1,6 @@
1+package registry
2+
3+import "errors"
4+
5+// ErrRepositoryNotFound indicates a registry repository does not exist.
6+var ErrRepositoryNotFound = errors.New("repository not found")

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