Skip to content

Commit dc72343

Browse files
authored
Split Cloud Hypervisor manager tests into focused modules (#8066)
* Initial plan * refactor: split cloud hypervisor manager tests * test: exclude Cloud Hypervisor manager fixtures from builds --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 50b5ba4 commit dc72343

8 files changed

Lines changed: 1619 additions & 1567 deletions
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
import type { ExecaChildProcess } from 'execa';
2+
import type { MicrovmNetworkLifecycle } from '../microvm/network';
3+
import { createMicrovmNetworkPlan } from '../microvm/network';
4+
import type { MicrovmVsockClient } from '../microvm/vsock-client';
5+
import type { CloudHypervisorApiClient } from './api-client';
6+
import type { CloudHypervisorCgroup } from './launcher';
7+
import type { CloudHypervisorCleanupRegistry } from './cleanup-registry';
8+
import type { CloudHypervisorVmmIdentityManager } from './vmm-identity';
9+
import { CloudHypervisorManager } from './manager';
10+
11+
import {
12+
virtiofsdManagerMock, config, processMock, networkConfig, guestConfig, cleanupHandleMock, vmmIdentityMock, dependencies,
13+
} from './manager.test-utils';
14+
15+
describe('stop and cleanup', () => {
16+
it('cleans up the network and cgroup before removing the run directory', async () => {
17+
const order: string[] = [];
18+
const deps = dependencies({
19+
createNetwork: jest.fn((plan) => ({
20+
plan,
21+
setup: jest.fn().mockResolvedValue(plan),
22+
cleanup: jest.fn(async () => {
23+
order.push('network');
24+
}),
25+
})),
26+
createCgroup: jest.fn(() => ({
27+
cgroupPath: '/sys/fs/cgroup/awf-cloud-hypervisor/cleanup',
28+
setup: jest.fn().mockResolvedValue(undefined),
29+
assign: jest.fn().mockResolvedValue(undefined),
30+
expectedLimits: jest.fn().mockReturnValue({
31+
memoryMax: String(768 * 1024 * 1024),
32+
cpuMax: '300000 100000',
33+
pidsMax: '256',
34+
}),
35+
cleanup: jest.fn(async () => {
36+
order.push('cgroup');
37+
}),
38+
} as unknown as CloudHypervisorCgroup)),
39+
rm: jest.fn(async () => {
40+
order.push('run-directory');
41+
}),
42+
createVmmIdentity: jest.fn(() => ({
43+
...vmmIdentityMock(),
44+
cleanup: jest.fn(async () => {
45+
order.push('vmm-identity');
46+
}),
47+
} as unknown as CloudHypervisorVmmIdentityManager)),
48+
});
49+
const manager = new CloudHypervisorManager(
50+
config(),
51+
'/tmp/awf',
52+
deps,
53+
'cleanup',
54+
networkConfig(),
55+
);
56+
57+
await manager.start();
58+
await manager.stop();
59+
60+
expect(order).toEqual(['network', 'cgroup', 'run-directory', 'vmm-identity']);
61+
});
62+
63+
it('commits cleanup intent before resources and removes it only after teardown', async () => {
64+
const order: string[] = [];
65+
const handle = cleanupHandleMock();
66+
(handle.complete as jest.Mock).mockImplementation(async () => { order.push('record-complete'); });
67+
const registry: CloudHypervisorCleanupRegistry = {
68+
reapPending: jest.fn(async () => { order.push('reap'); }),
69+
createPending: jest.fn(async () => {
70+
order.push('record-create');
71+
return handle;
72+
}),
73+
create: jest.fn().mockResolvedValue(handle),
74+
};
75+
const deps = dependencies({
76+
cleanupRegistry: registry,
77+
createNetwork: jest.fn((plan) => ({
78+
plan,
79+
setup: jest.fn(async () => {
80+
order.push('network-setup');
81+
return plan;
82+
}),
83+
cleanup: jest.fn(async () => { order.push('network-cleanup'); }),
84+
})),
85+
rm: jest.fn(async () => { order.push('run-directory'); }),
86+
});
87+
const manager = new CloudHypervisorManager(
88+
config(), '/tmp/awf', deps, 'durable-order', networkConfig(),
89+
);
90+
91+
await manager.start();
92+
await manager.stop();
93+
94+
expect(order.indexOf('reap')).toBeLessThan(order.indexOf('record-create'));
95+
expect(order.indexOf('record-create')).toBeLessThan(order.indexOf('network-setup'));
96+
expect(order.slice(-3)).toEqual(['network-cleanup', 'run-directory', 'record-complete']);
97+
});
98+
99+
it('creates no network reservation when durable cleanup record creation fails', async () => {
100+
const release = jest.fn().mockResolvedValue(undefined);
101+
const deps = dependencies({
102+
reserveNetwork: jest.fn(async (runId, options) => ({
103+
plan: createMicrovmNetworkPlan(runId, options),
104+
release,
105+
})),
106+
cleanupRegistry: {
107+
reapPending: jest.fn().mockResolvedValue(undefined),
108+
createPending: jest.fn().mockRejectedValue(new Error('registry unavailable')),
109+
create: jest.fn().mockRejectedValue(new Error('registry unavailable')),
110+
},
111+
});
112+
const manager = new CloudHypervisorManager(
113+
config(),
114+
'/tmp/awf',
115+
deps,
116+
'record-failure',
117+
networkConfig(),
118+
);
119+
120+
await expect(manager.start()).rejects.toThrow('registry unavailable');
121+
expect(release).not.toHaveBeenCalled();
122+
expect(deps.createNetwork).not.toHaveBeenCalled();
123+
});
124+
125+
it('quiesces and stops virtiofsd while preserving the run directory and network in keep mode', async () => {
126+
const child = processMock();
127+
const virtiofsd = virtiofsdManagerMock();
128+
const guestClient = {
129+
connect: jest.fn().mockResolvedValue(undefined),
130+
shutdown: jest.fn().mockResolvedValue(undefined),
131+
destroy: jest.fn(),
132+
} as unknown as MicrovmVsockClient;
133+
const deps = dependencies({
134+
launch: jest.fn().mockReturnValue(child),
135+
createVirtiofsdManager: jest.fn().mockReturnValue(virtiofsd),
136+
createVsockClient: jest.fn().mockReturnValue(guestClient),
137+
});
138+
const manager = new CloudHypervisorManager(
139+
config(),
140+
'/tmp/awf',
141+
deps,
142+
'keep',
143+
networkConfig(),
144+
guestConfig(),
145+
);
146+
await manager.start();
147+
await manager.startInstance();
148+
149+
await manager.stop({ preserve: true });
150+
151+
const lifecycle = (deps.createNetwork as jest.Mock).mock.results[0]
152+
.value as MicrovmNetworkLifecycle;
153+
expect(virtiofsd.stop).toHaveBeenCalledTimes(1);
154+
expect(lifecycle.cleanup).not.toHaveBeenCalled();
155+
expect(deps.rm).toHaveBeenCalledWith(
156+
'/prepared',
157+
{ recursive: true, force: true },
158+
);
159+
expect(deps.rm).not.toHaveBeenCalledWith(
160+
expect.stringContaining('/run/awf-cloud-hypervisor/'),
161+
expect.anything(),
162+
);
163+
const cgroup = (deps.createCgroup as jest.Mock).mock.results[0].value as CloudHypervisorCgroup;
164+
expect(cgroup.cleanup).toHaveBeenCalledTimes(1);
165+
});
166+
167+
it('invokes a beforeCleanup hook after process termination but before run-directory removal', async () => {
168+
// Regression test: Cloud Hypervisor does not flush buffered guest
169+
// serial console output until its process actually exits, so
170+
// diagnostics collection must happen after process termination is
171+
// confirmed but before stop() removes the run directory those
172+
// diagnostic files live in. Discovered via live-KVM validation: a
173+
// guest boot failure produced a completely empty serial console log
174+
// when diagnostics were collected any earlier (e.g. before
175+
// vmm.shutdown()/process termination).
176+
const child = processMock();
177+
const deps = dependencies({
178+
launch: jest.fn().mockReturnValue(child),
179+
});
180+
const manager = new CloudHypervisorManager(
181+
config(),
182+
'/tmp/awf',
183+
deps,
184+
'keep',
185+
networkConfig(),
186+
guestConfig(),
187+
);
188+
await manager.start();
189+
190+
const beforeCleanup = jest.fn(async () => {});
191+
192+
await manager.stop({ beforeCleanup });
193+
194+
expect(beforeCleanup).toHaveBeenCalledTimes(1);
195+
expect(deps.rm).toHaveBeenCalledWith(
196+
expect.stringContaining('/run/awf-cloud-hypervisor/'),
197+
{ recursive: true, force: true },
198+
);
199+
// beforeCleanup must run strictly before the run-directory removal
200+
// call (deps.rm), i.e. after process termination is confirmed but
201+
// before diagnostic files are deleted.
202+
const runRmIndex = (deps.rm as jest.Mock).mock.calls.findIndex(
203+
([target]) => String(target).startsWith('/run/awf-cloud-hypervisor/'),
204+
);
205+
const rmCallOrder = (deps.rm as jest.Mock).mock.invocationCallOrder[runRmIndex];
206+
expect(beforeCleanup.mock.invocationCallOrder[0]).toBeLessThan(rmCallOrder);
207+
});
208+
209+
it('propagates a beforeCleanup hook failure alongside other stop() errors', async () => {
210+
const child = processMock();
211+
const deps = dependencies({
212+
launch: jest.fn().mockReturnValue(child),
213+
});
214+
const manager = new CloudHypervisorManager(
215+
config(),
216+
'/tmp/awf',
217+
deps,
218+
'keep',
219+
networkConfig(),
220+
guestConfig(),
221+
);
222+
await manager.start();
223+
224+
await expect(
225+
manager.stop({
226+
beforeCleanup: async () => {
227+
throw new Error('diagnostics write failed');
228+
},
229+
}),
230+
).rejects.toThrow(/diagnostics write failed/);
231+
// Run-directory removal must still proceed even if beforeCleanup fails.
232+
expect(deps.rm).toHaveBeenCalledWith(
233+
expect.stringContaining('/run/awf-cloud-hypervisor/'),
234+
{ recursive: true, force: true },
235+
);
236+
const vmmIdentity = (deps.createVmmIdentity as jest.Mock).mock.results[0]
237+
.value as CloudHypervisorVmmIdentityManager;
238+
expect(vmmIdentity.cleanup).toHaveBeenCalledTimes(1);
239+
});
240+
241+
it('retains virtiofsd and network until process termination is confirmed', async () => {
242+
const child = Promise.resolve({ exitCode: null }) as unknown as ExecaChildProcess<string>;
243+
Object.assign(child, {
244+
exitCode: null,
245+
signalCode: null,
246+
killed: false,
247+
pid: 9,
248+
kill: jest.fn(() => {
249+
Object.assign(child, { killed: true });
250+
return true;
251+
}),
252+
});
253+
const virtiofsd = virtiofsdManagerMock();
254+
const guestClient = {
255+
connect: jest.fn().mockResolvedValue(undefined),
256+
shutdown: jest.fn().mockResolvedValue(undefined),
257+
destroy: jest.fn(),
258+
} as unknown as MicrovmVsockClient;
259+
const deps = dependencies({
260+
launch: jest.fn().mockReturnValue(child),
261+
createVirtiofsdManager: jest.fn().mockReturnValue(virtiofsd),
262+
createVsockClient: jest.fn().mockReturnValue(guestClient),
263+
});
264+
const manager = new CloudHypervisorManager(
265+
config(),
266+
'/tmp/awf',
267+
deps,
268+
'termination',
269+
networkConfig(),
270+
guestConfig(),
271+
);
272+
await manager.start();
273+
await manager.startInstance();
274+
275+
await expect(manager.stop()).rejects.toThrow(/stopped before network\/run-directory removal/);
276+
const lifecycle = (deps.createNetwork as jest.Mock).mock.results[0]
277+
.value as MicrovmNetworkLifecycle;
278+
expect(lifecycle.cleanup).not.toHaveBeenCalled();
279+
expect(virtiofsd.stop).toHaveBeenCalledTimes(1);
280+
expect(deps.rm).not.toHaveBeenCalled();
281+
282+
Object.assign(child, { exitCode: 0 });
283+
await expect(manager.stop()).resolves.toBeUndefined();
284+
expect(virtiofsd.stop).toHaveBeenCalledTimes(1);
285+
expect(lifecycle.cleanup).toHaveBeenCalledTimes(1);
286+
});
287+
288+
it('waits briefly for natural VM exit after guest shutdown before sending SIGTERM', async () => {
289+
const child = processMock();
290+
const guestClient = {
291+
connect: jest.fn().mockResolvedValue(undefined),
292+
shutdown: jest.fn().mockResolvedValue(undefined),
293+
destroy: jest.fn(),
294+
} as unknown as MicrovmVsockClient;
295+
let sleepCalls = 0;
296+
const deps = dependencies({
297+
launch: jest.fn().mockReturnValue(child),
298+
createVsockClient: jest.fn().mockReturnValue(guestClient),
299+
sleep: jest.fn(async () => {
300+
sleepCalls += 1;
301+
if (sleepCalls === 3) Object.assign(child, { exitCode: 0 });
302+
}),
303+
});
304+
const manager = new CloudHypervisorManager(
305+
config(),
306+
'/tmp/awf',
307+
deps,
308+
'natural-exit',
309+
networkConfig(),
310+
guestConfig(),
311+
);
312+
await manager.start();
313+
await manager.startInstance();
314+
await manager.stop();
315+
expect(child.kill).not.toHaveBeenCalled();
316+
expect(sleepCalls).toBeGreaterThan(0);
317+
});
318+
319+
it('rolls back the network and cgroup when vm.create fails', async () => {
320+
const client = {
321+
ping: jest.fn().mockResolvedValue({ version: '53.0' }),
322+
vmCreate: jest.fn().mockRejectedValue(new Error('invalid disk path')),
323+
vmBoot: jest.fn().mockResolvedValue(undefined),
324+
vmInfo: jest.fn().mockResolvedValue({ state: 'Created' }),
325+
vmCounters: jest.fn().mockResolvedValue({}),
326+
vmShutdown: jest.fn().mockResolvedValue(undefined),
327+
vmmShutdown: jest.fn().mockResolvedValue(undefined),
328+
} as unknown as CloudHypervisorApiClient;
329+
const deps = dependencies({
330+
createClient: jest.fn().mockReturnValue(client),
331+
});
332+
const manager = new CloudHypervisorManager(
333+
config(),
334+
'/tmp/awf',
335+
deps,
336+
'create-failure',
337+
networkConfig(),
338+
);
339+
340+
await expect(manager.start()).rejects.toThrow('invalid disk path');
341+
342+
const lifecycle = (deps.createNetwork as jest.Mock).mock.results[0]
343+
.value as MicrovmNetworkLifecycle;
344+
expect(lifecycle.cleanup).toHaveBeenCalledTimes(1);
345+
expect(deps.rm).toHaveBeenCalled();
346+
const cgroup = (deps.createCgroup as jest.Mock).mock.results[0].value as CloudHypervisorCgroup;
347+
expect(cgroup.cleanup).toHaveBeenCalledTimes(1);
348+
});
349+
350+
});
351+

0 commit comments

Comments
 (0)