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
151 lines
4.7 KiB
Go
151 lines
4.7 KiB
Go
package model
|
|
|
|
import (
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
// Sentinel errors for jobs.
|
|
var (
|
|
// ErrJobActive means the service already has a queued or running job.
|
|
// CreateJob and jobs.Service.Trigger return it together with that job.
|
|
ErrJobActive = errors.New("an update is already in progress for this service")
|
|
// ErrAgentOffline means no connected agent can run the job.
|
|
ErrAgentOffline = errors.New("agent offline")
|
|
// ErrAgentTooOld means the only agent that could run the job speaks a
|
|
// protocol version without jobs.
|
|
ErrAgentTooOld = errors.New("agent too old for updates")
|
|
// ErrAgentTooOldForPinned means the only agent that could run the job
|
|
// speaks protocol version 2, which cannot deploy a digest-pinned or
|
|
// tagless ref. errors.Is(err, ErrAgentTooOld) is true for it as well.
|
|
ErrAgentTooOldForPinned error = agentTooOldForPinned{}
|
|
)
|
|
|
|
type agentTooOldForPinned struct{}
|
|
|
|
func (agentTooOldForPinned) Error() string {
|
|
return "agent too old to deploy a pinned version (needs protocol 3)"
|
|
}
|
|
|
|
func (agentTooOldForPinned) Is(target error) bool { return target == ErrAgentTooOld }
|
|
|
|
// NotUpdatableError says why a service cannot be updated. Reason is shown
|
|
// in the UI as is.
|
|
type NotUpdatableError struct {
|
|
Reason string
|
|
}
|
|
|
|
func (e *NotUpdatableError) Error() string { return "not updatable: " + e.Reason }
|
|
|
|
// Reasons a service cannot be updated.
|
|
const (
|
|
ReasonSelf = "updated manually outside the system"
|
|
ReasonContainer = "plain containers cannot be updated"
|
|
ReasonNoTag = "follows no tag"
|
|
ReasonRemoved = "service no longer exists"
|
|
ReasonBuilt = "built from source (build:)"
|
|
|
|
ReasonDrifted = "drifted: restore or accept the running version first"
|
|
ReasonNotDrifted = "runs its desired version"
|
|
)
|
|
|
|
// MergeRefusedError says why two services cannot be merged. Reason is shown
|
|
// in the UI as is.
|
|
type MergeRefusedError struct {
|
|
Reason string
|
|
}
|
|
|
|
func (e *MergeRefusedError) Error() string { return "cannot merge: " + e.Reason }
|
|
|
|
// Reasons two services cannot be merged. ReasonSelf is reused for the
|
|
// updater itself.
|
|
const (
|
|
ReasonMergeSame = "a service cannot be merged into itself"
|
|
ReasonMergeLiveOld = "the old service still exists"
|
|
ReasonMergeGoneNew = "the new service no longer exists"
|
|
ReasonMergeKind = "only services of the same kind can be merged"
|
|
ReasonMergeContainer = "plain containers cannot be merged"
|
|
)
|
|
|
|
// JobStatus is the state of an update job.
|
|
type JobStatus string
|
|
|
|
// Job statuses. A job moves queued -> running -> succeeded|failed, or
|
|
// straight from queued to failed.
|
|
const (
|
|
JobQueued JobStatus = "queued"
|
|
JobRunning JobStatus = "running"
|
|
JobSucceeded JobStatus = "succeeded"
|
|
JobFailed JobStatus = "failed"
|
|
)
|
|
|
|
// JobTrigger says what created a job. TriggerManual comes from the UI, TriggerWebhook from POST /update.
|
|
type JobTrigger string
|
|
|
|
// Job triggers.
|
|
const (
|
|
TriggerManual JobTrigger = "manual"
|
|
TriggerWebhook JobTrigger = "webhook"
|
|
TriggerPoll JobTrigger = "poll"
|
|
)
|
|
|
|
// Job is one update of one service.
|
|
type Job struct {
|
|
ID int64
|
|
ServiceID int64
|
|
ServiceName string // Service.DisplayName() of the job's service
|
|
AgentID *int64 // agent the job was sent to; nil if it never was
|
|
AgentName string
|
|
FromRef string
|
|
ToRef string // "repo:tag" when created; "repo:tag@digest" after success
|
|
Trigger JobTrigger
|
|
RequestedBy string // OIDC subject for manual jobs
|
|
Status JobStatus
|
|
Output string // empty in ListJobs results
|
|
OutputTruncated bool
|
|
Error string
|
|
CreatedAt time.Time
|
|
StartedAt *time.Time
|
|
FinishedAt *time.Time
|
|
}
|
|
|
|
// Active reports whether the job is queued or running.
|
|
func (j Job) Active() bool { return j.Status == JobQueued || j.Status == JobRunning }
|
|
|
|
// NewJob is what CreateJob needs to insert a queued job.
|
|
type NewJob struct {
|
|
ServiceID int64
|
|
AgentID *int64
|
|
FromRef string
|
|
ToRef string
|
|
Trigger JobTrigger
|
|
RequestedBy string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// JobFinish is a job's outcome as reported by the agent.
|
|
type JobFinish struct {
|
|
Status JobStatus // JobSucceeded or JobFailed
|
|
Ref string // deployed "repo:tag@digest"; set on success
|
|
Error string
|
|
Output string
|
|
OutputTruncated bool
|
|
StartedAt time.Time // agent clock
|
|
FinishedAt time.Time // agent clock
|
|
}
|
|
|
|
// JobFilter selects jobs for ListJobs. ServiceID 0 means all services.
|
|
// Limit 0 means 100.
|
|
type JobFilter struct {
|
|
ServiceID int64
|
|
Limit int
|
|
}
|
|
|
|
// Route is the hub's choice of agent connection for a job. ConnID identifies
|
|
// the connection, so a job is never sent over a connection that replaced the
|
|
// one chosen.
|
|
type Route struct {
|
|
AgentID int64
|
|
ConnID uint64
|
|
}
|