Docker Build / docker (push) Successful in 38s
## 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
117 lines
3.0 KiB
Go
117 lines
3.0 KiB
Go
// Package model holds the master's domain types shared by storage, the agent
|
|
// hub and the web UI.
|
|
package model
|
|
|
|
import (
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/shcizo/package-updater/internal/protocol"
|
|
)
|
|
|
|
// Sentinel errors returned by storage.
|
|
var (
|
|
ErrNotFound = errors.New("not found")
|
|
ErrConflict = errors.New("already exists")
|
|
ErrRevoked = errors.New("agent revoked")
|
|
)
|
|
|
|
// Agent is a registered agent. Whether it is connected right now is held in
|
|
// memory by the hub, not here.
|
|
type Agent struct {
|
|
ID int64
|
|
Name string
|
|
Hostname string
|
|
AgentVersion string
|
|
SwarmManager bool
|
|
LastSeenAt *time.Time
|
|
RevokedAt *time.Time
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// Service is one service as last reported by an agent.
|
|
type Service struct {
|
|
ID int64
|
|
Kind protocol.Kind
|
|
LocationKey string
|
|
AgentID *int64
|
|
AgentName string
|
|
ComposeProject string
|
|
ComposeService string
|
|
WorkingDir string
|
|
ConfigFiles string // raw config_files label, paths joined by ","
|
|
SwarmClusterID string
|
|
SwarmService string
|
|
ContainerName string
|
|
ImageRepo string
|
|
DesiredRef string // set by the last successful job; "" if never updated
|
|
ActualRef string
|
|
ActualDigest string
|
|
Status string
|
|
Self bool
|
|
Built bool // Compose service with a build: section; never updated
|
|
UpdateMode string // ModeManual or ModeAuto
|
|
// AvailableRef is the newest ref a webhook or poll reported for this
|
|
// service; "" if none, or if a later job already deployed it.
|
|
AvailableRef string
|
|
AvailableSource string // SourceWebhook; "" when AvailableRef is empty
|
|
AvailableAt *time.Time
|
|
FirstSeenAt time.Time
|
|
UpdatedAt time.Time
|
|
RemovedAt *time.Time
|
|
}
|
|
|
|
// Update modes of a service.
|
|
const (
|
|
ModeManual = "manual"
|
|
ModeAuto = "auto"
|
|
)
|
|
|
|
// Sources of an available ref.
|
|
const (
|
|
SourceWebhook = "webhook"
|
|
)
|
|
|
|
// Available is a newer ref reported for a service.
|
|
type Available struct {
|
|
Ref string
|
|
Source string // SourceWebhook
|
|
At time.Time
|
|
}
|
|
|
|
// DisplayName is the human-facing name of the service.
|
|
func (s Service) DisplayName() string {
|
|
switch s.Kind {
|
|
case protocol.KindCompose:
|
|
return s.ComposeProject + "/" + s.ComposeService
|
|
case protocol.KindSwarm:
|
|
return s.SwarmService
|
|
default:
|
|
return s.ContainerName
|
|
}
|
|
}
|
|
|
|
// Session is a logged-in browser session.
|
|
type Session struct {
|
|
ID string
|
|
Subject string
|
|
Name string
|
|
Email string
|
|
CSRFToken string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// LocationKey turns an agent-relative protocol key into the fleet-wide key
|
|
// stored in services.location_key. Compose services and containers belong to
|
|
// the agent's machine; Swarm services belong to the cluster, so two manager
|
|
// agents in one cluster report the same row.
|
|
func LocationKey(agentID int64, clusterID, agentKey string) string {
|
|
kind, rest, _ := strings.Cut(agentKey, "|")
|
|
if kind == string(protocol.KindSwarm) {
|
|
return kind + "|" + clusterID + "|" + rest
|
|
}
|
|
return kind + "|" + strconv.FormatInt(agentID, 10) + "|" + rest
|
|
}
|