Skip to content

Commit 81fa284

Browse files
committed
Merge branch 'master' into rucoder/kube-init-go
Resolved the merge conflict that appeared in TestNodeClusterSuite after we merged lf-edge#6269
2 parents 186e027 + 264701b commit 81fa284

9 files changed

Lines changed: 960 additions & 53 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
// Copyright (c) 2026 Zededa, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package cluster_test
5+
6+
import (
7+
"fmt"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
// revive:disable:dot-imports
13+
. "github.com/onsi/gomega"
14+
15+
uuid "github.com/satori/go.uuid"
16+
17+
"github.com/lf-edge/eve-api/go/evecommon"
18+
eveinfo "github.com/lf-edge/eve-api/go/info"
19+
"github.com/lf-edge/eve/evetest"
20+
"github.com/lf-edge/eve/evetest/matchers"
21+
"github.com/lf-edge/eve/evetest/netmodels"
22+
"github.com/lf-edge/eve/pkg/pillar/base"
23+
"github.com/lf-edge/eve/pkg/pillar/types"
24+
)
25+
26+
// purgeMarkerPath is written into the app's root filesystem before the purge.
27+
// A purge keeps the app's existing volumes (zedmanager's doUpdate: "Keep the
28+
// old volumes in place"), so the marker is expected to SURVIVE. Only a
29+
// purge&update, where the controller sends volume refs with new generation
30+
// counters, causes volumemgr to build fresh volumes.
31+
const purgeMarkerPath = "/root/purge-marker"
32+
33+
// appPurgeMarkerState reports "PRESENT" or "ABSENT" for purgeMarkerPath inside
34+
// the app. Phrased as an echo rather than relying on the exit status of `test`,
35+
// so the result does not depend on how a non-zero exit is surfaced.
36+
func appPurgeMarkerState(device *evetest.EdgeDevice, appUUID uuid.UUID,
37+
auth evetest.AuthMethod, timeout time.Duration) (string, string, error) {
38+
return device.RunShellScriptInsideApp(appUUID, auth,
39+
"test -f "+purgeMarkerPath+" && echo PRESENT || echo ABSENT", timeout, 0)
40+
}
41+
42+
// clusterHasVMIWithPrefix reports whether ZInfoKubeCluster lists a VMI whose
43+
// name starts with prefix. VMIs created by a VMIRS are named
44+
// "<vmirs-name>-<random suffix>", so the prefix identifies the owning VMIRS.
45+
func clusterHasVMIWithPrefix(info *eveinfo.ZInfoKubeCluster, prefix string) bool {
46+
for _, vmi := range info.GetEveVmApps() {
47+
if strings.HasPrefix(vmi.GetName(), prefix) {
48+
return true
49+
}
50+
}
51+
return false
52+
}
53+
54+
// TestAppInstancePurge verifies the happy-path purge of a running application on
55+
// a single-node EVE-K cluster: no node outage, no node reboot, no injected
56+
// fault. The controller increments the app's purge counter and EVE is expected
57+
// to tear the old workload down and bring an equivalent one back up.
58+
//
59+
// Under Kubevirt a purge changes the app's Kubernetes identity -- the VMIRS name
60+
// embeds the purge counter (base.GetAppKubeNameWithPurge) -- so the old VMIRS
61+
// must be deleted before the new one is created. That teardown runs through
62+
// domainmgr's handleModify path with impatient=true, which deliberately bypasses
63+
// the cluster-trust guard in doInactivate that otherwise leaves a VMIRS alone
64+
// while UserActivate=true. This test is the happy-path counterpart to
65+
// TestVMIRSStrandedReplicasRecovery: that one injects a fault, this one checks
66+
// the ordinary lifecycle is not disturbed.
67+
//
68+
// Purge vs. purge&update
69+
// ----------------------
70+
// A purge on its own does NOT recreate the app's volumes: zedmanager's doUpdate
71+
// hands the teardown to doRemove with uninstall=false explicitly to "keep the
72+
// old volumes in place", and purgeCmdDone only drops volume refs that the new
73+
// config no longer references. Volumes are rebuilt only when the config carries
74+
// new volume generation counters, i.e. a purge&update. This test therefore
75+
// asserts the marker file SURVIVES; a future purge&update test should assert the
76+
// opposite.
77+
//
78+
// Network model
79+
// -------------
80+
// - netmodels.SingleEthWithDHCP -- one mgmt+app port, SDN DNS, controller
81+
// reachable. Port forwarding (2222->22) is needed so the marker file can be
82+
// written and re-checked inside the app over SSH.
83+
//
84+
// Device configuration
85+
// --------------------
86+
// - clusterDeviceRequirements (cluster_test.go): WithHypervisor=Kubevirt,
87+
// DeviceReusePolicy=CreateFromScratchWithLiveImage, ext4, plus grub options
88+
// that cap dom0/eve/ctrd vcpus so cluster formation is fast. That policy
89+
// re-creates the VM even when a matching one exists, so this test always
90+
// forms its own cluster; only the harness (Adam, SDN, broker) is shared with
91+
// the other subtests. Running it standalone therefore costs no more than
92+
// running it as part of the suite.
93+
// - SystemAdapter on eth0 (DHCP, mgmt+app, NetworkType=V4Only).
94+
// - One Local NI "local-ni" and one container app (VirtualizationMode left at
95+
// its default, i.e. not NOHYPER, so the app is backed by a VMIRS/VMI rather
96+
// than a plain k8s ReplicaSet -- the code path a purge has to rename).
97+
//
98+
// Test parameters
99+
// ---------------
100+
// - TPM via evetest.TPMParameter() (suite-wide, see TestNodeClusterSuite).
101+
//
102+
// Phases
103+
// ------
104+
// 1. setup-done -> initial-config-applied: apply the device config.
105+
// 2. app-is-deployed: WaitUntilAppIsRunning (10 min budget excluding download).
106+
// 3. marker-written: touch purgeMarkerPath inside the app and confirm it reads
107+
// back as PRESENT, wrapped in Eventually since the app's SSH daemon comes up
108+
// some time after the app reports RUNNING.
109+
// 4. watches-started: subscribe to ZInfoApp and ZInfoKubeCluster, and record the
110+
// pre-purge VMIRS name. The watches are started HERE, immediately before the
111+
// purge, and not earlier: they are live subscriptions with buffered channels,
112+
// so a watch opened before the deployment would hand the assertions below the
113+
// entire deploy backlog and they would match stale RUNNING / non-RUNNING
114+
// messages instantly, passing without observing the purge at all.
115+
// 5. purge-requested: increment the purge counter via PurgeApplication.
116+
// waitUntilPurged=false because that helper waits via IterateDeviceInfoMsgs,
117+
// which replays Adam's stored messages before following and so would match
118+
// the pre-purge RUNNING immediately.
119+
// 6. app-left-running: ZInfoApp reports a state other than RUNNING. This proves
120+
// the purge was acted on rather than coalescing into a no-op, and is
121+
// deliberately "not RUNNING" rather than a specific PURGING/HALTING so the
122+
// test does not depend on which transitional states get published.
123+
// 7. app-recovered: ZInfoApp returns to State=RUNNING with no AppErr.
124+
// 8. vmirs-renamed: ZInfoKubeCluster lists a VMI belonging to the purge-counter-1
125+
// VMIRS and none belonging to the purge-counter-0 one. This is what separates
126+
// a purge from a plain restart -- a restart reuses the VMIRS name -- and it is
127+
// the assertion that would catch the old VMIRS being left behind.
128+
// 9. volumes-preserved: purgeMarkerPath still reads PRESENT (see "Purge vs.
129+
// purge&update" above).
130+
//
131+
// Suite placement
132+
// ---------------
133+
// - TestNodeClusterSuite (cluster tests are pinned to Kubevirt; purge under
134+
// Kubevirt is the path being exercised).
135+
func TestAppInstancePurge(test *testing.T) {
136+
evetestT := evetest.Init(test)
137+
t := NewGomegaWithT(evetestT)
138+
defer evetest.Close()
139+
140+
// Define configurable parameters available for the test.
141+
evetest.DefineTestParameters(
142+
evetest.TPMParameter(),
143+
)
144+
withTPM := evetest.GetTPMParameterValue()
145+
146+
// Set up the test harness and specify the test prerequisites.
147+
devName := "edge-dev"
148+
requiredDevice := clusterDeviceRequirements(devName, withTPM, evetest.FilesystemEXT4)
149+
requiredNetModel := evetest.RequireNetworkModel{
150+
NetworkModel: netmodels.SingleEthWithDHCP,
151+
}
152+
evetest.Setup(requiredDevice, requiredNetModel)
153+
evetest.Checkpoint("setup-done")
154+
155+
// Build and apply the initial device configuration.
156+
devConfig := evetest.NewEdgeDeviceConfig(devName)
157+
dhcpNet := devConfig.AddNetwork(
158+
evetest.DHCPNetworkConfig{
159+
NetworkType: evecommon.NetworkType_V4Only,
160+
})
161+
devConfig.AddNetworkAdapter(
162+
evetest.NetworkAdapterConfig{
163+
LogicalLabel: "ethernet0",
164+
PhysicalLabel: "eth0",
165+
InterfaceName: "eth0",
166+
NetworkUUID: dhcpNet,
167+
Usage: evecommon.PhyIoMemberUsage_PhyIoUsageMgmtAndApps,
168+
})
169+
170+
niUUID := devConfig.AddNetworkInstance(evetest.LocalNetworkInstanceConfig{
171+
DisplayName: "local-ni",
172+
Port: "ethernet0",
173+
Subnet: evetest.IPSubnet("10.11.12.0/24"),
174+
DHCPRange: types.IPRange{
175+
Start: evetest.IPAddress("10.11.12.2"),
176+
End: evetest.IPAddress("10.11.12.254"),
177+
},
178+
Gateway: evetest.IPAddress("10.11.12.1"),
179+
MTU: 1500,
180+
})
181+
const appDisplayName = "purge-app"
182+
appUUID := devConfig.AddApplication(evetest.ApplicationInstanceConfig{
183+
DisplayName: appDisplayName,
184+
Activate: true,
185+
Image: evetest.DockerContainer{
186+
ImageName: "lfedge/evetest-ubuntu-ctr",
187+
Tag: "1.0",
188+
},
189+
CPUs: 1,
190+
MemoryBytes: 500 * evetest.MiB,
191+
NetworkAdapters: []evetest.AppNetworkAdapter{
192+
evetest.VirtualNetworkAdapter{
193+
LogicalLabel: "vif0",
194+
NetworkInstanceUUID: niUUID,
195+
PortFwdRules: []evetest.PortFwdRule{
196+
{
197+
Protocol: evetest.NetworkProtocolTCP,
198+
EdgeNodePort: 2222,
199+
AppPort: 22,
200+
},
201+
},
202+
ACLAllowRules: []evetest.ACLAllowRule{
203+
{
204+
Protocol: evetest.NetworkProtocolAny,
205+
RemoteSubnet: evetest.IPSubnet("0.0.0.0/0"),
206+
},
207+
},
208+
},
209+
},
210+
})
211+
212+
device := evetest.GetEdgeDevice(devName)
213+
device.ApplyConfig(devConfig, true, true)
214+
log := evetest.Logger()
215+
log.Infof("Submitted config with app UUID=%v", appUUID)
216+
evetest.Checkpoint("initial-config-applied")
217+
218+
timeoutExcludingDownload := 10 * time.Minute
219+
device.WaitUntilAppIsRunning(appUUID, timeoutExcludingDownload)
220+
evetest.Checkpoint("app-is-deployed")
221+
222+
// Mark the app's root filesystem so volume handling across the purge is
223+
// observable. RunShellScriptInsideApp uses the 2222->22 port forwarding rule.
224+
appAuth := evetest.UsernamePasswordAuth{
225+
Username: "root",
226+
Password: "testpassword",
227+
}
228+
sshTimeout := 20 * time.Second
229+
sshReadyTimeout := 3 * time.Minute
230+
polling := 3 * time.Second
231+
log.Infof("Writing purge marker %s inside the app", purgeMarkerPath)
232+
t.Eventually(func(t Gomega) {
233+
_, stderr, err := device.RunShellScriptInsideApp(appUUID, appAuth,
234+
"touch "+purgeMarkerPath, sshTimeout, 0)
235+
t.Expect(err).ToNot(HaveOccurred(), stderr)
236+
}, sshReadyTimeout, polling).Should(Succeed())
237+
stdout, stderr, err := appPurgeMarkerState(device, appUUID, appAuth, sshTimeout)
238+
t.Expect(err).ToNot(HaveOccurred(), stderr)
239+
t.Expect(stdout).To(ContainSubstring("PRESENT"))
240+
evetest.Checkpoint("marker-written")
241+
242+
// Start the watches only now: they are live subscriptions over buffered
243+
// channels, so opening them earlier would leave the assertions below matching
244+
// the deploy backlog instead of the purge.
245+
//
246+
// The VMIRS name is GetAppKubeName(displayName, uuid) + "-" + purgeCounter
247+
// (pkg/pillar/base/kubevirt.go, GetAppKubeNameWithPurge); the counter is 0 for
248+
// a freshly deployed app and 1 after this test's purge. Appended manually
249+
// rather than calling GetAppKubeNameWithPurge directly since that helper
250+
// postdates the pkg/pillar version currently pinned in evetest/go.mod.
251+
kubeName := base.GetAppKubeName(appDisplayName, appUUID)
252+
vmirsBeforePurge := fmt.Sprintf("%s-0", kubeName)
253+
vmirsAfterPurge := fmt.Sprintf("%s-1", kubeName)
254+
appUpdates, stopAppWatch := device.WatchAppInfo(appUUID)
255+
defer stopAppWatch()
256+
clusterUpdates, stopClusterWatch := device.WatchClusterInfo()
257+
defer stopClusterWatch()
258+
evetest.Checkpoint("watches-started")
259+
260+
// Request the purge.
261+
log.Infof("Purging app %v (VMIRS %s -> %s)", appUUID,
262+
vmirsBeforePurge, vmirsAfterPurge)
263+
device.PurgeApplication(appUUID, false, 0)
264+
evetest.Checkpoint("purge-requested")
265+
266+
// The app must actually leave RUNNING, otherwise a purge dropped on the floor
267+
// would satisfy the recovery assertion below trivially.
268+
purgeStartTimeout := 3 * time.Minute
269+
t.Eventually(appUpdates, purgeStartTimeout).Should(Receive(matchers.SatisfyPredicate(
270+
"app left the RUNNING state to be purged",
271+
func(ainfo *eveinfo.ZInfoApp) bool {
272+
return ainfo.GetState() != eveinfo.ZSwState_RUNNING
273+
})))
274+
evetest.Checkpoint("app-left-running")
275+
276+
// The app should come back with no error reported.
277+
recoveryTimeout := 10 * time.Minute
278+
t.Eventually(appUpdates, recoveryTimeout).Should(Receive(matchers.SatisfyPredicate(
279+
"app returned to RUNNING with no error after the purge",
280+
func(ainfo *eveinfo.ZInfoApp) bool {
281+
return ainfo.GetState() == eveinfo.ZSwState_RUNNING &&
282+
len(ainfo.GetAppErr()) == 0
283+
})))
284+
evetest.Checkpoint("app-recovered")
285+
286+
// The purge counter is part of the VMIRS name, so the workload must have moved
287+
// to the new VMIRS and the old one must be gone. A plain restart would keep
288+
// the old name, and a failed teardown would leave both present.
289+
vmirsTimeout := 5 * time.Minute
290+
t.Eventually(clusterUpdates, vmirsTimeout).Should(Receive(matchers.SatisfyPredicate(
291+
fmt.Sprintf("VMI moved to VMIRS %s and none left on %s",
292+
vmirsAfterPurge, vmirsBeforePurge),
293+
func(info *eveinfo.ZInfoKubeCluster) bool {
294+
return clusterHasVMIWithPrefix(info, vmirsAfterPurge) &&
295+
!clusterHasVMIWithPrefix(info, vmirsBeforePurge)
296+
})))
297+
evetest.Checkpoint("vmirs-renamed")
298+
299+
// A purge keeps the app's volumes, so the marker must still be there.
300+
log.Infof("Checking that purge marker %s survived the purge", purgeMarkerPath)
301+
t.Eventually(func(t Gomega) {
302+
stdout, stderr, err := appPurgeMarkerState(device, appUUID, appAuth, sshTimeout)
303+
t.Expect(err).ToNot(HaveOccurred(), stderr)
304+
t.Expect(stdout).To(ContainSubstring("PRESENT"))
305+
}, sshReadyTimeout, polling).Should(Succeed())
306+
evetest.Checkpoint("volumes-preserved")
307+
}

evetest/tests/cluster/testsuite_test.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import (
1010
)
1111

1212
// TestNodeClusterSuite is the top-level entry point for cluster tests.
13-
// It runs TestSingleNodeCluster, TestThreeNodesCluster and then
14-
// TestClusterToSingleConversion, reusing the evetest harness (Adam
15-
// controller, SDN, broker) across the subtests for efficiency. All
16-
// subtests pin the device to the Kubevirt hypervisor (cluster tests are
17-
// the only ones that use Kubevirt).
13+
// It reuses the evetest harness (Adam controller, SDN, broker) across
14+
// the subtests for efficiency. All subtests pin the device to the Kubevirt
15+
// hypervisor (aka eve-k).
16+
//
17+
// The single-node subtests run before the three-node one, and the happy-path
18+
// purge runs before the fault-injecting VMIRS test, so a failure in the
19+
// ordinary app lifecycle is not masked by chaos.
1820
//
1921
// TestClusterToSingleConversion runs last and shares the three-device,
2022
// SeparateClusterPort requirements of TestThreeNodesCluster so the VMs
@@ -25,7 +27,7 @@ import (
2527
// Test parameters
2628
// ---------------
2729
// - TPM (bool) via evetest.TPMParameter(). The suite passes the same
28-
// TPM choice to both subtests.
30+
// TPM choice to all subtests.
2931
func TestNodeClusterSuite(test *testing.T) {
3032
evetest.Init(test)
3133
defer evetest.Close()
@@ -39,6 +41,12 @@ func TestNodeClusterSuite(test *testing.T) {
3941
evetest.TestCase{
4042
Test: TestSingleNodeCluster,
4143
},
44+
evetest.TestCase{
45+
Test: TestAppInstancePurge,
46+
},
47+
evetest.TestCase{
48+
Test: TestVMIRSStrandedReplicasRecovery,
49+
},
4250
evetest.TestCase{
4351
Test: TestThreeNodesCluster,
4452
},

0 commit comments

Comments
 (0)