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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 160 | 160 | {"/routes", h.handleRoutes}, | |
| 161 | 161 | {"/secrets", h.handleSecrets}, | |
| 162 | 162 | {"/deploy", h.handleDeploy}, | |
| 163 | + | {"/restart", h.handleRestart}, | |
| 164 | + | {"/tags", h.handleTags}, | |
| 163 | 165 | {"/logs", h.handleLogs}, | |
| 164 | 166 | } | |
| 165 | 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 | 850 | // handleLogs handles /admin/logs endpoints. | |
| 764 | 851 | // GET /admin/logs - Gordon process logs | |
| 765 | 852 | // 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 logs | 851 | // GET /admin/logs - Gordon process logs | ||
| 765 | // GET /admin/logs/:domain - Container logs for a specific domain | 852 | // 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) { | |||
| 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 | 948 | func TestHandler_NotFound(t *testing.T) { | |
| 848 | 949 | configSvc := inmocks.NewMockConfigService(t) | |
| 849 | 950 | 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 { | |||
| 23 | 23 | // Get retrieves a container by domain name. | |
| 24 | 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 | 30 | // List returns all managed containers. | |
| 27 | 31 | List(ctx context.Context) map[string]*domain.Container | |
| 28 | 32 | ||
| @@ -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.Container | 31 | 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 | |||
| 574 | 574 | 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 | 640 | // Shutdown provides a mock function for the type MockContainerService | |
| 578 | 641 | func (_mock *MockContainerService) Shutdown(ctx context.Context) error { | |
| 579 | 642 | ret := _mock.Called(ctx) | |
| @@ -574,6 +574,69 @@ func (_c *MockContainerService_Remove_Call) RunAndReturn(run func(ctx context.Co | |||||
| 574 | return _c | 574 | 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 MockContainerService | 640 | // 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 | |||
| 212 | 212 | 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 | 253 | // Stop stops a running container. | |
| 216 | 254 | func (s *Service) Stop(ctx context.Context, containerID string) error { | |
| 217 | 255 | 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, nil | 212 | 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) { | |||
| 566 | 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 | 604 | func TestNormalizeImageRef(t *testing.T) { | |
| 570 | 605 | tests := []struct { | |
| 571 | 606 | 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 string | 606 | name string | ||