|
| 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 | +} |
0 commit comments