package api import ( "context" "encoding/json" "net/http" "github.com/docker/docker/api/types" "github.com/shcizo/package-updater/internal/discovery" "github.com/shcizo/package-updater/internal/logging" "github.com/shcizo/package-updater/internal/metrics" "github.com/shcizo/package-updater/internal/updater" ) // Finder is the discovery interface used by the update handler. type Finder interface { FindJobs(ctx context.Context, image string) ([]discovery.Job, error) } // Submitter is the queue interface used by the update handler. type Submitter interface { Submit(ctx context.Context, jobs []discovery.Job) []updater.Result } // Pinger pings the Docker daemon for the healthcheck. type Pinger interface { Ping(ctx context.Context) (types.Ping, error) } // Handlers wires the HTTP endpoints to the rest of the service. type Handlers struct { finder Finder submitter Submitter pinger Pinger version string commit string buildTime string metrics *metrics.Metrics } // NewHandlers constructs a Handlers value with all dependencies injected. // m may be nil, in which case metrics recording is silently skipped. func NewHandlers(f Finder, s Submitter, p Pinger, version, commit, buildTime string, m *metrics.Metrics) *Handlers { return &Handlers{finder: f, submitter: s, pinger: p, version: version, commit: commit, buildTime: buildTime, metrics: m} } // Update implements POST /update. func (h *Handlers) Update(w http.ResponseWriter, r *http.Request) { var req UpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid JSON body") return } if req.Image == "" { writeJSONError(w, http.StatusBadRequest, "image is required") return } jobs, err := h.finder.FindJobs(r.Context(), req.Image) if err != nil { writeJSONError(w, http.StatusInternalServerError, "discovery failed: "+err.Error()) return } resp := UpdateResponse{ RequestID: logging.RequestIDFrom(r.Context()), Image: req.Image, Tag: req.Tag, Matched: len(jobs), Results: []ResultDTO{}, } if len(jobs) == 0 { writeJSON(w, http.StatusOK, resp) return } results := h.submitter.Submit(r.Context(), jobs) updated, failed := 0, 0 for _, res := range results { composeFile := "" if len(res.Job.ConfigFiles) > 0 { composeFile = res.Job.ConfigFiles[0] } resp.Results = append(resp.Results, ResultDTO{ Project: res.Job.Project, Service: res.Job.Service, ComposeFile: composeFile, Status: string(res.Status), Error: res.Error, DurationMs: res.DurationMs, }) if res.Status == updater.StatusUpdated { updated++ } else { failed++ } } switch { case failed == 0: writeJSON(w, http.StatusOK, resp) case updated == 0: writeJSON(w, http.StatusInternalServerError, resp) default: writeJSON(w, http.StatusMultiStatus, resp) } } // Healthz implements GET /healthz. func (h *Handlers) Healthz(w http.ResponseWriter, r *http.Request) { if _, err := h.pinger.Ping(r.Context()); err != nil { if h.metrics != nil { h.metrics.DockerPingUp.Set(0) } writeJSON(w, http.StatusServiceUnavailable, HealthResponse{Status: "unhealthy", Docker: "unreachable"}) return } if h.metrics != nil { h.metrics.DockerPingUp.Set(1) } writeJSON(w, http.StatusOK, HealthResponse{Status: "ok", Docker: "ok"}) } // Version implements GET /version. func (h *Handlers) Version(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, VersionResponse{ Version: h.version, Commit: h.commit, BuildTime: h.buildTime, }) } func writeJSON(w http.ResponseWriter, code int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(body) } func writeJSONError(w http.ResponseWriter, code int, msg string) { writeJSON(w, code, map[string]string{"error": msg}) }