detached 776f5de

fix(security): validate inputs and stop leaking internal errors in admin API

Add tag/build-arg validation in push command using validation.ValidateReference
and regex-based KEY=VALUE enforcement. Validate domain and repository path
params in handleRestart/handleTags to reject path traversal and control chars.
Return generic error messages instead of exposing internal details. Use
domain.ErrContainerNotFound sentinel to distinguish 404 vs 500 in restart.
Check Dockerfile existence before invoking docker buildx.
commit
776f5de7c242e97940ec91bd3914e6c24cfc4052
author
brice · · 2026-02-02 15:01 UTC
committer
brice · · 2026-02-02 18:54 UTC
parent
893d8fa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +404 -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 +87 -0
@@ -160,6 +160,8 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
160160 {"/routes", h.handleRoutes},
161161 {"/secrets", h.handleSecrets},
162162 {"/deploy", h.handleDeploy},
163+ {"/restart", h.handleRestart},
164+ {"/tags", h.handleTags},
163165 {"/logs", h.handleLogs},
164166 }
165167 for _, route := range prefixRoutes {
@@ -760,6 +762,91 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
760762 })
761763 }
762764
765+// handleRestart handles /admin/restart/:domain endpoint.
766+func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path string) {
767+ if r.Method != http.MethodPost {
768+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
769+ return
770+ }
771+
772+ ctx := r.Context()
773+ log := zerowrap.FromCtx(ctx)
774+
775+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
776+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
777+ return
778+ }
779+
780+ restartDomain := strings.TrimPrefix(path, "/restart/")
781+ if restartDomain == "" || restartDomain == "/restart" {
782+ h.sendError(w, http.StatusBadRequest, "domain required in path")
783+ return
784+ }
785+ if strings.Contains(restartDomain, "..") || strings.ContainsAny(restartDomain, "\x00\n\r") {
786+ h.sendError(w, http.StatusBadRequest, "invalid domain")
787+ return
788+ }
789+
790+ withAttachments := r.URL.Query().Get("attachments") == "true"
791+
792+ if err := h.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil {
793+ log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container")
794+ if errors.Is(err, domain.ErrContainerNotFound) {
795+ h.sendError(w, http.StatusNotFound, "container not found")
796+ return
797+ }
798+ h.sendError(w, http.StatusInternalServerError, "failed to restart container")
799+ return
800+ }
801+
802+ log.Info().Str("domain", restartDomain).Bool("with_attachments", withAttachments).Msg("container restarted via admin API")
803+ h.sendJSON(w, http.StatusOK, dto.RestartResponse{
804+ Status: "restarted",
805+ Domain: restartDomain,
806+ })
807+}
808+
809+// handleTags handles /admin/tags/:repository endpoint.
810+func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string) {
811+ if r.Method != http.MethodGet {
812+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
813+ return
814+ }
815+
816+ ctx := r.Context()
817+
818+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
819+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
820+ return
821+ }
822+
823+ repository := strings.TrimPrefix(path, "/tags/")
824+ if repository == "" || repository == "/tags" {
825+ h.sendError(w, http.StatusBadRequest, "repository name required in path")
826+ return
827+ }
828+ if err := validation.ValidateRepositoryName(repository); err != nil {
829+ h.sendError(w, http.StatusBadRequest, "invalid repository name")
830+ return
831+ }
832+
833+ if h.registrySvc == nil {
834+ h.sendError(w, http.StatusServiceUnavailable, "registry service not available")
835+ return
836+ }
837+
838+ tags, err := h.registrySvc.ListTags(ctx, repository)
839+ if err != nil {
840+ h.sendError(w, http.StatusInternalServerError, "failed to list tags")
841+ return
842+ }
843+
844+ h.sendJSON(w, http.StatusOK, dto.RepositoryTagsResponse{
845+ Repository: repository,
846+ Tags: tags,
847+ })
848+}
849+
763850 // handleLogs handles /admin/logs endpoints.
764851 // GET /admin/logs - Gordon process logs
765852 // GET /admin/logs/:domain - Container logs for a specific domain
@@ -160,6 +160,8 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
160 {"/routes", h.handleRoutes},160 {"/routes", h.handleRoutes},
161 {"/secrets", h.handleSecrets},161 {"/secrets", h.handleSecrets},
162 {"/deploy", h.handleDeploy},162 {"/deploy", h.handleDeploy},
163+ {"/restart", h.handleRestart},
164+ {"/tags", h.handleTags},
163 {"/logs", h.handleLogs},165 {"/logs", h.handleLogs},
164 }166 }
165 for _, route := range prefixRoutes {167 for _, route := range prefixRoutes {
@@ -760,6 +762,91 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
760 })762 })
761 }763 }
762 764
765+// handleRestart handles /admin/restart/:domain endpoint.
766+func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path string) {
767+ if r.Method != http.MethodPost {
768+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
769+ return
770+ }
771+
772+ ctx := r.Context()
773+ log := zerowrap.FromCtx(ctx)
774+
775+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
776+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
777+ return
778+ }
779+
780+ restartDomain := strings.TrimPrefix(path, "/restart/")
781+ if restartDomain == "" || restartDomain == "/restart" {
782+ h.sendError(w, http.StatusBadRequest, "domain required in path")
783+ return
784+ }
785+ if strings.Contains(restartDomain, "..") || strings.ContainsAny(restartDomain, "\x00\n\r") {
786+ h.sendError(w, http.StatusBadRequest, "invalid domain")
787+ return
788+ }
789+
790+ withAttachments := r.URL.Query().Get("attachments") == "true"
791+
792+ if err := h.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil {
793+ log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container")
794+ if errors.Is(err, domain.ErrContainerNotFound) {
795+ h.sendError(w, http.StatusNotFound, "container not found")
796+ return
797+ }
798+ h.sendError(w, http.StatusInternalServerError, "failed to restart container")
799+ return
800+ }
801+
802+ log.Info().Str("domain", restartDomain).Bool("with_attachments", withAttachments).Msg("container restarted via admin API")
803+ h.sendJSON(w, http.StatusOK, dto.RestartResponse{
804+ Status: "restarted",
805+ Domain: restartDomain,
806+ })
807+}
808+
809+// handleTags handles /admin/tags/:repository endpoint.
810+func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string) {
811+ if r.Method != http.MethodGet {
812+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
813+ return
814+ }
815+
816+ ctx := r.Context()
817+
818+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
819+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
820+ return
821+ }
822+
823+ repository := strings.TrimPrefix(path, "/tags/")
824+ if repository == "" || repository == "/tags" {
825+ h.sendError(w, http.StatusBadRequest, "repository name required in path")
826+ return
827+ }
828+ if err := validation.ValidateRepositoryName(repository); err != nil {
829+ h.sendError(w, http.StatusBadRequest, "invalid repository name")
830+ return
831+ }
832+
833+ if h.registrySvc == nil {
834+ h.sendError(w, http.StatusServiceUnavailable, "registry service not available")
835+ return
836+ }
837+
838+ tags, err := h.registrySvc.ListTags(ctx, repository)
839+ if err != nil {
840+ h.sendError(w, http.StatusInternalServerError, "failed to list tags")
841+ return
842+ }
843+
844+ h.sendJSON(w, http.StatusOK, dto.RepositoryTagsResponse{
845+ Repository: repository,
846+ Tags: tags,
847+ })
848+}
849+
763 // handleLogs handles /admin/logs endpoints.850 // handleLogs handles /admin/logs endpoints.
764 // GET /admin/logs - Gordon process logs851 // GET /admin/logs - Gordon process logs
765 // GET /admin/logs/:domain - Container logs for a specific domain852 // GET /admin/logs/:domain - Container logs for a specific domain
M internal/adapters/in/http/admin/handler_test.go +101 -0
@@ -844,6 +844,107 @@ 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_Tags_InvalidRepository(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+ status int
930+ }{
931+ {"path traversal", "/admin/tags/../../etc/passwd", http.StatusBadRequest},
932+ {"empty repo", "/admin/tags/", http.StatusBadRequest},
933+ }
934+
935+ for _, tt := range tests {
936+ t.Run(tt.name, func(t *testing.T) {
937+ req := httptest.NewRequest("GET", tt.path, nil)
938+ req = req.WithContext(ctxWithScopes("admin:status:read"))
939+ rec := httptest.NewRecorder()
940+
941+ handler.ServeHTTP(rec, req)
942+
943+ assert.Equal(t, tt.status, rec.Code)
944+ })
945+ }
946+}
947+
847948 func TestHandler_NotFound(t *testing.T) {
848949 configSvc := inmocks.NewMockConfigService(t)
849950 authSvc := inmocks.NewMockAuthService(t)
@@ -844,6 +844,107 @@ 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_Tags_InvalidRepository(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+ status int
930+ }{
931+ {"path traversal", "/admin/tags/../../etc/passwd", http.StatusBadRequest},
932+ {"empty repo", "/admin/tags/", http.StatusBadRequest},
933+ }
934+
935+ for _, tt := range tests {
936+ t.Run(tt.name, func(t *testing.T) {
937+ req := httptest.NewRequest("GET", tt.path, nil)
938+ req = req.WithContext(ctxWithScopes("admin:status:read"))
939+ rec := httptest.NewRecorder()
940+
941+ handler.ServeHTTP(rec, req)
942+
943+ assert.Equal(t, tt.status, rec.Code)
944+ })
945+ }
946+}
947+
847 func TestHandler_NotFound(t *testing.T) {948 func TestHandler_NotFound(t *testing.T) {
848 configSvc := inmocks.NewMockConfigService(t)949 configSvc := inmocks.NewMockConfigService(t)
849 authSvc := inmocks.NewMockAuthService(t)950 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 +38 -0
@@ -212,6 +212,44 @@ 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+ attachmentIDs := s.attachments[domainName]
227+ s.mu.RUnlock()
228+
229+ if !exists || container == nil {
230+ return domain.ErrContainerNotFound
231+ }
232+
233+ // Restart the main container
234+ if err := s.runtime.RestartContainer(ctx, container.ID); err != nil {
235+ return log.WrapErr(err, "failed to restart container")
236+ }
237+ log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted")
238+
239+ // Restart attachments if requested
240+ if withAttachments && len(attachmentIDs) > 0 {
241+ for _, attachID := range attachmentIDs {
242+ if err := s.runtime.RestartContainer(ctx, attachID); err != nil {
243+ log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to restart attachment")
244+ continue // Don't fail the whole operation for one attachment
245+ }
246+ log.Info().Str("attachment_id", attachID).Msg("attachment restarted")
247+ }
248+ }
249+
250+ return nil
251+}
252+
215253 // Stop stops a running container.
216254 func (s *Service) Stop(ctx context.Context, containerID string) error {
217255 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -212,6 +212,44 @@ 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+ attachmentIDs := s.attachments[domainName]
227+ s.mu.RUnlock()
228+
229+ if !exists || container == nil {
230+ return domain.ErrContainerNotFound
231+ }
232+
233+ // Restart the main container
234+ if err := s.runtime.RestartContainer(ctx, container.ID); err != nil {
235+ return log.WrapErr(err, "failed to restart container")
236+ }
237+ log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted")
238+
239+ // Restart attachments if requested
240+ if withAttachments && len(attachmentIDs) > 0 {
241+ for _, attachID := range attachmentIDs {
242+ if err := s.runtime.RestartContainer(ctx, attachID); err != nil {
243+ log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to restart attachment")
244+ continue // Don't fail the whole operation for one attachment
245+ }
246+ log.Info().Str("attachment_id", attachID).Msg("attachment restarted")
247+ }
248+ }
249+
250+ return nil
251+}
252+
215 // Stop stops a running container.253 // Stop stops a running container.
216 func (s *Service) Stop(ctx context.Context, containerID string) error {254 func (s *Service) Stop(ctx context.Context, containerID string) error {
217 ctx = zerowrap.CtxWithFields(ctx, map[string]any{255 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/usecase/container/service_test.go +35 -0
@@ -566,6 +566,41 @@ func TestService_Shutdown_PartialFailure(t *testing.T) {
566566 assert.Len(t, svc.containers, 1)
567567 }
568568
569+func TestService_Restart_NotFound(t *testing.T) {
570+ runtime := mocks.NewMockContainerRuntime(t)
571+ envLoader := mocks.NewMockEnvLoader(t)
572+ eventBus := mocks.NewMockEventPublisher(t)
573+
574+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
575+ ctx := testContext()
576+
577+ err := svc.Restart(ctx, "nonexistent.example.com", false)
578+
579+ assert.Error(t, err)
580+ assert.True(t, errors.Is(err, domain.ErrContainerNotFound))
581+}
582+
583+func TestService_Restart_Success(t *testing.T) {
584+ runtime := mocks.NewMockContainerRuntime(t)
585+ envLoader := mocks.NewMockEnvLoader(t)
586+ eventBus := mocks.NewMockEventPublisher(t)
587+
588+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
589+ ctx := testContext()
590+
591+ svc.containers["test.example.com"] = &domain.Container{
592+ ID: "container-123",
593+ Name: "gordon-test.example.com",
594+ Status: "running",
595+ }
596+
597+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
598+
599+ err := svc.Restart(ctx, "test.example.com", false)
600+
601+ assert.NoError(t, err)
602+}
603+
569604 func TestNormalizeImageRef(t *testing.T) {
570605 tests := []struct {
571606 name string
@@ -566,6 +566,41 @@ func TestService_Shutdown_PartialFailure(t *testing.T) {
566 assert.Len(t, svc.containers, 1)566 assert.Len(t, svc.containers, 1)
567 }567 }
568 568
569+func TestService_Restart_NotFound(t *testing.T) {
570+ runtime := mocks.NewMockContainerRuntime(t)
571+ envLoader := mocks.NewMockEnvLoader(t)
572+ eventBus := mocks.NewMockEventPublisher(t)
573+
574+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
575+ ctx := testContext()
576+
577+ err := svc.Restart(ctx, "nonexistent.example.com", false)
578+
579+ assert.Error(t, err)
580+ assert.True(t, errors.Is(err, domain.ErrContainerNotFound))
581+}
582+
583+func TestService_Restart_Success(t *testing.T) {
584+ runtime := mocks.NewMockContainerRuntime(t)
585+ envLoader := mocks.NewMockEnvLoader(t)
586+ eventBus := mocks.NewMockEventPublisher(t)
587+
588+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
589+ ctx := testContext()
590+
591+ svc.containers["test.example.com"] = &domain.Container{
592+ ID: "container-123",
593+ Name: "gordon-test.example.com",
594+ Status: "running",
595+ }
596+
597+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
598+
599+ err := svc.Restart(ctx, "test.example.com", false)
600+
601+ assert.NoError(t, err)
602+}
603+
569 func TestNormalizeImageRef(t *testing.T) {604 func TestNormalizeImageRef(t *testing.T) {
570 tests := []struct {605 tests := []struct {
571 name string606 name string

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