Add selectable canary-first fleet updates
CI / Test (rest) (pull_request) Successful in 39s
CI / Test (store) (pull_request) Successful in 42s
CI / Lint (pull_request) Successful in 11s
CI / Build (windows/amd64) (pull_request) Successful in 7s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 1m45s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m17s
CI / Test (rest) (pull_request) Successful in 39s
CI / Test (store) (pull_request) Successful in 42s
CI / Lint (pull_request) Successful in 11s
CI / Build (windows/amd64) (pull_request) Successful in 7s
CI / Build (linux/amd64) (pull_request) Successful in 8s
CI / Build (linux/arm64) (pull_request) Successful in 8s
CI / Test (server-http) (pull_request) Successful in 1m45s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m17s
This commit is contained in:
@@ -32,6 +32,13 @@ import (
|
||||
type fleetUpdateStartReq struct {
|
||||
TargetVersion string `json:"target_version,omitempty"`
|
||||
HostIDs []string `json:"host_ids,omitempty"`
|
||||
CanaryFirst bool `json:"canary_first,omitempty"`
|
||||
}
|
||||
|
||||
type fleetUpdateCandidate struct {
|
||||
Host store.Host
|
||||
Eligible bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// fleetUpdateHostView is one row in the JSON response for GET
|
||||
@@ -56,6 +63,7 @@ type fleetUpdateView struct {
|
||||
CurrentHostID string `json:"current_host_id,omitempty"`
|
||||
HaltedReason string `json:"halted_reason,omitempty"`
|
||||
CompletedAt *string `json:"completed_at,omitempty"`
|
||||
CanaryFirst bool `json:"canary_first"`
|
||||
Hosts []fleetUpdateHostView `json:"hosts"`
|
||||
}
|
||||
|
||||
@@ -65,6 +73,7 @@ type fleetUpdateView struct {
|
||||
type fleetUpdatePage struct {
|
||||
// Idle-state fields.
|
||||
OutOfDateHosts []store.Host // online hosts whose version != target
|
||||
Candidates []fleetUpdateCandidate
|
||||
TargetVersion string
|
||||
|
||||
// Active-state fields. Nil when no fleet update has ever run.
|
||||
@@ -100,6 +109,11 @@ func (s *Server) handleAPIFleetUpdateStart(w stdhttp.ResponseWriter, r *stdhttp.
|
||||
if target == "" {
|
||||
target = version.Version
|
||||
}
|
||||
if target != version.Version {
|
||||
writeJSONError(w, stdhttp.StatusUnprocessableEntity, "unsupported_target_version",
|
||||
"fleet updates can only target the running server version")
|
||||
return
|
||||
}
|
||||
hostIDs := body.HostIDs
|
||||
if len(hostIDs) == 0 {
|
||||
derived, err := s.deriveOutOfDateOnlineHostIDs(r.Context(), target)
|
||||
@@ -108,6 +122,19 @@ func (s *Server) handleAPIFleetUpdateStart(w stdhttp.ResponseWriter, r *stdhttp.
|
||||
return
|
||||
}
|
||||
hostIDs = derived
|
||||
} else {
|
||||
validated, reasons, err := s.validateFleetUpdateHostIDs(r.Context(), target, hostIDs)
|
||||
if err != nil {
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
if len(reasons) > 0 {
|
||||
writeJSON(w, stdhttp.StatusUnprocessableEntity, map[string]any{
|
||||
"code": "ineligible_hosts", "message": "one or more selected hosts are ineligible", "reasons": reasons,
|
||||
})
|
||||
return
|
||||
}
|
||||
hostIDs = validated
|
||||
}
|
||||
if len(hostIDs) == 0 {
|
||||
writeJSONError(w, stdhttp.StatusConflict, "no_hosts_eligible",
|
||||
@@ -115,7 +142,20 @@ func (s *Server) handleAPIFleetUpdateStart(w stdhttp.ResponseWriter, r *stdhttp.
|
||||
return
|
||||
}
|
||||
|
||||
fuID, err := s.deps.FleetWorker.Start(r.Context(), user.ID, target, hostIDs)
|
||||
var fuID string
|
||||
var err error
|
||||
if body.CanaryFirst && len(hostIDs) > 1 {
|
||||
worker, supported := s.deps.FleetWorker.(interface {
|
||||
StartCanary(context.Context, string, string, []string) (string, error)
|
||||
})
|
||||
if !supported {
|
||||
writeJSONError(w, stdhttp.StatusServiceUnavailable, "canary_unavailable", "")
|
||||
return
|
||||
}
|
||||
fuID, err = worker.StartCanary(r.Context(), user.ID, target, hostIDs)
|
||||
} else {
|
||||
fuID, err = s.deps.FleetWorker.Start(r.Context(), user.ID, target, hostIDs)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrFleetUpdateRunning) {
|
||||
writeJSONError(w, stdhttp.StatusConflict, "fleet_update_in_progress", err.Error())
|
||||
@@ -129,6 +169,7 @@ func (s *Server) handleAPIFleetUpdateStart(w stdhttp.ResponseWriter, r *stdhttp.
|
||||
"fleet_update_id": fuID,
|
||||
"target_version": target,
|
||||
"host_count": len(hostIDs),
|
||||
"canary_first": body.CanaryFirst,
|
||||
})
|
||||
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||
ID: ulid.Make().String(), UserID: &user.ID, Actor: "user",
|
||||
@@ -209,6 +250,7 @@ func (s *Server) handleAPIFleetUpdateGet(w stdhttp.ResponseWriter, r *stdhttp.Re
|
||||
Status: fu.Status,
|
||||
CurrentHostID: fu.CurrentHostID,
|
||||
HaltedReason: fu.HaltedReason,
|
||||
CanaryFirst: fu.PauseAfterFirst,
|
||||
Hosts: make([]fleetUpdateHostView, 0, len(hosts)),
|
||||
}
|
||||
if fu.CompletedAt != nil {
|
||||
@@ -286,6 +328,27 @@ func (s *Server) buildFleetUpdatePage(r *stdhttp.Request) (fleetUpdatePage, erro
|
||||
}
|
||||
for _, h := range hosts {
|
||||
page.HostNames[h.ID] = h.Name
|
||||
candidate := fleetUpdateCandidate{Host: h}
|
||||
switch {
|
||||
case h.AgentVersion == "":
|
||||
candidate.Reason = "version unknown"
|
||||
case h.AgentVersion == page.TargetVersion:
|
||||
candidate.Reason = "already current"
|
||||
case s.deps.Hub == nil || !s.deps.Hub.Connected(h.ID):
|
||||
candidate.Reason = "offline"
|
||||
default:
|
||||
updating, uerr := s.deps.Store.RunningUpdateJobForHost(r.Context(), h.ID)
|
||||
if uerr != nil {
|
||||
return page, uerr
|
||||
}
|
||||
if updating != "" {
|
||||
candidate.Reason = "update already running"
|
||||
} else {
|
||||
candidate.Eligible = true
|
||||
page.OutOfDateHosts = append(page.OutOfDateHosts, h)
|
||||
}
|
||||
}
|
||||
page.Candidates = append(page.Candidates, candidate)
|
||||
}
|
||||
|
||||
active, err := s.deps.Store.ActiveFleetUpdate(r.Context())
|
||||
@@ -328,20 +391,54 @@ func (s *Server) buildFleetUpdatePage(r *stdhttp.Request) (fleetUpdatePage, erro
|
||||
}
|
||||
}
|
||||
|
||||
// Idle list (or "still out of date" reference even when an active
|
||||
// roll is running — cheap to compute, harmless to attach).
|
||||
for _, h := range hosts {
|
||||
if h.Status != "online" {
|
||||
continue
|
||||
}
|
||||
if h.AgentVersion == "" || h.AgentVersion == page.TargetVersion {
|
||||
continue
|
||||
}
|
||||
page.OutOfDateHosts = append(page.OutOfDateHosts, h)
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// validateFleetUpdateHostIDs deduplicates an explicit selection while
|
||||
// preserving review order and rejects the entire request if membership has
|
||||
// changed or any selected host is not dispatchable.
|
||||
func (s *Server) validateFleetUpdateHostIDs(ctx context.Context, target string, requested []string) ([]string, map[string]string, error) {
|
||||
hosts, err := s.deps.Store.ListHosts(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
byID := make(map[string]store.Host, len(hosts))
|
||||
for _, h := range hosts {
|
||||
byID[h.ID] = h
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
validated := make([]string, 0, len(requested))
|
||||
reasons := map[string]string{}
|
||||
for _, id := range requested {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
h, ok := byID[id]
|
||||
switch {
|
||||
case !ok:
|
||||
reasons[id] = "host not found"
|
||||
case h.AgentVersion == "":
|
||||
reasons[id] = "agent version unknown"
|
||||
case h.AgentVersion == target:
|
||||
reasons[id] = "already at target version"
|
||||
case s.deps.Hub == nil || !s.deps.Hub.Connected(id):
|
||||
reasons[id] = "host offline"
|
||||
default:
|
||||
jobID, jerr := s.deps.Store.RunningUpdateJobForHost(ctx, id)
|
||||
if jerr != nil {
|
||||
return nil, nil, jerr
|
||||
}
|
||||
if jobID != "" {
|
||||
reasons[id] = "update already in progress"
|
||||
} else {
|
||||
validated = append(validated, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return validated, reasons, nil
|
||||
}
|
||||
|
||||
// deriveOutOfDateOnlineHostIDs returns the list of host IDs that
|
||||
// (a) are online (Hub.Connected) and (b) have an agent_version that's
|
||||
// non-empty AND != target. Used by the start endpoint when the caller
|
||||
|
||||
Reference in New Issue
Block a user