Repository navigation
Expand file tree
/
Copy pathconvergence.go
More file actions
215 lines (198 loc) · 7.75 KB
/
Copy pathconvergence.go
File metadata and controls
215 lines (198 loc) · 7.75 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
// SPDX-License-Identifier: Apache-2.0
// Copyright © 2026 Eldara Tech
package docker
import (
"time"
"github.com/docker/docker/api/types/swarm"
)
// ServiceConvergence is what a caller needs to decide whether one service has
// finished rolling out. It is deliberately separate from ServiceEntry: that
// struct backs the services view, where ReplicasOnNode mirrors `docker service
// ls` and so keeps counting a superseded task while its container runs. Running
// here drops the outgoing generation, which is the question --wait asks and the
// services view answers with UpToDate instead (see issue #480).
type ServiceConvergence struct {
Name string
Mode string
// Running counts tasks that are actually running, on an active node.
Running int
// Desired is the target count over active nodes.
Desired int
// Completed counts tasks that ran to completion on an active node. Only
// meaningful together with Job: for a long-running service a completed task
// is one swarm is about to replace, not one that finished its work.
Completed int
// Job reports a service swarm will not restart after a clean exit — a
// restart policy of "none" or "on-failure". Such a service is *supposed* to
// end with no task running, so Running < Desired is its success state, not
// a failure to converge (issue #443).
//
// A task that exits non-zero and exhausts its restart budget ends Failed,
// never Complete, so counting only completed tasks keeps the distinction
// that matters: finished versus broken.
Job bool
// UpdateState is the raw swarm UpdateStatus.State, empty when the service
// has never been updated. Note that a nil UpdateStatus means "no rollout has
// ever run", NOT "the rollout finished".
UpdateState string
// Monitor is UpdateConfig.Monitor: the window after a task is created during
// which its failure still counts against the rollout. Zero when unset.
Monitor time.Duration
// NewestTaskAge is how long the newest running task has been alive, measured
// from task creation — the same instant swarm measures Monitor from.
//
// A caller waiting out the monitor window needs it: a task only reports
// running once its healthcheck passes, so by then start_period and the
// checks that followed have already consumed part of the window, and
// sometimes all of it. Zero when nothing is running.
NewestTaskAge time.Duration
}
// activeNodeIDs returns the nodes that can currently run tasks. A task pinned to
// a node that is down never converges, so counting it in the denominator would
// make a release hang until timeout for a reason no redeploy can fix.
func activeNodeIDs(snap *SwarmSnapshot) map[string]struct{} {
active := make(map[string]struct{}, len(snap.Nodes))
for _, n := range snap.Nodes {
if n.Status.State == swarm.NodeStateReady && n.Spec.Availability == swarm.NodeAvailabilityActive {
active[n.ID] = struct{}{}
}
}
return active
}
// LoadStackConvergence returns per-service convergence facts for a stack.
//
// Running counts tasks whose ACTUAL state is running — not their desired state.
// Up-to-dateness comes free: on a rolling update Swarm marks superseded tasks
// DesiredState=shutdown, so requiring both DesiredState and Status.State to be
// running counts exactly the current generation.
func LoadStackConvergence(stackName string) []ServiceConvergence {
snap, err := GetOrRefreshSnapshot()
if err != nil {
l().Warnf("failed to get snapshot: %v", err)
return nil
}
return snap.StackConvergence(stackName)
}
// StackConvergence is LoadStackConvergence against an already-fetched snapshot,
// so a caller polling one specific swarm for convergence does not read another
// swarm's tasks out of the process-wide cache.
func (snap *SwarmSnapshot) StackConvergence(stackName string) []ServiceConvergence {
active := activeNodeIDs(snap)
var out []ServiceConvergence
for _, svc := range snap.Services {
if svc.Spec.Labels["com.docker.stack.namespace"] != stackName {
continue
}
job := isJobService(svc)
running, completed := 0, 0
var newest time.Time
for _, t := range snap.Tasks {
if t.ServiceID != svc.ID {
continue
}
// A task not yet assigned has no NodeID; it is by definition not
// running yet, but guard anyway rather than counting it as active.
if t.NodeID == "" {
continue
}
if _, ok := active[t.NodeID]; !ok {
continue
}
switch {
case t.DesiredState == swarm.TaskStateRunning && t.Status.State == swarm.TaskStateRunning:
running++
case job && t.Status.State == swarm.TaskStateComplete:
// Swarm sets DesiredState=shutdown once a job's task exits, so
// this is not reachable through the running arm above.
completed++
default:
continue
}
// The window is outstanding until the LAST task created has survived
// it, so the newest task governs. A completed job task counts here
// too, or --wait would measure the window from zero and sit out a
// full monitor after the job had already finished.
if t.CreatedAt.After(newest) {
newest = t.CreatedAt
}
}
out = append(out, ServiceConvergence{
Name: svc.Spec.Name,
Mode: getServiceMode(svc),
Running: running,
Completed: completed,
Job: job,
Desired: desiredOverActiveNodes(svc, len(active)),
UpdateState: updateState(svc),
Monitor: monitorWindow(svc),
NewestTaskAge: ageSince(newest),
})
}
return out
}
// ageSince is how long ago t was, clamped at zero. CreatedAt comes off the
// manager's clock and is compared against this host's, so skew can put it in the
// future; reporting a negative age would credit the caller with time that has
// not passed. A zero timestamp (nothing running) is likewise zero age.
func ageSince(t time.Time) time.Duration {
if t.IsZero() {
return 0
}
if age := time.Since(t); age > 0 {
return age
}
return 0
}
// isJobService reports a service swarm will not restart after a clean exit.
//
// Swarm's native mode: replicated-job is not the shape to look for — the
// compose v3 schema `docker stack deploy` accepts cannot express it, so the
// only way to run a one-shot task in a stack is a normal replicated service
// with a restart policy that declines to restart it. That is what init and
// migration steps in charts use, there being no depends_on in swarm.
//
// An omitted restart policy means "any", swarm's default, which is not a job.
func isJobService(svc swarm.Service) bool {
rp := svc.Spec.TaskTemplate.RestartPolicy
if rp == nil {
return false
}
switch rp.Condition {
case swarm.RestartPolicyConditionNone, swarm.RestartPolicyConditionOnFailure:
return true
default:
return false
}
}
// DesiredReplicas is the service's target task count against this snapshot: the
// declared replicas, or for a global service one per node that can currently run
// one. Exported for callers outside this package that would otherwise duplicate
// the mode switch and get the global case wrong (issue #480).
func (snap *SwarmSnapshot) DesiredReplicas(svc swarm.Service) int {
return desiredOverActiveNodes(svc, len(activeNodeIDs(snap)))
}
// desiredOverActiveNodes is the target task count. For a global service that is
// one per active node, so draining a node lowers the target rather than making
// the service permanently short.
func desiredOverActiveNodes(svc swarm.Service, activeNodes int) int {
switch {
case svc.Spec.Mode.Replicated != nil && svc.Spec.Mode.Replicated.Replicas != nil:
return int(*svc.Spec.Mode.Replicated.Replicas)
case svc.Spec.Mode.Global != nil:
return activeNodes
default:
return 1
}
}
func updateState(svc swarm.Service) string {
if svc.UpdateStatus == nil {
return ""
}
return string(svc.UpdateStatus.State)
}
func monitorWindow(svc swarm.Service) time.Duration {
if svc.Spec.UpdateConfig == nil {
return 0
}
return svc.Spec.UpdateConfig.Monitor
}