Initial implementation: package-updater v1 #1
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user