1package admin
2
3import (
4 "encoding/json"
5 "errors"
6 "io"
7 "net/http"
8
9 "github.com/bnema/zerowrap"
10
11 "github.com/bnema/gordon/internal/adapters/dto"
12 "github.com/bnema/gordon/internal/domain"
13)
14
15func toImageResponse(image domain.ImageInfo) dto.Image {
16 return dto.Image{
17 Repository: image.Repository,
18 Tag: image.Tag,
19 Size: image.Size,
20 Created: image.Created,
21 ID: image.ID,
22 Dangling: image.Dangling,
23 }
24}
25
26func toImagePruneResponse(report domain.ImagePruneReport) dto.ImagePruneResponse {
27 return dto.ImagePruneResponse{
28 Runtime: dto.RuntimePruneResult{
29 DeletedCount: report.Runtime.DeletedCount,
30 SpaceReclaimed: report.Runtime.SpaceReclaimed,
31 },
32 Registry: dto.RegistryPruneResult{
33 TagsRemoved: report.Registry.TagsRemoved,
34 BlobsRemoved: report.Registry.BlobsRemoved,
35 SpaceReclaimed: report.Registry.SpaceReclaimed,
36 },
37 Plan: toPruneSummary(report.Plan),
38 }
39}
40
41func (h *Handler) handleImages(w http.ResponseWriter, r *http.Request, path string) {
42 if path != "/images" && path != "/images/prune" {
43 h.sendError(w, http.StatusNotFound, "route not found")
44 return
45 }
46
47 if h.imageSvc == nil {
48 h.sendError(w, http.StatusServiceUnavailable, "image service not available")
49 return
50 }
51
52 switch {
53 case path == "/images" && r.Method == http.MethodGet:
54 h.handleImagesGet(w, r)
55 case path == "/images/prune" && r.Method == http.MethodPost:
56 h.handleImagesPrune(w, r)
57 default:
58 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
59 }
60}
61
62func (h *Handler) handleImagesGet(w http.ResponseWriter, r *http.Request) {
63 ctx := r.Context()
64
65 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
66 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
67 return
68 }
69
70 images, err := h.imageSvc.ListImages(ctx)
71 if err != nil {
72 h.sendError(w, http.StatusInternalServerError, "failed to list images")
73 return
74 }
75
76 response := make([]dto.Image, 0, len(images))
77 for _, image := range images {
78 response = append(response, toImageResponse(image))
79 }
80
81 h.sendJSON(w, http.StatusOK, dto.ImagesResponse{Images: response})
82}
83
84func (h *Handler) handleImagesPrune(w http.ResponseWriter, r *http.Request) {
85 ctx := r.Context()
86 log := zerowrap.FromCtx(ctx)
87
88 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
89 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
90 return
91 }
92
93 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
94 var req dto.ImagePruneRequest
95 if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
96 log.Warn().Err(err).Msg("invalid image prune JSON")
97 h.sendError(w, http.StatusBadRequest, "invalid JSON")
98 return
99 }
100
101 opts := domain.DefaultImagePruneOptions()
102 if req.KeepLast != nil {
103 if *req.KeepLast < 0 {
104 h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0")
105 return
106 }
107 opts.KeepLast = *req.KeepLast
108 }
109 if req.PruneDangling != nil {
110 opts.PruneDangling = *req.PruneDangling
111 }
112 if req.PruneRegistry != nil {
113 opts.PruneRegistry = *req.PruneRegistry
114 }
115 if req.DryRun != nil {
116 opts.DryRun = *req.DryRun
117 }
118 if !opts.PruneDangling && !opts.PruneRegistry {
119 h.sendError(w, http.StatusBadRequest, "at least one prune scope must be enabled")
120 return
121 }
122
123 report, err := h.imageSvc.Prune(ctx, opts)
124 if err != nil {
125 log.Error().Err(err).Int("keep_last", opts.KeepLast).Msg("image prune failed")
126 if errors.Is(err, domain.ErrPruneDisabled) {
127 h.sendJSON(w, http.StatusConflict, dto.AppError{Error: "prune-disabled", Message: err.Error()})
128 return
129 }
130 h.sendError(w, http.StatusInternalServerError, "failed to prune images")
131 return
132 }
133
134 h.sendJSON(w, http.StatusOK, toImagePruneResponse(report))
135}