db84a2f6ba
req.Tag was only echoed in the HTTP response, never used to match jobs. Compose mode didn't care (ComposeExecutor re-pulls the compose file's own pinned tag), but SwarmExecutor sets the service image directly from Job.Image, which was built from the untagged req.Image alone -- so a Swarm deploy silently rewrote the service to :latest instead of the requested tag. Build the full image:tag reference once in the handler and pass it into FindJobs; NormaliseImage/ImagesMatch already strip tags before matching, so this doesn't change which jobs match in either mode.
149 lines
4.0 KiB
Go
149 lines
4.0 KiB
Go
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
|
|
}
|
|
|
|
requestedImage := req.Image
|
|
if req.Tag != "" {
|
|
requestedImage = req.Image + ":" + req.Tag
|
|
}
|
|
|
|
jobs, err := h.finder.FindJobs(r.Context(), requestedImage)
|
|
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,
|
|
ServiceID: res.Job.ServiceID,
|
|
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})
|
|
}
|