// 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/imageref" "github.com/shcizo/package-updater/internal/protocol" ) // Sentinel errors returned by storage. var ( ErrNotFound = errors.New("not found") // ErrConflict means the row already exists, or that it changed meanwhile // (AcceptRunning, MergeService). 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 // SwarmClusterID is the Swarm cluster the agent's daemon was in when it last // connected; "" outside a swarm. SwarmClusterID string 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 AvailableDigest string // digest of AvailableRef when known (poll); "" from the webhook AvailableSource string // SourceWebhook or SourcePoll; "" when AvailableRef is empty AvailableAt *time.Time LastPollError string // last registry error for the followed ref; "" when fine // LastTagDigest is the newest digest of the followed tag that was deployed // or started: set when the poller starts an auto job, when it sees the service // already running that digest, and when any plain tag job succeeds (the // digest the agent pinned); "" if never. The poller does // not start another job for the same digest, so a rollback sticks until the // registry shows a different one. LastTagDigest string // AwaitingInventory is true from a successful job until the next inventory // write for the row. Until then DesiredRef is newer than ActualRef without // the service having drifted. AwaitingInventory bool FirstSeenAt time.Time UpdatedAt time.Time RemovedAt *time.Time } // Update modes of a service. const ( ModeManual = "manual" ModeAuto = "auto" ) // Sources of an available ref and of an ImageVersion. const ( SourceWebhook = "webhook" SourcePoll = "poll" // SourceRunning is used by image_versions only: an agent reported the // version as running. SourceRunning = "running" ) // Available is a newer ref reported for a service. type Available struct { Ref string Digest string // "" when unknown (webhook) Source string // SourceWebhook or SourcePoll At time.Time } // ImageVersion is one version of an image repo the master has seen. Tag and // Digest are "" when the version has no tag or its digest is unknown; they // are never both empty. type ImageVersion struct { ID int64 Repo string Tag string Digest string Source string // SourceWebhook, SourcePoll or SourceRunning: the first source that saw it FirstSeenAt time.Time } // Ref returns "repo:tag@digest", "repo:tag" or "repo@digest". func (v ImageVersion) Ref() string { return imageref.Ref{Repo: v.Repo, Tag: v.Tag, Digest: v.Digest}.String() } // Drifted reports whether the service runs something other than the version // the last job deployed. A service the updater never touched, a removed one // and one whose inventory has not caught up with the last job do not drift. func (s Service) Drifted() bool { return s.DesiredRef != "" && s.RemovedAt == nil && !s.AwaitingInventory && !RunsRef(s.DesiredRef, s.ActualRef, s.ActualDigest) } // RunsRef reports whether a service reporting actualRef and actualDigest runs // the desired ref. The digest decides when both sides know one, so // "repo:tag@D" is run by "repo:tag" reporting digest D, and by "repo@D". // Otherwise the repo (compared exactly, without registry normalisation) and // the followed tag must be equal. func RunsRef(desired, actualRef, actualDigest string) bool { d, a := imageref.Parse(desired), imageref.Parse(actualRef) if d.Repo != a.Repo { return false } ad := a.Digest if ad == "" { ad = actualDigest } if d.Digest != "" && ad != "" { return d.Digest == ad } return followedTag(d) == followedTag(a) } // followedTag is the tag a ref follows: a bare image follows "latest" and a // ref with a digest but no tag follows nothing. func followedTag(r imageref.Ref) string { if r.Tag == "" && r.Digest == "" { return "latest" } return r.Tag } // RunningRef is the ref to record as the desired one when the running version // is accepted: actualRef itself if it carries a digest, "repo:tag@digest" if // only actualDigest is known (a bare image is "latest"), else actualRef. func RunningRef(actualRef, actualDigest string) string { r := imageref.Parse(actualRef) if r.Digest != "" || !imageref.IsDigest(actualDigest) { return actualRef } r.Tag, r.Digest = followedTag(r), actualDigest return r.String() } // RunningRef returns the ref accepting the running version records as the // desired one; see the function RunningRef. Templates post it as the guard of // the accept form. func (s Service) RunningRef() string { return RunningRef(s.ActualRef, s.ActualDigest) } // Move says that the removed service FromID has probably moved to the live // service ToID on another agent. The names are the agents' own; a revoked // agent keeps its name. type Move struct { FromID, ToID int64 FromAgent, ToAgent string } // 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 }