Files
package-updater/internal/master/model/job.go
T
shcizo 41a619b316
Docker Build / docker (push) Successful in 38s
feat: master/agent phase 3 — webhook and auto mode (#8) (#22)
## Summary
- `POST /update` on the master (outside OIDC, bearer `UPDATER_API_KEY`; unset → 503). Matches `image` against `image_repo` on all agents, marks `available_*` on every match, starts webhook-triggered jobs for `auto` services targeting `<image_repo>:<tag>` (followed tag when no tag is given), and waits for them up to `UPDATE_TIMEOUT` (default 5m). 404 on no match, 500 if any auto job failed or timed out, otherwise 200.
- Manual/auto toggle per service on the overview and service page (`POST /services/{id}/mode`, CSRF); auto is blocked for self, `build:` and container services with the reason shown. "Update available" badge and an "Available" row.
- `FinishJob` clears `available_*` only when the successful job was created for that exact ref, after it was reported. Migration `0003_webhook.sql`, phase-3 plan doc, README / gitea-action README / CLAUDE.md / deploy example updated. `action.yml` unchanged.

Closes #8

## Behaviour changes for CI
- Mixed results now return 500 (the old 207 is gone); no match returns 404 instead of 200.
- With a `tag`, Compose services get pinned to `<image_repo>:<tag>` via `compose.override.yaml`; the tag must be a pushed tag.
- `busy` (job already active) and `refused` (not updatable) are reported but return 200.
- The reverse proxy read timeout must exceed `UPDATE_TIMEOUT`.

## Test plan
- [x] `go test ./...` and `go test -race ./internal/agent/... ./internal/master/... ./internal/e2e/`
- [x] `go test -tags integration ./internal/master/store/` against throwaway Postgres
- [x] UI verified in browser (mode toggle, disabled reason, badge via SSE, Available row)
- [ ] Manual: webhook with manual service → `marked` + badge
- [ ] Manual: webhook with auto service → waits, `succeeded`, badge cleared, override pinned
- [ ] Manual: wrong token 401, unknown image 404, offline agent 500
- [ ] Gitea action pointed at the master's `/update` in a real CI run

Reviewed-on: #22
2026-10-12 20:03:22 +00:00

118 lines
3.4 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")
)
// 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:)"
)
// 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
}