feat: master/agent phase 5 — drift and merge, backend (#10) (#30)
Docker Build / docker (push) Successful in 42s

## Summary
- Drift detection: `model.Service.Drifted()` compares desired and running version by digest (tag when the digest is unknown). `services.awaiting_inventory` hides drift between a job result and the next inventory write.
- Poll and webhook jobs are refused for a drifted service; `POST /services/{id}/restore` (manual job to `desired_ref`) and `POST /services/{id}/accept` (running version becomes desired, guarded by `actual` = `Service.RunningRef`).
- Move suggestions (`store.MoveCandidates`, Compose only) and `POST /services/{id}/merge`: the old row keeps its id, mode, `desired_ref` and history and takes the new location; the duplicate is deleted.
- Backend only. The buttons and badges come with #28, on top of the Ledger UI (#18).

Closes #10

## Test plan
- [x] `gofmt -l .`, `go vet ./...`, `go test ./...`
- [x] `go test -race ./internal/agent/... ./internal/master/... ./internal/e2e/`
- [x] `go test -tags integration ./internal/master/store/` against postgres:17-alpine
- [ ] On the server: migration 0005 applies; no drift shown after a normal update
- [ ] On the server: manual `docker compose up` with another tag gives drift; webhook for an auto service is refused
- [ ] On the server: restore and accept via POST; merge after moving a stack

Reviewed-on: #30
This commit was merged in pull request #30.
This commit is contained in:
2026-10-13 09:04:36 +00:00
parent 307e46e4c7
commit 919ad91710
21 changed files with 2139 additions and 39 deletions
+175 -16
View File
@@ -18,6 +18,7 @@ import (
"github.com/shcizo/package-updater/internal/master/events"
"github.com/shcizo/package-updater/internal/master/model"
"github.com/shcizo/package-updater/internal/master/webauth"
"github.com/shcizo/package-updater/internal/protocol"
)
//go:embed templates/*.html static/*
@@ -40,6 +41,16 @@ type Store interface {
RecentVersions(ctx context.Context, repo string, limit int) ([]model.ImageVersion, error)
// SetUpdateMode returns model.ErrNotFound for an unknown or removed service.
SetUpdateMode(ctx context.Context, id int64, mode string) error
// AcceptRunning sets the desired ref of a drifted service; model.ErrNotFound
// for an unknown or removed service, model.ErrConflict when the running
// version is no longer actualRef@actualDigest.
AcceptRunning(ctx context.Context, id int64, actualRef, actualDigest, desired string) error
// MoveCandidates returns the suggested moves of removed services.
MoveCandidates(ctx context.Context) ([]model.Move, error)
// MergeSources returns the removed services that could be merged into toID.
MergeSources(ctx context.Context, toID int64) ([]model.Service, error)
// MergeService moves the live service toID onto the removed fromID.
MergeService(ctx context.Context, fromID, toID int64) error
GetJob(ctx context.Context, id int64) (model.Job, error)
ListJobs(ctx context.Context, f model.JobFilter) ([]model.Job, error)
ListAgents(ctx context.Context) ([]model.Agent, error)
@@ -63,6 +74,10 @@ type Jobs interface {
// TriggerVersion updates to exactly the image version versionID of the
// service's repo; model.ErrNotFound for an unknown service or version.
TriggerVersion(ctx context.Context, serviceID, versionID int64, requestedBy string) (model.Job, error)
// Restorable explains why a drifted service cannot be restored.
Restorable(svc model.Service) error
// RestoreDesired deploys the service's desired ref again.
RestoreDesired(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error)
}
// Broker delivers change signals to open streams and accepts signals from
@@ -118,6 +133,9 @@ func (s *Server) Register(mux *http.ServeMux, protect func(http.Handler) http.Ha
route("GET /fragments/services/{id}/jobs", s.serviceJobsFragment)
route("POST /services/{id}/update", s.updateService)
route("POST /services/{id}/mode", s.setMode)
route("POST /services/{id}/restore", s.restoreDesired)
route("POST /services/{id}/accept", s.acceptRunning)
route("POST /services/{id}/merge", s.mergeService)
route("GET /jobs", s.jobsPage)
route("GET /fragments/jobs", s.jobsFragment)
route("GET /jobs/{id}", s.jobPage)
@@ -145,8 +163,14 @@ type pageData struct {
Versions []versionRow
Polling string // what the service page says about registry polling
PollFailed bool // Polling is the last registry error
Jobs []model.Job
Job model.Job
// RestoreBlocked is why a drifted Service cannot be restored; "" when it
// can. Only meaningful when Service.Drifted().
RestoreBlocked string
MoveFrom *model.Move // a live Service: the removed service it probably replaces
MoveTo *model.Move // a removed Service: the live service it probably moved to
MergeSources []model.Service // a live Compose or Swarm Service: removed ones to merge in
Jobs []model.Job
Job model.Job
}
func (s *Server) base(r *http.Request, title string) pageData {
@@ -235,6 +259,14 @@ func (s *Server) serviceDetail(r *http.Request, id int64) (pageData, error) {
return data, err
}
data.AutoBlocked = s.autoBlocked(svc)
if err := s.jobs.Restorable(svc); errors.As(err, &nu) {
data.RestoreBlocked = nu.Reason
} else if err != nil {
return data, err
}
if err := s.moveData(r, &data); err != nil {
return data, err
}
versions, err := s.store.RecentVersions(r.Context(), svc.ImageRepo, versionsLimit)
if err != nil {
return data, err
@@ -245,6 +277,27 @@ func (s *Server) serviceDetail(r *http.Request, id int64) (pageData, error) {
return data, err
}
// moveData fills the move suggestion and the merge choices of the service page.
func (s *Server) moveData(r *http.Request, data *pageData) error {
svc := data.Service
moves, err := s.store.MoveCandidates(r.Context())
if err != nil {
return err
}
for i, m := range moves {
switch svc.ID {
case m.ToID:
data.MoveFrom = &moves[i]
case m.FromID:
data.MoveTo = &moves[i]
}
}
if svc.RemovedAt == nil && !svc.Self && svc.Kind != protocol.KindContainer {
data.MergeSources, err = s.store.MergeSources(r.Context(), svc.ID)
}
return err
}
func (s *Server) servicePage(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
@@ -296,18 +349,130 @@ func (s *Server) updateService(w http.ResponseWriter, r *http.Request) {
case err == nil || errors.Is(err, model.ErrJobActive):
http.Redirect(w, r, "/jobs/"+strconv.FormatInt(job.ID, 10), http.StatusSeeOther)
case errors.As(err, &nu):
data, derr := s.serviceDetail(r, id)
if derr != nil {
s.failLookup(w, derr)
return
}
data.Error = "Cannot update: " + nu.Reason + "."
s.render(w, http.StatusConflict, "service.html", "layout", data)
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot update: "+nu.Reason+".")
default:
s.failLookup(w, err)
}
}
// restoreDesired deploys the desired ref of a drifted service again.
func (s *Server) restoreDesired(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
sess, _ := webauth.SessionFrom(r.Context())
job, err := s.jobs.RestoreDesired(r.Context(), id, sess.Subject)
var nu *model.NotUpdatableError
switch {
case err == nil || errors.Is(err, model.ErrJobActive):
http.Redirect(w, r, "/jobs/"+strconv.FormatInt(job.ID, 10), http.StatusSeeOther)
case errors.As(err, &nu):
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot restore: "+nu.Reason+".")
default:
s.failLookup(w, err)
}
}
// acceptRunning makes the running version of a drifted service the desired
// one. The form's actual field is the running ref the user saw (Service.RunningRef);
// it only guards against the service having changed since, the value written
// comes from the row.
func (s *Server) acceptRunning(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
actual := r.PostFormValue("actual")
if actual == "" {
http.Error(w, "missing actual", http.StatusBadRequest)
return
}
svc, err := s.store.GetService(r.Context(), id)
if err == nil && svc.RemovedAt != nil {
err = model.ErrNotFound
}
if err != nil {
s.failLookup(w, err)
return
}
if !svc.Drifted() {
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot accept: the service runs its desired version.")
return
}
if actual != svc.RunningRef() {
s.renderServiceError(w, r, id, http.StatusConflict, "The running version changed; check it again.")
return
}
desired := svc.RunningRef()
err = s.store.AcceptRunning(r.Context(), id, svc.ActualRef, svc.ActualDigest, desired)
switch {
case errors.Is(err, model.ErrConflict):
s.renderServiceError(w, r, id, http.StatusConflict, "The running version changed; check it again.")
return
case err != nil:
s.failLookup(w, err)
return
}
sess, _ := webauth.SessionFrom(r.Context())
s.log.Info("drift_accepted", "service_id", id, "subject", sess.Subject, "desired", desired)
s.broker.Publish(events.Services)
if r.PostFormValue("return") == "overview" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/services/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// mergeService moves the live service {id} onto the removed service in the
// form field "from", which keeps its history, mode and desired ref.
func (s *Server) mergeService(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
from, err := strconv.ParseInt(r.PostFormValue("from"), 10, 64)
if err != nil {
http.Error(w, "invalid service", http.StatusBadRequest)
return
}
err = s.store.MergeService(r.Context(), from, id)
var refused *model.MergeRefusedError
switch {
case errors.As(err, &refused):
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot merge: "+refused.Reason+".")
return
case errors.Is(err, model.ErrJobActive):
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot merge: "+model.ErrJobActive.Error()+".")
return
case errors.Is(err, model.ErrRevoked):
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot merge: the agent is revoked.")
return
case errors.Is(err, model.ErrConflict):
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot merge: the service changed; check it again.")
return
case err != nil:
s.failLookup(w, err)
return
}
sess, _ := webauth.SessionFrom(r.Context())
s.log.Info("service_merged", "from_id", from, "to_id", id, "subject", sess.Subject)
s.broker.Publish(events.Services)
s.broker.Publish(events.Jobs)
http.Redirect(w, r, "/services/"+strconv.FormatInt(from, 10), http.StatusSeeOther)
}
// renderServiceError renders the service page of id with msg as its error.
func (s *Server) renderServiceError(w http.ResponseWriter, r *http.Request, id int64, status int, msg string) {
data, err := s.serviceDetail(r, id)
if err != nil {
s.failLookup(w, err)
return
}
data.Error = msg
s.render(w, status, "service.html", "layout", data)
}
// setMode switches a service between manual and auto. Manual is always
// allowed; auto only when jobs.AutoAllowed says so.
func (s *Server) setMode(w http.ResponseWriter, r *http.Request) {
@@ -331,13 +496,7 @@ func (s *Server) setMode(w http.ResponseWriter, r *http.Request) {
}
var nu *model.NotUpdatableError
if err := s.jobs.AutoAllowed(svc); errors.As(err, &nu) {
data, derr := s.serviceDetail(r, id)
if derr != nil {
s.failLookup(w, derr)
return
}
data.Error = "Cannot enable auto: " + nu.Reason + "."
s.render(w, http.StatusConflict, "service.html", "layout", data)
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot enable auto: "+nu.Reason+".")
return
} else if err != nil {
s.fail(w, err)