Initial implementation: package-updater v1 #1

Merged
shcizo merged 27 commits from feat/initial-implementation into main 2026-05-22 11:59:39 +00:00
3 changed files with 340 additions and 0 deletions
Showing only changes of commit 857f504d36 - Show all commits
+133
View File
@@ -0,0 +1,133 @@
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/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
}
// NewHandlers constructs a Handlers value with all dependencies injected.
func NewHandlers(f Finder, s Submitter, p Pinger, version, commit, buildTime string) *Handlers {
return &Handlers{finder: f, submitter: s, pinger: p, version: version, commit: commit, buildTime: buildTime}
}
// 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 {
writeJSON(w, http.StatusServiceUnavailable, HealthResponse{Status: "unhealthy", Docker: "unreachable"})
return
}
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})
}
+168
View File
@@ -0,0 +1,168 @@
package api_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/docker/docker/api/types"
"github.com/shcizo/package-updater/internal/api"
"github.com/shcizo/package-updater/internal/discovery"
"github.com/shcizo/package-updater/internal/updater"
"github.com/stretchr/testify/require"
)
type fakeFinder struct {
jobs []discovery.Job
err error
}
func (f *fakeFinder) FindJobs(_ context.Context, _ string) ([]discovery.Job, error) {
return f.jobs, f.err
}
type fakeSubmitter struct {
results []updater.Result
}
func (f *fakeSubmitter) Submit(_ context.Context, jobs []discovery.Job) []updater.Result {
if f.results != nil {
return f.results
}
out := make([]updater.Result, len(jobs))
for i, j := range jobs {
out[i] = updater.Result{Job: j, Status: updater.StatusUpdated}
}
return out
}
type fakePinger struct{ err error }
func (f *fakePinger) Ping(_ context.Context) (types.Ping, error) {
return types.Ping{}, f.err
}
func decode[T any](t *testing.T, body io.Reader) T {
t.Helper()
var v T
require.NoError(t, json.NewDecoder(body).Decode(&v))
return v
}
func TestUpdate_ValidationError(t *testing.T) {
h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now")
req := httptest.NewRequest(http.MethodPost, "/update",
strings.NewReader(`{"tag":"v1.2.3"}`))
w := httptest.NewRecorder()
h.Update(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
}
func TestUpdate_BadJSON(t *testing.T) {
h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now")
req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`not json`))
w := httptest.NewRecorder()
h.Update(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
}
func TestUpdate_DiscoveryFailureReturns500(t *testing.T) {
finder := &fakeFinder{err: errors.New("daemon unreachable")}
h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now")
body, _ := json.Marshal(api.UpdateRequest{Image: "r/x"})
req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body))
w := httptest.NewRecorder()
h.Update(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestUpdate_ZeroMatchesReturns200(t *testing.T) {
h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now")
body, _ := json.Marshal(api.UpdateRequest{Image: "r/x"})
req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body))
w := httptest.NewRecorder()
h.Update(w, req)
require.Equal(t, http.StatusOK, w.Code)
resp := decode[api.UpdateResponse](t, w.Body)
require.Equal(t, 0, resp.Matched)
}
func TestUpdate_AllSucceeded200(t *testing.T) {
finder := &fakeFinder{jobs: []discovery.Job{
{Project: "p", Service: "s", WorkingDir: "/x", ConfigFiles: []string{"/x/c.yml"}},
}}
h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now")
body, _ := json.Marshal(api.UpdateRequest{Image: "r/x", Tag: "v1"})
req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body))
w := httptest.NewRecorder()
h.Update(w, req)
require.Equal(t, http.StatusOK, w.Code)
resp := decode[api.UpdateResponse](t, w.Body)
require.Equal(t, 1, resp.Matched)
require.Equal(t, "updated", resp.Results[0].Status)
require.Equal(t, "/x/c.yml", resp.Results[0].ComposeFile)
}
func TestUpdate_MixedReturns207(t *testing.T) {
jobs := []discovery.Job{
{Project: "p1", Service: "s", ConfigFiles: []string{"/x/c.yml"}},
{Project: "p2", Service: "s", ConfigFiles: []string{"/y/c.yml"}},
}
finder := &fakeFinder{jobs: jobs}
submitter := &fakeSubmitter{results: []updater.Result{
{Job: jobs[0], Status: updater.StatusUpdated},
{Job: jobs[1], Status: updater.StatusFailed, Error: "boom"},
}}
h := api.NewHandlers(finder, submitter, &fakePinger{}, "v0.0.0", "abc", "now")
body, _ := json.Marshal(api.UpdateRequest{Image: "r/x"})
req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body))
w := httptest.NewRecorder()
h.Update(w, req)
require.Equal(t, http.StatusMultiStatus, w.Code)
}
func TestUpdate_AllFailedReturns500(t *testing.T) {
jobs := []discovery.Job{{Project: "p", Service: "s", ConfigFiles: []string{"/x/c.yml"}}}
finder := &fakeFinder{jobs: jobs}
submitter := &fakeSubmitter{results: []updater.Result{
{Job: jobs[0], Status: updater.StatusFailed, Error: "boom"},
}}
h := api.NewHandlers(finder, submitter, &fakePinger{}, "v0.0.0", "abc", "now")
body, _ := json.Marshal(api.UpdateRequest{Image: "r/x"})
req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body))
w := httptest.NewRecorder()
h.Update(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestHealthz_OKWhenDockerUp(t *testing.T) {
h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now")
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
h.Healthz(w, req)
require.Equal(t, http.StatusOK, w.Code)
}
func TestHealthz_503WhenDockerDown(t *testing.T) {
h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{err: errors.New("ping fail")}, "v0.0.0", "abc", "now")
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
h.Healthz(w, req)
require.Equal(t, http.StatusServiceUnavailable, w.Code)
}
func TestVersion(t *testing.T) {
h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v1.2.3", "abcdef", "2026-05-22T00:00:00Z")
req := httptest.NewRequest(http.MethodGet, "/version", nil)
w := httptest.NewRecorder()
h.Version(w, req)
require.Equal(t, http.StatusOK, w.Code)
resp := decode[api.VersionResponse](t, w.Body)
require.Equal(t, "v1.2.3", resp.Version)
}
+39
View File
@@ -0,0 +1,39 @@
package api
// UpdateRequest is the body of POST /update.
type UpdateRequest struct {
Image string `json:"image"`
Tag string `json:"tag,omitempty"`
}
// UpdateResponse is the body of POST /update.
type UpdateResponse struct {
RequestID string `json:"request_id"`
Image string `json:"image"`
Tag string `json:"tag,omitempty"`
Matched int `json:"matched"`
Results []ResultDTO `json:"results"`
}
// ResultDTO is one row in UpdateResponse.Results.
type ResultDTO struct {
Project string `json:"project"`
Service string `json:"service"`
ComposeFile string `json:"compose_file"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
DurationMs int64 `json:"duration_ms"`
}
// HealthResponse is the body of GET /healthz.
type HealthResponse struct {
Status string `json:"status"`
Docker string `json:"docker"`
}
// VersionResponse is the body of GET /version.
type VersionResponse struct {
Version string `json:"version"`
Commit string `json:"commit"`
BuildTime string `json:"build_time"`
}