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:
@@ -71,6 +71,16 @@ func NewWorker(st *store.Store, hub Hub, disp Dispatcher, alerts AlertRaiser) *W
|
||||
// worker goroutine. Returns the new fleet_update_id on success.
|
||||
// store.ErrFleetUpdateRunning bubbles up unchanged.
|
||||
func (w *Worker) Start(ctx context.Context, userID, targetVersion string, hostIDs []string) (string, error) {
|
||||
return w.start(ctx, userID, targetVersion, hostIDs, false)
|
||||
}
|
||||
|
||||
// StartCanary starts the same sequential rollout but pauses after the first
|
||||
// verified host so an operator can inspect it before starting the remainder.
|
||||
func (w *Worker) StartCanary(ctx context.Context, userID, targetVersion string, hostIDs []string) (string, error) {
|
||||
return w.start(ctx, userID, targetVersion, hostIDs, true)
|
||||
}
|
||||
|
||||
func (w *Worker) start(ctx context.Context, userID, targetVersion string, hostIDs []string, pauseAfterFirst bool) (string, error) {
|
||||
if userID == "" || targetVersion == "" {
|
||||
return "", errors.New("fleetupdate: userID and targetVersion required")
|
||||
}
|
||||
@@ -85,6 +95,7 @@ func (w *Worker) Start(ctx context.Context, userID, targetVersion string, hostID
|
||||
StartedByUserID: userID,
|
||||
TargetVersion: targetVersion,
|
||||
Status: "running",
|
||||
PauseAfterFirst: pauseAfterFirst,
|
||||
}, hostIDs); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -137,6 +148,13 @@ func (w *Worker) run(ctx context.Context, fuID, userID, targetVersion string) {
|
||||
|
||||
next := pending[0]
|
||||
w.processHost(ctx, fuID, userID, next)
|
||||
if fu.PauseAfterFirst && next.Position == 0 {
|
||||
updated, _, gerr := w.store.GetFleetUpdate(ctx, fuID)
|
||||
if gerr == nil && updated.Status == "running" {
|
||||
_ = w.store.HaltFleetUpdate(ctx, fuID, "canary succeeded; review the host, then resume remaining agents", time.Now().UTC())
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +213,19 @@ func (w *Worker) processHost(ctx context.Context, fuID, userID string, slot stor
|
||||
return
|
||||
}
|
||||
}
|
||||
reason := fmt.Sprintf("timeout waiting for %s to reach %s", hostID, w.targetVersion)
|
||||
// One authoritative final read closes the poll/deadline race. AgentVersion
|
||||
// is written directly from the reconnect hello handshake.
|
||||
lastVersion := "unknown"
|
||||
if h, err := w.store.GetHost(ctx, hostID); err == nil && h != nil {
|
||||
if h.AgentVersion == w.targetVersion {
|
||||
_ = w.store.SetFleetUpdateHostStatus(ctx, fuID, hostID, "succeeded", "", jobID)
|
||||
return
|
||||
}
|
||||
if h.AgentVersion != "" {
|
||||
lastVersion = h.AgentVersion
|
||||
}
|
||||
}
|
||||
reason := fmt.Sprintf("timeout waiting for %s reconnect hello at %s (last reported %s)", hostID, w.targetVersion, lastVersion)
|
||||
_ = w.store.SetFleetUpdateHostStatus(ctx, fuID, hostID, "failed", reason, jobID)
|
||||
w.halt(ctx, fuID, reason)
|
||||
}
|
||||
|
||||
@@ -160,6 +160,35 @@ func TestWorkerTwoHostsBothSucceed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerCanaryPausesAfterFirstVerifiedHost(t *testing.T) {
|
||||
st := openStore(t)
|
||||
uid := mustCreateAdmin(t, st)
|
||||
h1 := mustCreateHost(t, st, "canary", "v0")
|
||||
h2 := mustCreateHost(t, st, "remainder", "v0")
|
||||
hub := &fakeHub{online: map[string]bool{h1: true, h2: true}}
|
||||
disp := &fakeDispatcher{st: st, target: "v2", delayMS: 20}
|
||||
alerts := &recAlert{}
|
||||
w := NewWorker(st, hub, disp, alerts)
|
||||
w.pollPeriod = 10 * time.Millisecond
|
||||
w.hostTimeout = time.Second
|
||||
|
||||
fuID, err := w.StartCanary(context.Background(), uid, "v2", []string{h1, h2})
|
||||
if err != nil {
|
||||
t.Fatalf("start canary: %v", err)
|
||||
}
|
||||
fu := waitForStatus(t, st, fuID, "halted", 2*time.Second)
|
||||
if fu.HaltedReason != "canary succeeded; review the host, then resume remaining agents" {
|
||||
t.Fatalf("halt reason: %q", fu.HaltedReason)
|
||||
}
|
||||
_, hosts, _ := st.GetFleetUpdate(context.Background(), fuID)
|
||||
if hosts[0].Status != "succeeded" || hosts[1].Status != "pending" {
|
||||
t.Fatalf("canary statuses: %+v", hosts)
|
||||
}
|
||||
if len(alerts.reasons) != 0 {
|
||||
t.Fatalf("successful canary pause must not alert: %v", alerts.reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerSecondHostTimesOutHalts(t *testing.T) {
|
||||
st := openStore(t)
|
||||
uid := mustCreateAdmin(t, st)
|
||||
|
||||
Reference in New Issue
Block a user