Skip to content

Commit 78470fa

Browse files
authored
Merge pull request #99 from vatesfr/fix/oos-taint-vm-shutting-down
fix(node-out-of-service): taint node when VM is shutting down
2 parents fc98832 + 7faa31d commit 78470fa

6 files changed

Lines changed: 66 additions & 33 deletions

File tree

‎Makefile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ build: ## Build
7171

7272
.PHONY: run
7373
run: build ## Run
74-
./bin/xenorchestra-cloud-controller-manager-$(ARCH) --v=5 --kubeconfig=$(KUBECONFIG) --cloud-config=xo-config.yaml --controllers=cloud-node,cloud-node-lifecycle,cloud-node-label-sync \
74+
./bin/xenorchestra-cloud-controller-manager-$(ARCH) --v=5 --kubeconfig=$(KUBECONFIG) --cloud-config=xo-config.yaml --controllers=cloud-node,cloud-node-lifecycle,cloud-node-label-sync,cloud-node-out-of-service \
7575
--use-service-account-credentials --leader-elect=false --bind-address=127.0.0.1 --authorization-always-allow-paths=/healthz,/livez,/readyz,/metrics
7676

7777
.PHONY: lint

‎docs/node-out-of-service.md‎

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
The `cloud-node-out-of-service` controller applies the
44
`node.kubernetes.io/out-of-service=nodeshutdown:NoExecute` taint to nodes whose
5-
Xen Orchestra VM is no longer running. This is the Kubernetes
5+
Xen Orchestra VM is no longer running, or is currently shutting down
6+
(suspend, clean or hard shutdown in progress). This is the Kubernetes
67
[Non-Graceful Node Shutdown](https://kubernetes.io/docs/concepts/cluster-administration/node-shutdown/#non-graceful-node-shutdown)
78
procedure, implemented by the CCM because it owns the cloud instance state.
89

@@ -18,6 +19,15 @@ error`, so a simple pod restart takes 8 to 10 minutes.
1819
The 6 minute timer itself is not configurable. The supported way to bypass it is
1920
the out-of-service taint: KCM then detaches the volume immediately.
2021

22+
The same wait applies to a VM that is being deliberately shut down: a clean
23+
shutdown or suspend started from Xen Orchestra stops the guest without draining
24+
the node, but the VM's power state stays `Running` for the whole duration of the
25+
OS shutdown. The controller therefore also looks at the in-flight XAPI
26+
operations of the VM and treats a running VM that has a `suspend`,
27+
`clean-shutdown` or `hard-shutdown` operation in progress as down, so the grace
28+
period starts as soon as the shutdown begins instead of after the power state
29+
flips.
30+
2131
## Who reads the taint
2232

2333
Only the **kube-controller-manager of the workload cluster** reads it, in three
@@ -50,19 +60,22 @@ Every `--node-out-of-service-sync-period` (default `10s`) it lists the nodes and
5060
initialized yet (`node.cloudprovider.kubernetes.io/uninitialized`);
5161
2. **fast path**: skips Ready nodes that do not carry the taint, so a healthy
5262
cluster makes no Xen Orchestra API call;
53-
3. queries Xen Orchestra with `InstancesV2.InstanceExists` and
54-
`InstanceShutdown`;
55-
4. applies the taint when the VM is down **and** the node is `NotReady`:
63+
3. queries Xen Orchestra with `GetInstance`, which returns the VM's power state
64+
and in-flight operations;
65+
4. applies the taint when the VM is down or shutting down **and** the node is
66+
`NotReady`:
5667
* the VM was deleted from Xen Orchestra → immediate, it can never come back;
57-
* the VM is only powered off → only after
58-
`--node-out-of-service-grace-period` (default `30s`), because a clean reboot
59-
must not trigger a force detach;
68+
* the VM is not running (halted, paused or suspended), or a `suspend`,
69+
`clean-shutdown` or `hard-shutdown` operation is in progress → only after
70+
`--node-out-of-service-grace-period` (default `30s`), because a clean
71+
reboot (no operation in flight) must not trigger a force detach;
6072
5. removes the taint once the VM runs again and the node is `Ready`.
6173

6274
Applying the taint to a node that is not actually shut down can corrupt a
63-
filesystem, so the two conditions (VM down *and* node `NotReady`) and the grace
64-
period are required. See the upstream warning in the Node Shutdowns
65-
documentation.
75+
filesystem, so the two conditions (VM down or shutting down *and* node
76+
`NotReady`) and the grace period are required. The grace period also covers a
77+
shutdown operation that is cancelled before it completes. See the upstream
78+
warning in the Node Shutdowns documentation.
6679

6780
## Enabling the controller
6881

@@ -75,7 +88,7 @@ the `docs/deploy` manifests enable it by default.
7588
| flag | default | description |
7689
|---|---|---|
7790
| `--node-out-of-service-sync-period` | `10s` | reconciliation period |
78-
| `--node-out-of-service-grace-period` | `30s` | how long a powered-off VM must stay down (with a NotReady node) before tainting |
91+
| `--node-out-of-service-grace-period` | `30s` | how long a VM must stay down or have a shutdown operation in progress (with a NotReady node) before tainting |
7992

8093
With the Helm chart these are set with the `nodeOutOfServiceSyncPeriod` and
8194
`nodeOutOfServiceGracePeriod` values (empty by default, which keeps the built-in

‎go.mod‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ require (
66
github.com/gofrs/uuid v4.4.0+incompatible
77
github.com/jarcoal/httpmock v1.4.1
88
github.com/spf13/pflag v1.0.10
9-
github.com/stretchr/testify v1.12.0
10-
github.com/vatesfr/xenorchestra-go-sdk v1.19.0
9+
github.com/stretchr/testify v1.12.1
10+
github.com/vatesfr/xenorchestra-go-sdk v1.20.0
1111
github.com/vatesfr/xenorchestra-k8s-common v0.3.0
1212
go.uber.org/mock v0.6.0
1313
k8s.io/api v0.36.3
@@ -65,7 +65,7 @@ require (
6565
github.com/prometheus/client_model v0.6.2 // indirect
6666
github.com/prometheus/common v0.67.5 // indirect
6767
github.com/prometheus/procfs v0.19.2 // indirect
68-
github.com/sourcegraph/jsonrpc2 v0.2.2 // indirect
68+
github.com/sourcegraph/jsonrpc2 v0.2.3 // indirect
6969
github.com/spf13/cobra v1.10.2 // indirect
7070
github.com/stoewer/go-strcase v1.3.0 // indirect
7171
github.com/subosito/gotenv v1.6.0 // indirect
@@ -86,7 +86,7 @@ require (
8686
go.uber.org/multierr v1.11.0 // indirect
8787
go.uber.org/zap v1.28.0 // indirect
8888
go.yaml.in/yaml/v2 v2.4.3 // indirect
89-
go.yaml.in/yaml/v3 v3.0.4 // indirect
89+
go.yaml.in/yaml/v3 v3.0.5 // indirect
9090
golang.org/x/crypto v0.47.0 // indirect
9191
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
9292
golang.org/x/net v0.49.0 // indirect

‎go.sum‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
7171
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
7272
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
7373
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
74-
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
74+
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
7575
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
7676
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
7777
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o=
@@ -134,8 +134,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
134134
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
135135
github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js=
136136
github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0=
137-
github.com/sourcegraph/jsonrpc2 v0.2.2 h1:fCyU80iidEwcF9kWaj4ylOO1h8pT8P8sFGv8EKemLaw=
138-
github.com/sourcegraph/jsonrpc2 v0.2.2/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo=
137+
github.com/sourcegraph/jsonrpc2 v0.2.3 h1:0VYp5WZ2irQvRK8OKKxZAbbplj6Pmda07MXm+Mdz3GQ=
138+
github.com/sourcegraph/jsonrpc2 v0.2.3/go.mod h1:0jBvyko0tdLpe6ntNSF/mvFalb/RPRXcmogoV47cEWc=
139139
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
140140
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
141141
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -152,14 +152,14 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
152152
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
153153
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
154154
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
155-
github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
156-
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
155+
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
156+
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
157157
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
158158
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
159159
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE=
160160
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk=
161-
github.com/vatesfr/xenorchestra-go-sdk v1.19.0 h1:QGFi9h6CioQFeNOLOmPfGiBt84uT1hDG/PtYkSZFsMA=
162-
github.com/vatesfr/xenorchestra-go-sdk v1.19.0/go.mod h1:lkAhxh9gLFZNLIubuMcjDKP49G2/H/h6wfWA3nWpO1o=
161+
github.com/vatesfr/xenorchestra-go-sdk v1.20.0 h1:zLNv4l8YhRnwamrXlcC65URJ7cfSh08wCHs6aTht2kc=
162+
github.com/vatesfr/xenorchestra-go-sdk v1.20.0/go.mod h1:pOG7n9YM9sxJc4EyE/uPf8JbMi/mEHkVLkQAzhL0jbM=
163163
github.com/vatesfr/xenorchestra-k8s-common v0.3.0 h1:nIALjBoPmtbQei8SezQCj4jNQ2umFiTuFw8cmwj4GFg=
164164
github.com/vatesfr/xenorchestra-k8s-common v0.3.0/go.mod h1:nBjpo85ypPajWO4zpDKAP6/k58y88zjGX445N/wzLDU=
165165
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -214,8 +214,9 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
214214
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
215215
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
216216
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
217-
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
218217
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
218+
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
219+
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
219220
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
220221
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
221222
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=

‎pkg/controllers/nodeoutofservice/node_out_of_service_controller.go‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ const (
5959
var (
6060
// SyncPeriod is how often the controller watches all nodes.
6161
SyncPeriod = DefaultSyncPeriod
62-
// GracePeriod is how long a powered-off VM must stay down while its node is
63-
// NotReady before the out-of-service taint is applied. A missing VM (deleted
64-
// from Xen Orchestra) is tainted immediately.
62+
// GracePeriod is how long a VM must be down or shutting down while its node
63+
// is NotReady before the out-of-service taint is applied. A missing VM
64+
// (deleted from Xen Orchestra) is tainted immediately.
6565
GracePeriod = DefaultGracePeriod
6666
)
6767

@@ -84,8 +84,9 @@ type Controller struct {
8484
i xenorchestra.XOInstances
8585
gracePeriod time.Duration
8686

87-
// firstObserved records, per node UID, when the "VM down and node NotReady"
88-
// condition was first observed. Only touched by the single sync goroutine.
87+
// firstObserved records, per node UID, when the "VM down or shutting down
88+
// and node NotReady" condition was first observed. Only touched by the
89+
// single sync goroutine.
8990
firstObserved map[string]time.Time
9091
}
9192

@@ -224,9 +225,9 @@ func (c *Controller) SyncNodes(ctx context.Context) error {
224225
instanceState = vm.PowerState
225226
instanceRunning = vm.PowerState == payloads.PowerStateRunning
226227
}
227-
instanceDown := !instanceRunning
228+
instanceDownOrShuttingDown := !instanceRunning || (!instanceMissing && vm.IsShuttingDown())
228229

229-
if instanceDown && !nodeReady {
230+
if instanceDownOrShuttingDown && !nodeReady {
230231
if _, seen := c.firstObserved[key]; !seen {
231232
c.firstObserved[key] = now
232233
}
@@ -235,8 +236,8 @@ func (c *Controller) SyncNodes(ctx context.Context) error {
235236
}
236237

237238
switch {
238-
case shouldApplyOutOfServiceTaint(node, instanceDown, !instanceMissing, nodeReady, c.firstObserved[key], now, c.gracePeriod):
239-
klog.InfoS("Applying out-of-service taint: VM is not running and node is not Ready",
239+
case shouldApplyOutOfServiceTaint(node, instanceDownOrShuttingDown, !instanceMissing, nodeReady, c.firstObserved[key], now, c.gracePeriod):
240+
klog.InfoS("Applying out-of-service taint: VM is down or shutting down and node is not Ready",
240241
"node", klog.KObj(node), "instanceState", instanceState)
241242
if err := addOutOfServiceTaint(c.kubeClient, node); err != nil {
242243
klog.ErrorS(err, "Failed to apply out-of-service taint", "node", klog.KObj(node))

‎pkg/controllers/nodeoutofservice/node_out_of_service_controller_test.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,24 @@ func TestSyncNodesDoesNotTaintRunningNode(t *testing.T) {
122122
assert.False(t, hasOutOfServiceTaint(getNode(t, client, node.Name)))
123123
}
124124

125+
func TestSyncNodesAppliesTaintAfterGraceWhenShutdownOperationIsInProgress(t *testing.T) {
126+
ctx := context.Background()
127+
node := testNode(notReady)
128+
inst := vmWithState(payloads.PowerStateRunning)
129+
inst.vm.CurrentOperations = map[string]payloads.VMOperation{
130+
"operation-id": payloads.VMOperationSuspend,
131+
}
132+
c, client := newTestController(t, node, inst, time.Minute)
133+
134+
require.NoError(t, c.SyncNodes(ctx))
135+
assert.False(t, hasOutOfServiceTaint(getNode(t, client, node.Name)))
136+
assert.Contains(t, c.firstObserved, string(node.UID))
137+
138+
c.firstObserved[string(node.UID)] = time.Now().Add(-2 * time.Minute)
139+
require.NoError(t, c.SyncNodes(ctx))
140+
assert.True(t, hasOutOfServiceTaint(getNode(t, client, node.Name)))
141+
}
142+
125143
func TestSyncNodesDoesNotTaintReadyNode(t *testing.T) {
126144
ctx := context.Background()
127145
node := testNode() // Ready

0 commit comments

Comments
 (0)