Coverage for tests/streamwise/test_streamwise_node.py: 100%

172 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-09 04:47 +0000

1#!/usr/bin/env python3 

2""" 

3Unit tests for streamwise.py node-related endpoints. 

4""" 

5 

6import sys 

7import pytest 

8 

9from typing import Any 

10from typing import Dict 

11 

12from unittest.mock import patch 

13from unittest.mock import AsyncMock 

14 

15from quart.typing import TestClientProtocol 

16 

17from http import HTTPStatus 

18 

19from tests.test_utils import temp_sys_path 

20from tests.k8s_mock import K8sMock 

21 

22mock_k8s = K8sMock() 

23 

24mock_modules = {} 

25mock_modules.update(mock_k8s.get_sub_modules()) 

26with patch.dict(sys.modules, mock_modules): 

27 with temp_sys_path("streamwise"): 

28 from streamwise import streamwise as sw 

29 # node_manager is imported by streamwise.py at module level; 

30 # access it via the module attribute so we get the exact same object 

31 node_manager = sw.node_manager 

32 

33 

34def _get_client() -> TestClientProtocol: 

35 app = sw.app 

36 client = app.test_client() 

37 return client 

38 

39 

40@pytest.fixture(scope="function", autouse=True) 

41def setup_k8s_cluster() -> None: 

42 # for some reason k8s_config.load_kube_config() is not async mocked 

43 sw.k8s_cluster = "unittest" 

44 

45 

46@pytest.mark.asyncio 

47async def test_nodes() -> None: 

48 client = _get_client() 

49 response = await client.get("/nodes") 

50 assert response.status_code == HTTPStatus.NOT_FOUND 

51 response_json = await response.get_json() 

52 assert response_json == {"error": "No nodes found"} 

53 

54 

55@pytest.mark.asyncio 

56async def test_api_nodes() -> None: 

57 client = _get_client() 

58 response = await client.get("/api/nodes") 

59 assert response.status_code == HTTPStatus.OK 

60 response_json = await response.get_json() 

61 assert response_json == [] 

62 

63 

64@pytest.mark.asyncio 

65async def test_remove_node() -> None: 

66 client = _get_client() 

67 response = await client.delete("/api/node/testnode") 

68 assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR 

69 response_json = await response.get_json() 

70 assert response_json == {"error": "object MagicMock can't be used in 'await' expression"} # TODO 

71 

72 

73@pytest.mark.asyncio 

74async def test_node_info() -> None: 

75 client = _get_client() 

76 response = await client.get("/node/testnode") 

77 assert response.status_code == HTTPStatus.NOT_FOUND 

78 response_json = await response.get_json() 

79 assert response_json == {"error": "Node 'testnode' not found"} 

80 

81 

82_MOCK_NODE = { 

83 "node_name": "testnode", 

84 "region": "eastus", 

85 "resource_group": "rg-test", 

86 "addresses": [{ 

87 "type": "InternalIP", 

88 "address": "10.0.0.1" 

89 }], 

90 "is_ready": True, 

91 "capacity_resources": { 

92 "cpu": 4.0, 

93 "memory": 8 * 1024 * 1024 * 1024, 

94 "storage": 100 * 1024 * 1024 * 1024, 

95 "gpu": "N/A" 

96 }, 

97 "allocatable_resources": { 

98 "cpu": 4.0, 

99 "memory": 8 * 1024 * 1024 * 1024, 

100 "storage": 100 * 1024 * 1024 * 1024, 

101 "gpu": "N/A" 

102 }, 

103 "architecture": "amd64", 

104 "kernel_version": "5.15.0", 

105 "os_image": "Ubuntu 22.04", 

106 "creation_timestamp": "2024-01-01T00:00:00Z", 

107 "labels": {}, 

108 "images": None, 

109 "gpu_model": "N/A", 

110 "mig_enabled": False, 

111 "mig_resources": {}, 

112} 

113 

114 

115@pytest.mark.asyncio 

116async def test_nodes_has_submit_job_button() -> None: 

117 client = _get_client() 

118 with patch.object(node_manager, "get_k8s_nodes", new=AsyncMock(return_value=[_MOCK_NODE])): 

119 with patch.object(node_manager, "get_k8s_pods", new=AsyncMock(return_value=[])): 

120 response = await client.get("/nodes") 

121 assert response.status_code == HTTPStatus.OK 

122 response_text = await response.get_data(as_text=True) 

123 assert 'href="/job/"' in response_text 

124 assert 'title="Submit Job"' in response_text 

125 

126 

127@pytest.mark.asyncio 

128async def test_node_shows_mig_enabled() -> None: 

129 """Node page shows MIG enabled badge when mig_enabled is True.""" 

130 client = _get_client() 

131 

132 mig_node: Dict[str, Any] = dict(_MOCK_NODE) 

133 mig_node.update({ 

134 "mig_enabled": True, 

135 "gpu_model": "NVIDIA-A100-SXM4-80GB", 

136 "capacity_resources": dict(mig_node["capacity_resources"], gpu=7), 

137 "allocatable_resources": dict(mig_node["allocatable_resources"], gpu="N/A"), 

138 "mig_resources": { 

139 "1g.10gb": { 

140 "capacity": 7, 

141 "allocatable": 7 

142 } 

143 } 

144 }) 

145 

146 with patch.object(node_manager, "get_k8s_nodes", new=AsyncMock(return_value=[mig_node])): 

147 with patch.object(node_manager, "get_k8s_pods", new=AsyncMock(return_value=[])): 

148 response = await client.get("/nodes") 

149 

150 assert response.status_code == HTTPStatus.OK 

151 response_text = await response.get_data(as_text=True) 

152 msg = '<span class="text-success" title="Multi-Instance GPU" aria-label="MIG enabled">\u2705</span>' 

153 assert msg in response_text 

154 

155 

156@pytest.mark.asyncio 

157async def test_node_shows_mig_resources() -> None: 

158 """Node page shows per-profile MIG resource counts in the Resources table.""" 

159 client = _get_client() 

160 

161 mig_node: Dict[str, Any] = dict(_MOCK_NODE) 

162 mig_node.update({ 

163 "mig_enabled": True, 

164 "gpu_model": "NVIDIA-A100-SXM4-80GB", 

165 "mig_resources": { 

166 "1g.5gb": { 

167 "capacity": 7, 

168 "allocatable": 5 

169 }, 

170 "2g.10gb": { 

171 "capacity": 3, 

172 "allocatable": 2 

173 }, 

174 } 

175 }) 

176 

177 with patch.object(node_manager, "get_k8s_nodes", new=AsyncMock(return_value=[mig_node])): 

178 with patch.object(node_manager, "get_k8s_pods", new=AsyncMock(return_value=[])): 

179 response = await client.get("/nodes") 

180 

181 assert response.status_code == HTTPStatus.OK 

182 response_text = await response.get_data(as_text=True) 

183 assert "1g.5gb" in response_text 

184 assert "2g.10gb" in response_text 

185 # Allocatable and capacity counts visible 

186 assert "5" in response_text 

187 assert "2" in response_text 

188 

189 

190@pytest.mark.asyncio 

191async def test_node_shows_mig_disabled() -> None: 

192 """Node page shows no MIG indicator when mig_enabled is False.""" 

193 client = _get_client() 

194 with patch.object(node_manager, "get_k8s_nodes", new=AsyncMock(return_value=[_MOCK_NODE])): 

195 with patch.object(node_manager, "get_k8s_pods", new=AsyncMock(return_value=[])): 

196 response = await client.get("/nodes") 

197 

198 assert response.status_code == HTTPStatus.OK 

199 response_text = await response.get_data(as_text=True) 

200 assert "MIG" in response_text # row label is always present 

201 assert "Enabled" not in response_text # but not the "Enabled" status text 

202 

203 

204@pytest.mark.asyncio 

205async def test_pod_shows_mig_profile() -> None: 

206 """Node page shows MIG profile badge in the pods table for MIG pods.""" 

207 client = _get_client() 

208 mig_pod = { 

209 "namespace": "rtgen", 

210 "pod_name": "flux-pod", 

211 "status": "Running", 

212 "pod_ip": "10.0.0.5", 

213 "container_name": "flux", 

214 "url": "http://10.0.0.5:8080", 

215 "node": "testnode", 

216 "cpu": 2, 

217 "memory": 4 * 1024 * 1024 * 1024, 

218 "gpu": 1, 

219 "mig_profile": "1g.10gb", 

220 } 

221 with patch.object(node_manager, "get_k8s_nodes", new=AsyncMock(return_value=[_MOCK_NODE])): 

222 with patch.object(node_manager, "get_k8s_pods", new=AsyncMock(return_value=[mig_pod])): 

223 response = await client.get("/nodes") 

224 

225 assert response.status_code == HTTPStatus.OK 

226 response_text = await response.get_data(as_text=True) 

227 assert "1g.10gb" in response_text 

228 assert "MIG slice" in response_text 

229 

230 

231@pytest.mark.asyncio 

232async def test_pod_shows_no_mig_profile_for_full_gpu() -> None: 

233 """Node page shows plain GPU count for full-GPU pods (no MIG profile).""" 

234 client = _get_client() 

235 full_gpu_pod = { 

236 "namespace": "rtgen", 

237 "pod_name": "flux-pod", 

238 "status": "Running", 

239 "pod_ip": "10.0.0.5", 

240 "container_name": "flux", 

241 "url": "http://10.0.0.5:8080", 

242 "node": "testnode", 

243 "cpu": 2, 

244 "memory": 4 * 1024 * 1024 * 1024, 

245 "gpu": 1, 

246 "mig_profile": None, 

247 } 

248 

249 with patch.object(node_manager, "get_k8s_nodes", new=AsyncMock(return_value=[_MOCK_NODE])): 

250 with patch.object(node_manager, "get_k8s_pods", new=AsyncMock(return_value=[full_gpu_pod])): 

251 response = await client.get("/nodes") 

252 

253 assert response.status_code == HTTPStatus.OK 

254 response_text = await response.get_data(as_text=True) 

255 assert "MIG slice" not in response_text 

256 

257 

258@pytest.mark.asyncio 

259async def test_mig_visible_when_gpu_capacity_zero() -> None: 

260 """Nodes table shows MIG profiles even when capacity_resources.gpu is 0 (single strategy).""" 

261 client = _get_client() 

262 mig_node: Dict[str, Any] = dict(_MOCK_NODE) 

263 mig_node.update({ 

264 "mig_enabled": True, 

265 "gpu_model": "NVIDIA-A100-SXM4-80GB", 

266 "capacity_resources": dict(mig_node["capacity_resources"], gpu=0), 

267 "allocatable_resources": dict(mig_node["allocatable_resources"], gpu=0), 

268 "mig_resources": { 

269 "1g.10gb": {"capacity": 3, "allocatable": 3}, 

270 "2g.20gb": {"capacity": 2, "allocatable": 2}, 

271 } 

272 }) 

273 

274 with patch.object(node_manager, "get_k8s_nodes", new=AsyncMock(return_value=[mig_node])): 

275 with patch.object(node_manager, "get_k8s_pods", new=AsyncMock(return_value=[])): 

276 response = await client.get("/nodes") 

277 

278 assert response.status_code == HTTPStatus.OK 

279 response_text = await response.get_data(as_text=True) 

280 assert "MIG" in response_text 

281 assert "1g.10gb" in response_text 

282 assert "2g.20gb" in response_text 

283 

284 

285@pytest.mark.asyncio 

286async def test_gpu_row_excludes_mig_pods() -> None: 

287 """Resources table GPU row does not count MIG pods — they go in their own MIG rows.""" 

288 client = _get_client() 

289 

290 mig_node: Dict[str, Any] = dict(_MOCK_NODE) 

291 mig_node.update({ 

292 "mig_enabled": True, 

293 "gpu_model": "NVIDIA-A100-SXM4-80GB", 

294 "capacity_resources": dict(mig_node["capacity_resources"], gpu=1), 

295 "allocatable_resources": dict(mig_node["allocatable_resources"], gpu=1), 

296 "mig_resources": { 

297 "1g.10gb": {"capacity": 7, "allocatable": 7} 

298 } 

299 }) 

300 

301 mig_pod = { 

302 "namespace": "rtgen", 

303 "pod_name": "esrgan-pod", 

304 "status": "Running", 

305 "pod_ip": "10.0.0.6", 

306 "container_name": "realesrgan", 

307 "url": "http://10.0.0.6:8080", 

308 "node": "testnode", 

309 "cpu": 1, 

310 "memory": 2 * 1024 * 1024 * 1024, 

311 "gpu": 1, 

312 "mig_profile": "1g.10gb", 

313 } 

314 full_gpu_pod = { 

315 "namespace": "rtgen", 

316 "pod_name": "flux-pod", 

317 "status": "Running", 

318 "pod_ip": "10.0.0.5", 

319 "container_name": "flux", 

320 "url": "http://10.0.0.5:8080", 

321 "node": "testnode", 

322 "cpu": 2, 

323 "memory": 4 * 1024 * 1024 * 1024, 

324 "gpu": 1, 

325 "mig_profile": None, 

326 } 

327 with patch.object(node_manager, "get_k8s_nodes", new=AsyncMock(return_value=[mig_node])): 

328 with patch.object(node_manager, "get_k8s_pods", new=AsyncMock(return_value=[mig_pod, full_gpu_pod])): 

329 response = await client.get("/node/testnode") 

330 assert response.status_code == HTTPStatus.OK 

331 response_text = await response.get_data(as_text=True) 

332 # The GPU resource row should show "1.0" allocated (only the full-GPU pod), 

333 # not "2.0" (which would include the MIG pod). 

334 # Check the MIG row shows 1 allocated for 1g.10gb 

335 assert "1g.10gb" in response_text 

336 

337 

338@pytest.mark.asyncio 

339async def test_mixed_full_gpu_and_mig() -> None: 

340 """Nodes table shows both the full GPU count and MIG profiles (7 full + 1 MIG-partitioned).""" 

341 client = _get_client() 

342 mig_node: Dict[str, Any] = dict(_MOCK_NODE) 

343 mig_node.update({ 

344 "mig_enabled": True, 

345 "gpu_model": "NVIDIA-A100-SXM4-80GB", 

346 "capacity_resources": dict(mig_node["capacity_resources"], gpu=7), 

347 "allocatable_resources": dict(mig_node["allocatable_resources"], gpu=7), 

348 "mig_resources": { 

349 "1g.10gb": {"capacity": 3, "allocatable": 3}, 

350 "2g.20gb": {"capacity": 2, "allocatable": 2}, 

351 } 

352 }) 

353 

354 with patch.object(node_manager, "get_k8s_nodes", new=AsyncMock(return_value=[mig_node])): 

355 with patch.object(node_manager, "get_k8s_pods", new=AsyncMock(return_value=[])): 

356 response = await client.get("/nodes") 

357 

358 assert response.status_code == HTTPStatus.OK 

359 response_text = await response.get_data(as_text=True) 

360 assert "7" in response_text # Full GPU count visible 

361 # MIG and profiles visible 

362 assert "MIG" in response_text 

363 assert "1g.10gb" in response_text 

364 assert "2g.20gb" in response_text 

365 

366 

367@pytest.mark.asyncio 

368async def test_pure_mig_node_gpu_na() -> None: 

369 """Resources table handles gpu=N/A without error on pure-MIG nodes.""" 

370 client = _get_client() 

371 

372 mig_node: Dict[str, Any] = dict(_MOCK_NODE) 

373 mig_node.update({ 

374 "mig_enabled": True, 

375 "gpu_model": "NVIDIA-A100-SXM4-80GB", 

376 "capacity_resources": dict(mig_node["capacity_resources"], gpu="N/A"), 

377 "allocatable_resources": dict(mig_node["allocatable_resources"], gpu="N/A"), 

378 "mig_resources": { 

379 "1g.10gb": {"capacity": 7, "allocatable": 7} 

380 } 

381 }) 

382 

383 with patch.object(node_manager, "get_k8s_nodes", new=AsyncMock(return_value=[mig_node])): 

384 with patch.object(node_manager, "get_k8s_pods", new=AsyncMock(return_value=[])): 

385 response = await client.get("/node/testnode") 

386 

387 assert response.status_code == HTTPStatus.OK 

388 response_text = await response.get_data(as_text=True) 

389 assert "1g.10gb" in response_text 

390 assert "MIG" in response_text