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 }
38}
39
40func (h *Handler) handleImages(w http.ResponseWriter, r *http.Request, path string) {
41 if path != "/images" && path != "/images/prune" {
42 h.sendError(w, http.StatusNotFound, "route not found")
43 return
44 }
45
46 if h.imageSvc == nil {
47 h.sendError(w, http.StatusServiceUnavailable, "image service not available")
48 return
49 }
50
51 switch {
52 case path == "/images" && r.Method == http.MethodGet:
53 h.handleImagesGet(w, r)
54 case path == "/images/prune" && r.Method == http.MethodPost:
55 h.handleImagesPrune(w, r)
56 default:
57 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
58 }
59}
60
61func (h *Handler) handleImagesGet(w http.ResponseWriter, r *http.Request) {
62 ctx := r.Context()
63
64 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
65 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
66 return
67 }
68
69 images, err := h.imageSvc.ListImages(ctx)
70 if err != nil {
71 h.sendError(w, http.StatusInternalServerError, "failed to list images")
72 return
73 }
74
75 response := make([]dto.Image, 0, len(images))
76 for _, image := range images {
77 response = append(response, toImageResponse(image))
78 }
79
80 h.sendJSON(w, http.StatusOK, dto.ImagesResponse{Images: response})
81}
82
83func (h *Handler) handleImagesPrune(w http.ResponseWriter, r *http.Request) {
84 ctx := r.Context()
85 log := zerowrap.FromCtx(ctx)
86
87 if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
88 h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
89 return
90 }
91
92 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
93 var req dto.ImagePruneRequest
94 if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
95 log.Warn().Err(err).Msg("invalid image prune JSON")
96 h.sendError(w, http.StatusBadRequest, "invalid JSON")
97 return
98 }
99
100 opts := domain.DefaultImagePruneOptions()
101 if req.KeepLast != nil {
102 if *req.KeepLast < 0 {
103 h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0")
104 return
105 }
106 opts.KeepLast = *req.KeepLast
107 }
108 if req.PruneDangling != nil {
109 opts.PruneDangling = *req.PruneDangling
110 }
111 if req.PruneRegistry != nil {
112 opts.PruneRegistry = *req.PruneRegistry
113 }
114 if !opts.PruneDangling && !opts.PruneRegistry {
115 h.sendError(w, http.StatusBadRequest, "at least one prune scope must be enabled")
116 return
117 }
118
119 report, err := h.imageSvc.Prune(ctx, opts)
120 if err != nil {
121 log.Error().Err(err).Int("keep_last", opts.KeepLast).Msg("image prune failed")
122 h.sendError(w, http.StatusInternalServerError, "failed to prune images")
123 return
124 }
125
126 h.sendJSON(w, http.StatusOK, toImagePruneResponse(report))
127}