Coverage for tests/test_lssthtc.py: 20%

538 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-09 03:48 -0700

1# This file is part of ctrl_bps_htcondor. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

16# it under the terms of the GNU General Public License as published by 

17# the Free Software Foundation, either version 3 of the License, or 

18# (at your option) any later version. 

19# 

20# This program is distributed in the hope that it will be useful, 

21# but WITHOUT ANY WARRANTY; without even the implied warranty of 

22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23# GNU General Public License for more details. 

24# 

25# You should have received a copy of the GNU General Public License 

26# along with this program. If not, see <https://www.gnu.org/licenses/>. 

27"""Unit tests for classes and functions in lssthtc.py.""" 

28 

29import io 

30import logging 

31import os 

32import pathlib 

33import stat 

34import sys 

35import tempfile 

36import unittest 

37from shutil import copy2, copytree, ignore_patterns, rmtree, which 

38 

39import htcondor 

40 

41from lsst.ctrl.bps.htcondor import lssthtc 

42from lsst.utils.tests import temporaryDirectory 

43 

44logger = logging.getLogger("lsst.ctrl.bps.htcondor") 

45TESTDIR = os.path.abspath(os.path.dirname(__file__)) 

46 

47 

48class TestLsstHtc(unittest.TestCase): 

49 """Test basic usage.""" 

50 

51 def testHtcEscapeInt(self): 

52 self.assertEqual(lssthtc.htc_escape(100), 100) 

53 

54 def testHtcEscapeDouble(self): 

55 self.assertEqual(lssthtc.htc_escape('"double"'), '""double""') 

56 

57 def testHtcEscapeSingle(self): 

58 self.assertEqual(lssthtc.htc_escape("'single'"), "''single''") 

59 

60 def testHtcEscapeNoSideEffect(self): 

61 val = "'val'" 

62 self.assertEqual(lssthtc.htc_escape(val), "''val''") 

63 self.assertEqual(val, "'val'") 

64 

65 def testHtcEscapeQuot(self): 

66 self.assertEqual(lssthtc.htc_escape("&quot;val&quot;"), '"val"') 

67 

68 def testHtcVersion(self): 

69 ver = lssthtc.htc_version() 

70 self.assertRegex(ver, r"^\d+\.\d+\.\d+$") 

71 

72 

73class TweakJobInfoTestCase(unittest.TestCase): 

74 """Test the function responsible for massaging job information.""" 

75 

76 def setUp(self): 

77 self.log_file = tempfile.NamedTemporaryFile(prefix="test_", suffix=".log") 

78 self.log_name = pathlib.Path(self.log_file.name) 

79 self.job = { 

80 "Cluster": 1, 

81 "Proc": 0, 

82 "Iwd": str(self.log_name.parent), 

83 "Owner": self.log_name.owner(), 

84 "MyType": None, 

85 "TerminatedNormally": True, 

86 } 

87 

88 def tearDown(self): 

89 self.log_file.close() 

90 

91 def testDirectAssignments(self): 

92 lssthtc._tweak_log_info(self.log_name, self.job) 

93 self.assertEqual(self.job["ClusterId"], self.job["Cluster"]) 

94 self.assertEqual(self.job["ProcId"], self.job["Proc"]) 

95 self.assertEqual(self.job["Iwd"], str(self.log_name.parent)) 

96 self.assertEqual(self.job["Owner"], self.log_name.owner()) 

97 

98 def testJobStatusAssignmentJobAbortedEvent(self): 

99 job = self.job | {"MyType": "JobAbortedEvent"} 

100 lssthtc._tweak_log_info(self.log_name, job) 

101 self.assertTrue("JobStatus" in job) 

102 self.assertEqual(job["JobStatus"], htcondor.JobStatus.REMOVED) 

103 

104 def testJobStatusAssignmentExecuteEvent(self): 

105 job = self.job | {"MyType": "ExecuteEvent"} 

106 lssthtc._tweak_log_info(self.log_name, job) 

107 self.assertTrue("JobStatus" in job) 

108 self.assertEqual(job["JobStatus"], htcondor.JobStatus.RUNNING) 

109 

110 def testJobStatusAssignmentSubmitEvent(self): 

111 job = self.job | {"MyType": "SubmitEvent"} 

112 lssthtc._tweak_log_info(self.log_name, job) 

113 self.assertTrue("JobStatus" in job) 

114 self.assertEqual(job["JobStatus"], htcondor.JobStatus.IDLE) 

115 

116 def testJobStatusAssignmentJobHeldEvent(self): 

117 job = self.job | {"MyType": "JobHeldEvent"} 

118 lssthtc._tweak_log_info(self.log_name, job) 

119 self.assertTrue("JobStatus" in job) 

120 self.assertEqual(job["JobStatus"], htcondor.JobStatus.HELD) 

121 

122 def testJobStatusAssignmentJobTerminatedEvent(self): 

123 job = self.job | {"MyType": "JobTerminatedEvent"} 

124 lssthtc._tweak_log_info(self.log_name, job) 

125 self.assertTrue("JobStatus" in job) 

126 self.assertEqual(job["JobStatus"], htcondor.JobStatus.COMPLETED) 

127 

128 def testJobStatusAssignmentPostScriptTerminatedEvent(self): 

129 job = self.job | {"MyType": "PostScriptTerminatedEvent"} 

130 lssthtc._tweak_log_info(self.log_name, job) 

131 self.assertTrue("JobStatus" in job) 

132 self.assertEqual(job["JobStatus"], htcondor.JobStatus.COMPLETED) 

133 

134 def testAddingExitStatusSuccess(self): 

135 job = self.job | { 

136 "MyType": "JobTerminatedEvent", 

137 "ToE": {"ExitBySignal": False, "ExitCode": 1}, 

138 } 

139 lssthtc._tweak_log_info(self.log_name, job) 

140 self.assertIn("ExitBySignal", job) 

141 self.assertIs(job["ExitBySignal"], False) 

142 self.assertIn("ExitCode", job) 

143 self.assertEqual(job["ExitCode"], 1) 

144 

145 def testAddingExitStatusFailure(self): 

146 job = self.job | { 

147 "MyType": "JobHeldEvent", 

148 } 

149 with self.assertLogs(logger=logger, level="ERROR") as cm: 

150 lssthtc._tweak_log_info(self.log_name, job) 

151 self.assertIn("Could not determine exit status", cm.output[0]) 

152 

153 def testLoggingUnknownLogEvent(self): 

154 job = self.job | {"MyType": "Foo"} 

155 with self.assertLogs(logger=logger, level="DEBUG") as cm: 

156 lssthtc._tweak_log_info(self.log_name, job) 

157 self.assertIn("Unknown log event", cm.output[1]) 

158 

159 def testMissingKey(self): 

160 job = self.job 

161 del job["Cluster"] 

162 with self.assertRaises(KeyError) as cm: 

163 lssthtc._tweak_log_info(self.log_name, job) 

164 self.assertEqual(str(cm.exception), "'Cluster'") 

165 

166 

167class HtcCheckDagmanOutputTestCase(unittest.TestCase): 

168 """Test htc_check_dagman_output function.""" 

169 

170 def test_missing_output_file(self): 

171 with temporaryDirectory() as tmp_dir: 

172 with self.assertRaises(FileNotFoundError): 

173 _ = lssthtc.htc_check_dagman_output(tmp_dir) 

174 

175 def test_permissions_output_file(self): 

176 with temporaryDirectory() as tmp_dir: 

177 copy2(f"{TESTDIR}/data/test_tmpdir_abort.dag.dagman.out", tmp_dir) 

178 os.chmod(f"{tmp_dir}/test_tmpdir_abort.dag.dagman.out", 0o200) 

179 print(os.stat(f"{tmp_dir}/test_tmpdir_abort.dag.dagman.out")) 

180 results = lssthtc.htc_check_dagman_output(tmp_dir) 

181 os.chmod(f"{tmp_dir}/test_tmpdir_abort.dag.dagman.out", 0o600) 

182 self.assertIn("Could not read dagman output file", results) 

183 

184 def test_submit_failure(self): 

185 with temporaryDirectory() as tmp_dir: 

186 copy2(f"{TESTDIR}/data/bad_submit.dag.dagman.out", tmp_dir) 

187 results = lssthtc.htc_check_dagman_output(tmp_dir) 

188 self.assertIn("Warn: Job submission issues (last: ", results) 

189 

190 def test_tmpdir_abort(self): 

191 with temporaryDirectory() as tmp_dir: 

192 copy2(f"{TESTDIR}/data/test_tmpdir_abort.dag.dagman.out", tmp_dir) 

193 results = lssthtc.htc_check_dagman_output(tmp_dir) 

194 self.assertIn("Cannot submit from /tmp", results) 

195 

196 def test_no_messages(self): 

197 with temporaryDirectory() as tmp_dir: 

198 copy2(f"{TESTDIR}/data/test_no_messages.dag.dagman.out", tmp_dir) 

199 results = lssthtc.htc_check_dagman_output(tmp_dir) 

200 self.assertEqual("", results) 

201 

202 

203class SummarizeDagTestCase(unittest.TestCase): 

204 """Test summarize_dag function.""" 

205 

206 def test_no_dag_file(self): 

207 with temporaryDirectory() as tmp_dir: 

208 summary, job_name_to_pipetask, job_name_to_type = lssthtc.summarize_dag(tmp_dir) 

209 self.assertFalse(len(job_name_to_pipetask)) 

210 self.assertFalse(len(job_name_to_type)) 

211 self.assertFalse(summary) 

212 

213 def test_success(self): 

214 with temporaryDirectory() as tmp_dir: 

215 copy2(f"{TESTDIR}/data/good.dag", tmp_dir) 

216 summary, job_name_to_label, job_name_to_type = lssthtc.summarize_dag(tmp_dir) 

217 self.assertEqual(summary, "pipetaskInit:1;label1:1;label2:1;label3:1;finalJob:1") 

218 self.assertEqual( 

219 job_name_to_label, 

220 { 

221 "pipetaskInit": "pipetaskInit", 

222 "0682f8f9-12f0-40a5-971e-8b30c7231e5c_label1_val1_val2": "label1", 

223 "d0305e2d-f164-4a85-bd24-06afe6c84ed9_label2_val1_val2": "label2", 

224 "2806ecc9-1bba-4362-8fff-ab4e6abb9f83_label3_val1_val2": "label3", 

225 "finalJob": "finalJob", 

226 }, 

227 ) 

228 self.assertEqual( 

229 job_name_to_type, 

230 { 

231 "pipetaskInit": lssthtc.WmsNodeType.PAYLOAD, 

232 "0682f8f9-12f0-40a5-971e-8b30c7231e5c_label1_val1_val2": lssthtc.WmsNodeType.PAYLOAD, 

233 "d0305e2d-f164-4a85-bd24-06afe6c84ed9_label2_val1_val2": lssthtc.WmsNodeType.PAYLOAD, 

234 "2806ecc9-1bba-4362-8fff-ab4e6abb9f83_label3_val1_val2": lssthtc.WmsNodeType.PAYLOAD, 

235 "finalJob": lssthtc.WmsNodeType.FINAL, 

236 }, 

237 ) 

238 

239 def test_service(self): 

240 with temporaryDirectory() as tmp_dir: 

241 copy2(f"{TESTDIR}/data/tiny_problems/tiny_problems.dag", tmp_dir) 

242 summary, job_name_to_label, job_name_to_type = lssthtc.summarize_dag(tmp_dir) 

243 self.assertEqual(summary, "pipetaskInit:1;label1:2;label2:2;finalJob:1") 

244 self.assertEqual( 

245 job_name_to_label, 

246 { 

247 "pipetaskInit": "pipetaskInit", 

248 "057c8caf-66f6-4612-abf7-cdea5b666b1b_label1_val1a_val2b": "label1", 

249 "4a7f478b-2e9b-435c-a730-afac3f621658_label1_val1a_val2a": "label1", 

250 "40040b97-606d-4997-98d3-e0493055fe7e_label2_val1a_val2b": "label2", 

251 "696ee50d-e711-40d6-9caf-ee29ae4a656d_label2_val1a_val2a": "label2", 

252 "finalJob": "finalJob", 

253 "provisioningJob": "provisioningJob", 

254 }, 

255 ) 

256 self.assertEqual( 

257 job_name_to_type, 

258 { 

259 "pipetaskInit": lssthtc.WmsNodeType.PAYLOAD, 

260 "057c8caf-66f6-4612-abf7-cdea5b666b1b_label1_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

261 "4a7f478b-2e9b-435c-a730-afac3f621658_label1_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

262 "40040b97-606d-4997-98d3-e0493055fe7e_label2_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

263 "696ee50d-e711-40d6-9caf-ee29ae4a656d_label2_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

264 "finalJob": lssthtc.WmsNodeType.FINAL, 

265 "provisioningJob": lssthtc.WmsNodeType.SERVICE, 

266 }, 

267 ) 

268 

269 def test_noop(self): 

270 with temporaryDirectory() as tmp_dir: 

271 copy2(f"{TESTDIR}/data/noop_running_1/noop_running_1.dag", tmp_dir) 

272 summary, job_name_to_label, job_name_to_type = lssthtc.summarize_dag(tmp_dir) 

273 self.assertEqual( 

274 set(summary.split(";")), 

275 {"pipetaskInit:1", "label1:6", "label2:6", "label3:6", "label4:6", "label5:6", "finalJob:1"}, 

276 ) 

277 self.assertEqual( 

278 job_name_to_label, 

279 { 

280 "label1_val1a_val2a": "label1", 

281 "label1_val1a_val2b": "label1", 

282 "label1_val1b_val2a": "label1", 

283 "label1_val1b_val2b": "label1", 

284 "label1_val1c_val2a": "label1", 

285 "label1_val1c_val2b": "label1", 

286 "label2_val1a_val2a": "label2", 

287 "label2_val1a_val2b": "label2", 

288 "label2_val1b_val2a": "label2", 

289 "label2_val1b_val2b": "label2", 

290 "label2_val1c_val2a": "label2", 

291 "label2_val1c_val2b": "label2", 

292 "label3_val1a_val2a": "label3", 

293 "label3_val1a_val2b": "label3", 

294 "label3_val1b_val2a": "label3", 

295 "label3_val1b_val2b": "label3", 

296 "label3_val1c_val2a": "label3", 

297 "label3_val1c_val2b": "label3", 

298 "label4_val1a_val2a": "label4", 

299 "label4_val1a_val2b": "label4", 

300 "label4_val1b_val2a": "label4", 

301 "label4_val1b_val2b": "label4", 

302 "label4_val1c_val2a": "label4", 

303 "label4_val1c_val2b": "label4", 

304 "label5_val1a_val2a": "label5", 

305 "label5_val1a_val2b": "label5", 

306 "label5_val1b_val2a": "label5", 

307 "label5_val1b_val2b": "label5", 

308 "label5_val1c_val2a": "label5", 

309 "label5_val1c_val2b": "label5", 

310 "finalJob": "finalJob", 

311 "pipetaskInit": "pipetaskInit", 

312 "wms_noop_order1_val1a": "order1", 

313 "wms_noop_order1_val1b": "order1", 

314 }, 

315 ) 

316 self.assertEqual( 

317 job_name_to_type, 

318 { 

319 "label1_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

320 "label1_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

321 "label1_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

322 "label1_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

323 "label1_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

324 "label1_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

325 "label2_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

326 "label2_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

327 "label2_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

328 "label2_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

329 "label2_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

330 "label2_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

331 "label3_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

332 "label3_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

333 "label3_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

334 "label3_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

335 "label3_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

336 "label3_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

337 "label4_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

338 "label4_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

339 "label4_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

340 "label4_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

341 "label4_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

342 "label4_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

343 "label5_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

344 "label5_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

345 "label5_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

346 "label5_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

347 "label5_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

348 "label5_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

349 "finalJob": lssthtc.WmsNodeType.FINAL, 

350 "pipetaskInit": lssthtc.WmsNodeType.PAYLOAD, 

351 "wms_noop_order1_val1a": lssthtc.WmsNodeType.NOOP, 

352 "wms_noop_order1_val1b": lssthtc.WmsNodeType.NOOP, 

353 }, 

354 ) 

355 

356 def test_subdags(self): 

357 with temporaryDirectory() as tmp_dir: 

358 submit_dir = os.path.join(tmp_dir, "group_running_1") 

359 copytree(f"{TESTDIR}/data/group_running_1", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

360 summary, job_name_to_label, job_name_to_type = lssthtc.summarize_dag(submit_dir) 

361 self.assertEqual( 

362 set(summary.split(";")), 

363 {"pipetaskInit:1", "label1:6", "label2:6", "label3:6", "label4:6", "label5:6", "finalJob:1"}, 

364 ) 

365 

366 self.assertEqual( 

367 job_name_to_label, 

368 { 

369 "pipetaskInit": "pipetaskInit", 

370 "label1_val1b_val2a": "label1", 

371 "label1_val1c_val2a": "label1", 

372 "label1_val1a_val2b": "label1", 

373 "label1_val1b_val2b": "label1", 

374 "label1_val1c_val2b": "label1", 

375 "label1_val1a_val2a": "label1", 

376 "label2_val1a_val2b": "label2", 

377 "label2_val1a_val2a": "label2", 

378 "label2_val1b_val2a": "label2", 

379 "label2_val1b_val2b": "label2", 

380 "label2_val1c_val2a": "label2", 

381 "label2_val1c_val2b": "label2", 

382 "label3_val1b_val2a": "label3", 

383 "label3_val1c_val2a": "label3", 

384 "label3_val1a_val2b": "label3", 

385 "label3_val1b_val2b": "label3", 

386 "label3_val1c_val2b": "label3", 

387 "label3_val1a_val2a": "label3", 

388 "label4_val1a_val2b": "label4", 

389 "label4_val1a_val2a": "label4", 

390 "label4_val1b_val2a": "label4", 

391 "label4_val1b_val2b": "label4", 

392 "label4_val1c_val2a": "label4", 

393 "label4_val1c_val2b": "label4", 

394 "label5_val1a_val2b": "label5", 

395 "label5_val1a_val2a": "label5", 

396 "label5_val1b_val2a": "label5", 

397 "label5_val1b_val2b": "label5", 

398 "label5_val1c_val2a": "label5", 

399 "label5_val1c_val2b": "label5", 

400 "finalJob": "finalJob", 

401 "provisioningJob": "provisioningJob", 

402 "wms_group_order1_val1a": "order1", 

403 "wms_group_order1_val1b": "order1", 

404 "wms_group_order1_val1c": "order1", 

405 "wms_check_status_wms_group_order1_val1a": "order1", 

406 "wms_check_status_wms_group_order1_val1b": "order1", 

407 "wms_check_status_wms_group_order1_val1c": "order1", 

408 }, 

409 ) 

410 

411 self.assertEqual( 

412 job_name_to_type, 

413 { 

414 "pipetaskInit": lssthtc.WmsNodeType.PAYLOAD, 

415 "label1_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

416 "label1_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

417 "label1_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

418 "label1_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

419 "label1_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

420 "label1_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

421 "label2_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

422 "label2_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

423 "label2_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

424 "label2_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

425 "label2_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

426 "label2_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

427 "label3_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

428 "label3_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

429 "label3_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

430 "label3_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

431 "label3_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

432 "label3_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

433 "label4_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

434 "label4_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

435 "label4_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

436 "label4_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

437 "label4_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

438 "label4_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

439 "label5_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

440 "label5_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

441 "label5_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

442 "label5_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

443 "label5_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

444 "label5_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

445 "finalJob": lssthtc.WmsNodeType.FINAL, 

446 "provisioningJob": lssthtc.WmsNodeType.SERVICE, 

447 "wms_group_order1_val1a": lssthtc.WmsNodeType.SUBDAG, 

448 "wms_group_order1_val1b": lssthtc.WmsNodeType.SUBDAG, 

449 "wms_group_order1_val1c": lssthtc.WmsNodeType.SUBDAG, 

450 "wms_check_status_wms_group_order1_val1a": lssthtc.WmsNodeType.SUBDAG_CHECK, 

451 "wms_check_status_wms_group_order1_val1b": lssthtc.WmsNodeType.SUBDAG_CHECK, 

452 "wms_check_status_wms_group_order1_val1c": lssthtc.WmsNodeType.SUBDAG_CHECK, 

453 }, 

454 ) 

455 

456 

457class ReadDagNodesLogTestCase(unittest.TestCase): 

458 """Test read_dag_nodes_log function.""" 

459 

460 def setUp(self): 

461 self.tmpdir = tempfile.mkdtemp() 

462 

463 def tearDown(self): 

464 rmtree(self.tmpdir, ignore_errors=True) 

465 

466 def testFileMissing(self): 

467 with self.assertRaisesRegex(FileNotFoundError, "DAGMan node log not found in"): 

468 _ = lssthtc.read_dag_nodes_log(self.tmpdir) 

469 

470 def testBadFile(self): 

471 with temporaryDirectory() as tmp_dir: 

472 submit_dir = os.path.join(tmp_dir, "tiny_problems") 

473 copytree(f"{TESTDIR}/data/tiny_problems", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

474 results = lssthtc.read_dag_nodes_log(submit_dir) 

475 self.assertEqual(results["9231.0"]["JobStatus"], lssthtc.JobStatus.COMPLETED) 

476 self.assertEqual(results["9231.0"]["ExitCode"], 1) 

477 self.assertEqual(len(results), 6) 

478 

479 def testRegular(self): 

480 with temporaryDirectory() as tmp_dir: 

481 submit_dir = os.path.join(tmp_dir, "tiny_problems") 

482 copytree(f"{TESTDIR}/data/tiny_problems", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

483 results = lssthtc.read_dag_nodes_log(submit_dir) 

484 self.assertEqual(results["9231.0"]["JobStatus"], lssthtc.JobStatus.COMPLETED) 

485 self.assertEqual(results["9231.0"]["ExitCode"], 1) 

486 self.assertEqual(len(results), 6) 

487 

488 def testSubdags(self): 

489 """Making sure it gets data from subdag dirs and doesn't 

490 fail if some subdags haven't started running yet. 

491 """ 

492 with temporaryDirectory() as tmp_dir: 

493 submit_dir = os.path.join(tmp_dir, "group_running_1") 

494 copytree(f"{TESTDIR}/data/group_running_1", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

495 results = lssthtc.read_dag_nodes_log(submit_dir) 

496 # main dag 

497 self.assertEqual(results["10094.0"]["JobStatus"], lssthtc.JobStatus.RUNNING) 

498 # subdag 

499 self.assertEqual(results["10112.0"]["JobStatus"], lssthtc.JobStatus.COMPLETED) 

500 self.assertEqual(results["10116.0"]["JobStatus"], lssthtc.JobStatus.RUNNING) 

501 

502 

503class ReadNodeStatusTestCase(unittest.TestCase): 

504 """Test read_node_status function.""" 

505 

506 def setUp(self): 

507 self.tmpdir = tempfile.mkdtemp() 

508 

509 def tearDown(self): 

510 rmtree(self.tmpdir, ignore_errors=True) 

511 

512 def testServiceJobNotSubmitted(self): 

513 # tiny_prov_no_submit files have successful workflow 

514 # but provisioningJob could not submit. 

515 copy2(f"{TESTDIR}/data/tiny_prov_no_submit/tiny_prov_no_submit.dag.nodes.log", self.tmpdir) 

516 copy2(f"{TESTDIR}/data/tiny_prov_no_submit/tiny_prov_no_submit.dag.dagman.log", self.tmpdir) 

517 copy2(f"{TESTDIR}/data/tiny_prov_no_submit/tiny_prov_no_submit.node_status", self.tmpdir) 

518 copy2(f"{TESTDIR}/data/tiny_prov_no_submit/tiny_prov_no_submit.dag", self.tmpdir) 

519 

520 jobs = lssthtc.read_node_status(self.tmpdir) 

521 found = [ 

522 id_ 

523 for id_ in jobs 

524 if jobs[id_].get("wms_node_type", lssthtc.WmsNodeType.UNKNOWN) == lssthtc.WmsNodeType.SERVICE 

525 ] 

526 self.assertEqual(len(found), 1) 

527 self.assertEqual(jobs[found[0]]["DAGNodeName"], "provisioningJob") 

528 self.assertEqual(jobs[found[0]]["NodeStatus"], lssthtc.NodeStatus.NOT_READY) 

529 

530 def testMissingStatusFile(self): 

531 copy2(f"{TESTDIR}/data/tiny_problems/tiny_problems.dag.nodes.log", self.tmpdir) 

532 copy2(f"{TESTDIR}/data/tiny_problems/tiny_problems.dag.dagman.log", self.tmpdir) 

533 copy2(f"{TESTDIR}/data/tiny_problems/tiny_problems.dag", self.tmpdir) 

534 

535 jobs = lssthtc.read_node_status(self.tmpdir) 

536 self.assertEqual(len(jobs), 7) 

537 self.assertEqual(jobs["9230.0"]["DAGNodeName"], "pipetaskInit") 

538 self.assertEqual(jobs["9230.0"]["wms_node_type"], lssthtc.WmsNodeType.PAYLOAD) 

539 self.assertEqual(jobs["9230.0"]["JobStatus"], lssthtc.JobStatus.COMPLETED) 

540 found = [ 

541 id_ 

542 for id_ in jobs 

543 if jobs[id_].get("wms_node_type", lssthtc.WmsNodeType.UNKNOWN) == lssthtc.WmsNodeType.SERVICE 

544 ] 

545 self.assertEqual(len(found), 1) 

546 self.assertEqual(jobs[found[0]]["DAGNodeName"], "provisioningJob") 

547 

548 def testSubdagsRunning(self): 

549 with temporaryDirectory() as tmp_dir: 

550 test_tmp_dir = pathlib.Path(tmp_dir) 

551 submit_dir = test_tmp_dir / "submit" 

552 copytree(f"{TESTDIR}/data/group_running_1", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

553 jobs = lssthtc.read_node_status(submit_dir) 

554 self.assertEqual(len(jobs), 39) # includes non-payload jobs 

555 # not guaranteed ids are same, so use names instead 

556 job_name_to_id = {} 

557 for id_, info in jobs.items(): 

558 job_name_to_id[info.get("DAGNodeName", id_)] = id_ 

559 job_type_to_names = {} 

560 for id_, info in jobs.items(): 

561 job_type_to_names.setdefault( 

562 info.get("wms_node_type", lssthtc.WmsNodeType.UNKNOWN), set() 

563 ).add(info.get("DAGNodeName", id_)) 

564 

565 # check counts 

566 self.assertNotIn(lssthtc.WmsNodeType.NOOP, job_type_to_names) 

567 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.PAYLOAD]), 31) 

568 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.FINAL]), 1) 

569 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.SERVICE]), 1) 

570 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.SUBDAG]), 3) 

571 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.SUBDAG_CHECK]), 3) 

572 

573 # spot check some statuses 

574 self.assertEqual( 

575 jobs[job_name_to_id["label3_val1a_val2b"]]["NodeStatus"], lssthtc.NodeStatus.DONE 

576 ) 

577 self.assertEqual( 

578 jobs[job_name_to_id["wms_group_order1_val1a"]]["NodeStatus"], lssthtc.NodeStatus.SUBMITTED 

579 ) 

580 self.assertEqual( 

581 jobs[job_name_to_id["label5_val1a_val2a"]]["NodeStatus"], lssthtc.NodeStatus.NOT_READY 

582 ) 

583 self.assertEqual( 

584 jobs[job_name_to_id["label2_val1a_val2a"]]["NodeStatus"], lssthtc.NodeStatus.DONE 

585 ) 

586 

587 def testSubdagsFailed(self): 

588 with temporaryDirectory() as tmp_dir: 

589 test_tmp_dir = pathlib.Path(tmp_dir) 

590 submit_dir = test_tmp_dir / "submit" 

591 copytree(f"{TESTDIR}/data/group_failed_1", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

592 jobs = lssthtc.read_node_status(submit_dir) 

593 self.assertEqual(len(jobs), 39) 

594 # not guaranteed ids are same, so use names instead 

595 job_name_to_id = {} 

596 for id_, info in jobs.items(): 

597 job_name_to_id[info.get("DAGNodeName", id_)] = id_ 

598 job_type_to_names = {} 

599 for id_, info in jobs.items(): 

600 job_type_to_names.setdefault( 

601 info.get("wms_node_type", lssthtc.WmsNodeType.UNKNOWN), set() 

602 ).add(info.get("DAGNodeName", id_)) 

603 

604 # check counts 

605 self.assertNotIn(lssthtc.WmsNodeType.NOOP, job_type_to_names) 

606 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.PAYLOAD]), 31) 

607 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.FINAL]), 1) 

608 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.SERVICE]), 1) 

609 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.SUBDAG]), 3) 

610 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.SUBDAG_CHECK]), 3) 

611 

612 # spot check some statuses 

613 self.assertEqual( 

614 jobs[job_name_to_id["label3_val1a_val2b"]]["NodeStatus"], lssthtc.NodeStatus.DONE 

615 ) 

616 self.assertEqual( 

617 jobs[job_name_to_id["wms_group_order1_val1a"]]["NodeStatus"], lssthtc.NodeStatus.DONE 

618 ) 

619 self.assertEqual( 

620 jobs[job_name_to_id["label5_val1a_val2a"]]["NodeStatus"], lssthtc.NodeStatus.DONE 

621 ) 

622 

623 self.assertEqual( 

624 jobs[job_name_to_id["label5_val1b_val2a"]]["NodeStatus"], lssthtc.NodeStatus.FUTILE 

625 ) 

626 self.assertEqual( 

627 jobs[job_name_to_id["wms_group_order1_val1b"]]["NodeStatus"], lssthtc.NodeStatus.DONE 

628 ) 

629 self.assertEqual( 

630 jobs[job_name_to_id["wms_check_status_wms_group_order1_val1b"]]["NodeStatus"], 

631 lssthtc.NodeStatus.ERROR, 

632 ) 

633 

634 

635class HTCJobTestCase(unittest.TestCase): 

636 """Test HTCJob methods.""" 

637 

638 def testWriteDagCommandsPayload(self): 

639 job = lssthtc.HTCJob( 

640 "job1", 

641 "label1", 

642 {"executable": "/bin/sleep", "arguments": "60", "log": "job1.log"}, 

643 {"dir": "jobs/label1"}, 

644 ) 

645 job.subfile = "job1.sub" 

646 

647 mockfh = io.StringIO() 

648 job.write_dag_commands(mockfh, "../..") 

649 self.assertIn('JOB job1 "job1.sub" DIR "../../jobs/label1"', mockfh.getvalue()) 

650 

651 def testWriteDagCommandsNotJob(self): 

652 # Testing giving command_name, no dag_rel_path and no dir 

653 job = lssthtc.HTCJob( 

654 "finalJob", 

655 "finalJob", 

656 {"executable": "/bin/sleep", "arguments": "60", "log": "job1.log"}, 

657 ) 

658 job.subfile = "jobs/finalJob/finalJob.sub" 

659 mockfh = io.StringIO() 

660 job.write_dag_commands(mockfh, "", "FINAL") 

661 self.assertIn('FINAL finalJob "jobs/finalJob/finalJob.sub"', mockfh.getvalue()) 

662 

663 def testWriteDagCommandsNoop(self): 

664 job = lssthtc.HTCJob("wms_noop_job1", "label1", {}, {"noop": True}) 

665 job.subfile = "notthere.sub" 

666 mockfh = io.StringIO() 

667 job.write_dag_commands(mockfh, "") 

668 self.assertIn("NOOP", mockfh.getvalue()) 

669 

670 def testWriteSubmitFile(self): 

671 job = lssthtc.HTCJob( 

672 "job1", 

673 "label1", 

674 {"executable": "/bin/sleep", "arguments": "60", "log": "job1.log"}, 

675 ) 

676 with temporaryDirectory() as tmp_dir: 

677 filename = pathlib.Path(tmp_dir) / "label1/job1.sub" 

678 job.write_submit_file(filename.parent) 

679 self.assertTrue(filename.exists()) 

680 # Try to make Submit object from file to find any syntax issues 

681 _ = lssthtc.htc_create_submit_from_file(filename) 

682 

683 def testWriteSubmitFileExists(self): 

684 job = lssthtc.HTCJob( 

685 "job1", 

686 "label1", 

687 {"executable": "/bin/sleep", "arguments": "60", "log": "job1.log"}, 

688 ) 

689 with temporaryDirectory() as tmp_dir: 

690 filename = pathlib.Path(tmp_dir) / "job1.sub" 

691 job.subfile = filename 

692 with open(filename, "w"): 

693 pass # make empty file 

694 job.write_submit_file(filename.parent) 

695 # make sure didn't overwrite file 

696 self.assertEqual(filename.stat().st_size, 0, "Incorrectly overwrote existing file") 

697 

698 

699class HtcWriteJobCommands(unittest.TestCase): 

700 """Test _htc_write_job_commands function.""" 

701 

702 def testAllCommands(self): 

703 dag_cmds = { 

704 "pre": { 

705 "defer": {"status": 1, "time": 120}, 

706 "debug": {"filename": "debug_pre.txt", "type": "ALL"}, 

707 "executable": "exec1", 

708 "arguments": "arg1 arg2", 

709 }, 

710 "post": { 

711 "defer": {"status": 2, "time": 180}, 

712 "debug": {"filename": "debug_post.txt", "type": "ALL"}, 

713 "executable": "exec2", 

714 "arguments": "arg3 arg4", 

715 }, 

716 "vars": {"num": 8, "spaces": "a space"}, 

717 "pre_skip": "1", 

718 "retry": 3, 

719 "retry_unless_exit": 1, 

720 "abort_dag_on": {"node_exit": 100, "abort_exit": 4}, 

721 } 

722 

723 truth = """SCRIPT DEFER 1 120 DEBUG debug_pre.txt ALL PRE job1 exec1 arg1 arg2 

724SCRIPT DEFER 2 180 DEBUG debug_post.txt ALL POST job1 exec2 arg3 arg4 

725VARS job1 num="8" 

726VARS job1 spaces="a space" 

727PRE_SKIP job1 1 

728RETRY job1 3 UNLESS-EXIT 1 

729ABORT-DAG-ON job1 100 RETURN 4 

730""" 

731 mockfh = io.StringIO() 

732 lssthtc._htc_write_job_commands(mockfh, "job1", dag_cmds) 

733 self.assertEqual(mockfh.getvalue(), truth) 

734 

735 def testPartialCommands(self): 

736 # Trigger skipping the inner if clauses. 

737 dag_cmds = { 

738 "pre": { 

739 "executable": "exec1", 

740 }, 

741 "post": { 

742 "executable": "exec2", 

743 }, 

744 "vars": {"num": 8, "spaces": "a space"}, 

745 "pre_skip": "1", 

746 "retry": 3, 

747 } 

748 

749 truth = """SCRIPT PRE job1 exec1 

750SCRIPT POST job1 exec2 

751VARS job1 num="8" 

752VARS job1 spaces="a space" 

753PRE_SKIP job1 1 

754RETRY job1 3 

755""" 

756 mockfh = io.StringIO() 

757 lssthtc._htc_write_job_commands(mockfh, "job1", dag_cmds) 

758 self.assertEqual(mockfh.getvalue(), truth) 

759 

760 def testNoCommands(self): 

761 dag_cmds = {} 

762 mockfh = io.StringIO() 

763 lssthtc._htc_write_job_commands(mockfh, "job2", dag_cmds) 

764 self.assertEqual(mockfh.getvalue(), "") 

765 

766 

767class HTCBackupFilesSinglePathTestCase(unittest.TestCase): 

768 """Test htc_backup_files_single_path function.""" 

769 

770 def testSrcDestSame(self): 

771 with temporaryDirectory() as tmp_dir: 

772 with self.assertRaisesRegex( 

773 RuntimeError, "Destination directory is same as the source directory" 

774 ): 

775 lssthtc.htc_backup_files_single_path(tmp_dir, tmp_dir) 

776 

777 def testSuccess(self): 

778 with temporaryDirectory() as tmp_dir: 

779 test_tmp_dir = pathlib.Path(tmp_dir) 

780 submit_dir = test_tmp_dir / "the_src_dir" 

781 copytree(f"{TESTDIR}/data/tiny_success", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

782 backup_dir = test_tmp_dir / "the_dest_dir" 

783 backup_dir.mkdir() 

784 lssthtc.htc_backup_files_single_path(submit_dir, backup_dir) 

785 result_submit = [] 

786 for root, _, files in os.walk(submit_dir): 

787 result_submit.extend([str(os.path.join(os.path.relpath(root, submit_dir), f)) for f in files]) 

788 self.assertEqual( 

789 set(result_submit), 

790 { 

791 "./tiny_success.dag.dagman.log", 

792 "./tiny_success.dag.dagman.out", 

793 "./tiny_success.dag", 

794 }, 

795 ) 

796 result_backup = [] 

797 for root, _, files in os.walk(backup_dir): 

798 result_backup.extend([str(os.path.join(os.path.relpath(root, backup_dir), f)) for f in files]) 

799 self.assertEqual( 

800 set(result_backup), 

801 { 

802 "./tiny_success.info.json", 

803 "./tiny_success.dag.metrics", 

804 "./tiny_success.dag.nodes.log", 

805 "./tiny_success.node_status", 

806 }, 

807 ) 

808 

809 

810class HTCBackupFilesTestCase(unittest.TestCase): 

811 """Test htc_backup_files function.""" 

812 

813 def testDirectoryNotFound(self): 

814 with temporaryDirectory() as tmp_dir: 

815 test_tmp_dir = pathlib.Path(tmp_dir) 

816 submit_dir = test_tmp_dir / "submit" 

817 with self.assertRaises(FileNotFoundError): 

818 _ = lssthtc.htc_backup_files(submit_dir) 

819 

820 def testSuccess(self): 

821 with temporaryDirectory() as tmp_dir: 

822 test_tmp_dir = pathlib.Path(tmp_dir) 

823 submit_dir = test_tmp_dir / "submit" 

824 copytree(f"{TESTDIR}/data/tiny_success", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

825 result_rescue = lssthtc.htc_backup_files(submit_dir) 

826 self.assertIsNone(result_rescue) 

827 result_submit = [] 

828 for root, _, files in os.walk(submit_dir): 

829 result_submit.extend([str(os.path.join(os.path.relpath(root, submit_dir), f)) for f in files]) 

830 self.assertEqual( 

831 set(result_submit), 

832 { 

833 "./tiny_success.dag.dagman.log", 

834 "./tiny_success.dag.dagman.out", 

835 "./tiny_success.dag", 

836 "000/tiny_success.info.json", 

837 "000/tiny_success.dag.metrics", 

838 "000/tiny_success.dag.nodes.log", 

839 "000/tiny_success.node_status", 

840 }, 

841 ) 

842 

843 def testDestNotInSubmitDir(self): 

844 with temporaryDirectory() as tmp_dir: 

845 test_tmp_dir = pathlib.Path(tmp_dir) 

846 submit_dir = test_tmp_dir / "submit" 

847 copytree(f"{TESTDIR}/data/tiny_problems", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

848 with self.assertLogs("lsst.ctrl.bps.htcondor", level="WARNING") as cm: 

849 result_rescue = lssthtc.htc_backup_files(submit_dir, test_tmp_dir / "backup") 

850 self.assertIn("Invalid backup location:", cm.output[-1]) 

851 result_rescue = lssthtc.htc_backup_files(submit_dir) 

852 self.assertTrue((submit_dir / "tiny_problems.dag.rescue001").samefile(result_rescue)) 

853 result_submit = [] 

854 for root, _, files in os.walk(submit_dir): 

855 result_submit.extend([str(os.path.join(os.path.relpath(root, submit_dir), f)) for f in files]) 

856 self.assertEqual( 

857 set(result_submit), 

858 { 

859 "./tiny_problems.dag.dagman.log", 

860 "./tiny_problems.dag.dagman.out", 

861 "./tiny_problems.dag", 

862 "./tiny_problems.dag.rescue001", 

863 "001/tiny_problems.info.json", 

864 "001/tiny_problems.dag.metrics", 

865 "001/tiny_problems.dag.nodes.log", 

866 "001/tiny_problems.node_status", 

867 }, 

868 ) 

869 

870 def testDestInSubmitDir(self): 

871 with temporaryDirectory() as tmp_dir: 

872 test_tmp_dir = pathlib.Path(tmp_dir) 

873 submit_dir = test_tmp_dir / "submit" 

874 backup_dir = submit_dir / "subdir" 

875 copytree(f"{TESTDIR}/data/tiny_problems", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

876 result_rescue = lssthtc.htc_backup_files(submit_dir, backup_dir) 

877 self.assertTrue((submit_dir / "tiny_problems.dag.rescue001").samefile(result_rescue)) 

878 result_submit = [] 

879 for root, _, files in os.walk(submit_dir): 

880 result_submit.extend([str(os.path.join(os.path.relpath(root, submit_dir), f)) for f in files]) 

881 self.assertEqual( 

882 set(result_submit), 

883 { 

884 "./tiny_problems.dag.dagman.log", 

885 "./tiny_problems.dag.dagman.out", 

886 "./tiny_problems.dag", 

887 "./tiny_problems.dag.rescue001", 

888 "subdir/001/tiny_problems.info.json", 

889 "subdir/001/tiny_problems.dag.metrics", 

890 "subdir/001/tiny_problems.dag.nodes.log", 

891 "subdir/001/tiny_problems.node_status", 

892 }, 

893 ) 

894 

895 def testRelativeSubdir(self): 

896 with temporaryDirectory() as tmp_dir: 

897 test_tmp_dir = pathlib.Path(tmp_dir) 

898 submit_dir = test_tmp_dir / "submit" 

899 copytree(f"{TESTDIR}/data/tiny_problems", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

900 result_rescue = lssthtc.htc_backup_files(submit_dir, "reldir") 

901 self.assertTrue((submit_dir / "tiny_problems.dag.rescue001").samefile(result_rescue)) 

902 result_submit = [] 

903 for root, _, files in os.walk(submit_dir): 

904 result_submit.extend([str(os.path.join(os.path.relpath(root, submit_dir), f)) for f in files]) 

905 self.assertEqual( 

906 set(result_submit), 

907 { 

908 "./tiny_problems.dag.dagman.log", 

909 "./tiny_problems.dag.dagman.out", 

910 "./tiny_problems.dag", 

911 "./tiny_problems.dag.rescue001", 

912 "reldir/001/tiny_problems.info.json", 

913 "reldir/001/tiny_problems.dag.metrics", 

914 "reldir/001/tiny_problems.dag.nodes.log", 

915 "reldir/001/tiny_problems.node_status", 

916 }, 

917 ) 

918 

919 def testSubdags(self): 

920 with temporaryDirectory() as tmp_dir: 

921 test_tmp_dir = pathlib.Path(tmp_dir) 

922 submit_dir = test_tmp_dir / "submit" 

923 copytree(f"{TESTDIR}/data/group_failed_1", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

924 result_rescue = lssthtc.htc_backup_files(submit_dir) 

925 self.assertTrue(result_rescue.samefile(submit_dir / "group_failed_1.dag.rescue001")) 

926 result_submit = [] 

927 for root, _, files in os.walk(submit_dir): 

928 result_submit.extend([str(os.path.join(os.path.relpath(root, submit_dir), f)) for f in files]) 

929 self.assertEqual( 

930 set(result_submit), 

931 { 

932 "./group_failed_1.dag", 

933 "./group_failed_1.dag.dagman.log", 

934 "./group_failed_1.dag.dagman.out", 

935 "./group_failed_1.dag.rescue001", 

936 "subdags/wms_group_order1_val1a/group_order1_val1a.dag", 

937 "subdags/wms_group_order1_val1a/group_order1_val1a.dag.dagman.log", 

938 "subdags/wms_group_order1_val1a/group_order1_val1a.dag.dagman.out", 

939 "subdags/wms_group_order1_val1b/group_order1_val1b.dag", 

940 "subdags/wms_group_order1_val1b/group_order1_val1b.dag.dagman.log", 

941 "subdags/wms_group_order1_val1b/group_order1_val1b.dag.dagman.out", 

942 "subdags/wms_group_order1_val1b/group_order1_val1b.dag.rescue001", 

943 "subdags/wms_group_order1_val1c/group_order1_val1c.dag", 

944 "subdags/wms_group_order1_val1c/group_order1_val1c.dag.dagman.log", 

945 "subdags/wms_group_order1_val1c/group_order1_val1c.dag.dagman.out", 

946 "001/group_failed_1.dag.nodes.log", 

947 "001/group_failed_1.info.json", 

948 "001/group_failed_1.node_status", 

949 "001/subdags/wms_group_order1_val1a/group_order1_val1a.dag.nodes.log", 

950 "001/subdags/wms_group_order1_val1a/group_order1_val1a.node_status", 

951 "001/subdags/wms_group_order1_val1a/wms_group_order1_val1a.dag.post.out", 

952 "001/subdags/wms_group_order1_val1a/wms_group_order1_val1a.status.txt", 

953 "001/subdags/wms_group_order1_val1b/group_order1_val1b.dag.nodes.log", 

954 "001/subdags/wms_group_order1_val1b/group_order1_val1b.node_status", 

955 "001/subdags/wms_group_order1_val1b/wms_group_order1_val1b.status.txt", 

956 "001/subdags/wms_group_order1_val1b/wms_group_order1_val1b.dag.post.out", 

957 "001/subdags/wms_group_order1_val1c/group_order1_val1c.dag.nodes.log", 

958 "001/subdags/wms_group_order1_val1c/group_order1_val1c.node_status", 

959 "001/subdags/wms_group_order1_val1c/wms_group_order1_val1c.dag.post.out", 

960 "001/subdags/wms_group_order1_val1c/wms_group_order1_val1c.status.txt", 

961 }, 

962 ) 

963 

964 

965class UpdateRescueFileTestCase(unittest.TestCase): 

966 """Test _update_rescue_file function.""" 

967 

968 def testSuccess(self): 

969 self.maxDiff = None 

970 with temporaryDirectory() as tmp_dir: 

971 test_tmp_dir = pathlib.Path(tmp_dir) 

972 submit_dir = test_tmp_dir / "submit" 

973 copytree(f"{TESTDIR}/data/group_failed_1", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

974 rescue_file = submit_dir / "group_failed_1.dag.rescue001" 

975 lssthtc._update_rescue_file(rescue_file) 

976 

977 with open(rescue_file) as fh: 

978 lines = fh.readlines() 

979 results = "".join(lines) 

980 

981 truth = """# Rescue DAG file, created after running 

982# the u_testuser_DM-46294_group_fail_20250310T160455Z.dag DAG file 

983# Created 3/10/2025 16:08:56 UTC 

984# Rescue DAG version: 2.0.1 (partial) 

985# 

986# Total number of Nodes: 26 

987# Nodes premarked DONE: 21 

988# Nodes that failed: 2 

989# wms_group_order1_val1b,finalJob,<ENDLIST> 

990 

991DONE pipetaskInit 

992DONE label1_val1c_val2a 

993DONE label1_val1b_val2b 

994DONE label1_val1b_val2a 

995DONE label1_val1c_val2b 

996DONE label1_val1a_val2a 

997DONE label1_val1a_val2b 

998DONE label3_val1c_val2a 

999DONE label3_val1b_val2b 

1000DONE label3_val1b_val2a 

1001DONE label3_val1c_val2b 

1002DONE label3_val1a_val2a 

1003DONE label3_val1a_val2b 

1004DONE wms_group_order1_val1a 

1005DONE label5_val1a_val2a 

1006DONE label5_val1a_val2b 

1007DONE wms_group_order1_val1c 

1008DONE label5_val1c_val2a 

1009DONE label5_val1c_val2b 

1010DONE wms_check_status_wms_group_order1_val1a 

1011DONE wms_check_status_wms_group_order1_val1c 

1012""" 

1013 

1014 print("results = ", results, file=sys.stderr) 

1015 print("truth = ", truth, file=sys.stderr) 

1016 self.assertEqual(results, truth) 

1017 

1018 

1019class ReadDagStatusTestCase(unittest.TestCase): 

1020 """Test read_dag_status function and read_single_dag_status.""" 

1021 

1022 def testFileMissing(self): 

1023 with temporaryDirectory() as tmp_dir: 

1024 with self.assertRaisesRegex(FileNotFoundError, "DAGMan node status not found"): 

1025 _ = lssthtc.read_dag_status(tmp_dir) 

1026 

1027 def testRegular(self): 

1028 with temporaryDirectory() as tmp_dir: 

1029 submit_dir = os.path.join(tmp_dir, "tiny_problems") 

1030 copytree(f"{TESTDIR}/data/tiny_problems", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

1031 results = lssthtc.read_dag_status(submit_dir) 

1032 truth = { 

1033 "JobProcsHeld": 0, 

1034 "NodesPost": 0, 

1035 "JobProcsIdle": 0, 

1036 "NodesTotal": 6, 

1037 "NodesFailed": 2, 

1038 "NodesDone": 3, 

1039 "NodesQueued": 0, 

1040 "NodesPre": 0, 

1041 "NodesFutile": 1, 

1042 "NodesUnready": 0, 

1043 } 

1044 self.assertEqual(results, results | truth) 

1045 

1046 def testSubdags(self): 

1047 """Making sure it gets data from subdag dirs and doesn't 

1048 fail if some subdags haven't started running yet. 

1049 """ 

1050 self.maxDiff = None 

1051 with temporaryDirectory() as tmp_dir: 

1052 submit_dir = os.path.join(tmp_dir, "submit") 

1053 copytree(f"{TESTDIR}/data/group_running_1", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

1054 results = lssthtc.read_dag_status(submit_dir) 

1055 truth = { 

1056 "JobProcsHeld": 0, 

1057 "NodesPost": 0, 

1058 "JobProcsIdle": 0, 

1059 "NodesTotal": 34, 

1060 "NodesFailed": 0, 

1061 "NodesDone": 17, 

1062 "NodesQueued": 3, 

1063 "NodesPre": 0, 

1064 "NodesFutile": 0, 

1065 "NodesUnready": 14, 

1066 } 

1067 self.assertEqual(results, results | truth) 

1068 

1069 

1070class ReadDagInfoTestCase(unittest.TestCase): 

1071 """Test read_dag_info function.""" 

1072 

1073 def testFileMissing(self): 

1074 with temporaryDirectory() as tmp_dir: 

1075 with self.assertRaisesRegex(FileNotFoundError, "File with DAGMan job information not found in "): 

1076 _ = lssthtc.read_dag_info(tmp_dir) 

1077 

1078 def testSuccess(self): 

1079 with temporaryDirectory() as tmp_dir: 

1080 copy2(f"{TESTDIR}/data/tiny_success/tiny_success.info.json", tmp_dir) 

1081 results = lssthtc.read_dag_info(tmp_dir) 

1082 

1083 truth = { 

1084 "test02": { 

1085 "9208.0": { 

1086 "ClusterId": 9208, 

1087 "GlobalJobId": "test02#9208.0#1739465078", 

1088 "bps_wms_service": "lsst.ctrl.bps.htcondor.htcondor_service.HTCondorService", 

1089 "bps_project": "dev", 

1090 "bps_payload": "tiny", 

1091 "bps_operator": "testuser", 

1092 "bps_wms_workflow": "lsst.ctrl.bps.htcondor.htcondor_service.HTCondorWorkflow", 

1093 "bps_provisioning_job": "provisioningJob", 

1094 "bps_run_quanta": "label1:1;label2:1", 

1095 "bps_campaign": "quick", 

1096 "bps_runsite": "testpool", 

1097 "bps_job_summary": "pipetaskInit:1;label1:1;label2:1;finalJob:1", 

1098 "bps_run": "u_testuser_tiny_20250213T164427Z", 

1099 "bps_isjob": "True", 

1100 } 

1101 } 

1102 } 

1103 

1104 self.assertEqual(results, truth) 

1105 

1106 def testPermissionError(self): 

1107 with temporaryDirectory() as tmp_dir: 

1108 copy2(f"{TESTDIR}/data/tiny_success/tiny_success.info.json", tmp_dir) 

1109 with unittest.mock.patch("lsst.ctrl.bps.htcondor.lssthtc.open") as mocked_open: 

1110 mocked_open.side_effect = PermissionError 

1111 with self.assertLogs("lsst.ctrl.bps.htcondor", level="DEBUG") as cm: 

1112 results = lssthtc.read_dag_info(tmp_dir) 

1113 self.assertIn("Retrieving DAGMan job information failed:", cm.output[-1]) 

1114 self.assertEqual({}, results) 

1115 

1116 

1117class HtcWriteCondorFileTestCase(unittest.TestCase): 

1118 """Test htc_write_condor_file function.""" 

1119 

1120 def testSuccess(self): 

1121 with temporaryDirectory() as tmp_dir: 

1122 job_name = "job1" 

1123 filename = pathlib.Path(tmp_dir) / f"label1/{job_name}.sub" 

1124 job = { 

1125 "executable": "$(CTRL_MPEXEC_DIR)/bin/pipetask", 

1126 "arguments": "-a -b 2 -c", 

1127 "request_memory": "2000", 

1128 "environment": "one=1 two=\"2\" three='spacey 'quoted' value'", 

1129 "log": f"{job_name}.log", 

1130 } 

1131 job_attrs = { 

1132 "bps_job_name": job_name, 

1133 "bps_job_label": "label1", 

1134 "bps_job_quanta": "task1:8;task2:8", 

1135 } 

1136 expected = [ 

1137 "executable=$(CTRL_MPEXEC_DIR)/bin/pipetask\n", 

1138 "arguments=-a -b 2 -c\n", 

1139 "request_memory=2000\n", 

1140 "environment=\"one=1 two=\"2\" three='spacey 'quoted' value'\"\n", 

1141 f"output={job_name}.$(Cluster).out\n", 

1142 f"error={job_name}.$(Cluster).out\n", 

1143 f"log={job_name}.log\n", 

1144 f'+bps_job_name = "{job_name}"\n', 

1145 '+bps_job_label = "label1"\n', 

1146 '+bps_job_quanta = "task1:8;task2:8"\n', 

1147 "queue\n", 

1148 ] 

1149 

1150 lssthtc.htc_write_condor_file(filename, job_name, job, job_attrs) 

1151 with open(filename, encoding="utf-8") as f: 

1152 actual = f.readlines() 

1153 

1154 self.assertEqual(set(actual), set(expected)) 

1155 self.assertTrue(filename.exists()) 

1156 # Try to make Submit object from file to find any syntax issues 

1157 _ = lssthtc.htc_create_submit_from_file(filename) 

1158 

1159 

1160class HtcCreateSubmitFromDagTestCase(unittest.TestCase): 

1161 """Test htc_create_submit_from_dag function.""" 

1162 

1163 @classmethod 

1164 def setUpClass(cls): 

1165 cls.bindir = None 

1166 # htcondor.Submit.from_dag requires condor_dagman executable in path. 

1167 if not which("condor_dagman"): # pragma: no cover 

1168 cls.bindir = tempfile.TemporaryDirectory() 

1169 fake_dagman_exec = pathlib.Path(cls.bindir.name) / "condor_dagman" 

1170 with open(fake_dagman_exec, "w") as fh: 

1171 print("#!/bin/bash", file=fh) 

1172 print("echo fake_condor_dagman $@", file=fh) 

1173 print("exit 0", file=fh) 

1174 fake_dagman_exec.chmod(fake_dagman_exec.stat().st_mode | stat.S_IEXEC) 

1175 os.environ["PATH"] = f"{os.environ['PATH']}:{cls.bindir.name}" 

1176 

1177 @classmethod 

1178 def tearDownClass(cls): 

1179 if cls.bindir: 

1180 cls.bindir.cleanup() 

1181 

1182 @unittest.mock.patch.dict(os.environ, {"_CONDOR_DAGMAN_MAX_JOBS_IDLE": "42"}) 

1183 def testMaxIdleEnvVar(self): 

1184 with temporaryDirectory() as tmp_dir: 

1185 copy2(f"{TESTDIR}/data/tiny_success/tiny_success.dag", tmp_dir) 

1186 dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag" 

1187 submit = lssthtc.htc_create_submit_from_dag(str(dag_filename), {}) 

1188 self.assertIn("-MaxIdle 42", submit["arguments"]) 

1189 self.assertEqual("true", os.environ["_CONDOR_DAGMAN_MANAGER_JOB_APPEND_GETENV"].lower()) 

1190 

1191 @unittest.mock.patch.dict(os.environ, {}) 

1192 def testMaxIdleGiven(self): 

1193 with temporaryDirectory() as tmp_dir: 

1194 copy2(f"{TESTDIR}/data/tiny_success/tiny_success.dag", tmp_dir) 

1195 dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag" 

1196 submit = lssthtc.htc_create_submit_from_dag(str(dag_filename), {"MaxIdle": 37}) 

1197 self.assertIn("-MaxIdle 37", submit["arguments"]) 

1198 self.assertEqual("true", os.environ["_CONDOR_DAGMAN_MANAGER_JOB_APPEND_GETENV"].lower()) 

1199 

1200 @unittest.mock.patch.dict(os.environ, {}) 

1201 def testNoMaxJobsIdle(self): 

1202 """Note: Since the produced arguments differ depending on 

1203 HTCondor version when no MaxIdle passed to from_dag, not 

1204 checking arguments string here. Instead just making sure 

1205 lssthtc code doesn't pass MaxIdle value to from_dag. 

1206 """ 

1207 with temporaryDirectory() as tmp_dir: 

1208 copy2(f"{TESTDIR}/data/tiny_success/tiny_success.dag", tmp_dir) 

1209 dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag" 

1210 with unittest.mock.patch("htcondor.Submit.from_dag") as submit_mock: 

1211 with unittest.mock.patch("htcondor.param") as mock_param: 

1212 mock_param.__contains__.return_value = False 

1213 _ = lssthtc.htc_create_submit_from_dag(str(dag_filename), {}) 

1214 self.assertEqual("true", os.environ["_CONDOR_DAGMAN_MANAGER_JOB_APPEND_GETENV"].lower()) 

1215 submit_mock.assert_called_once_with(str(dag_filename), {}) 

1216 

1217 @unittest.mock.patch.dict(os.environ, {}) 

1218 def testDoRecurseGivenWithNoEnv(self): 

1219 with temporaryDirectory() as tmp_dir: 

1220 copy2(f"{TESTDIR}/data/tiny_success/tiny_success.dag", tmp_dir) 

1221 dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag" 

1222 submit = lssthtc.htc_create_submit_from_dag(str(dag_filename), {"do_recurse": True}) 

1223 self.assertIn("-do_recurse", submit["arguments"]) 

1224 self.assertEqual("true", os.environ["_CONDOR_DAGMAN_GENERATE_SUBDAG_SUBMITS"].lower()) 

1225 self.assertEqual("true", os.environ["_CONDOR_DAGMAN_MANAGER_JOB_APPEND_GETENV"].lower()) 

1226 

1227 @unittest.mock.patch.dict(os.environ, {"_CONDOR_DAGMAN_GENERATE_SUBDAG_SUBMITS": "False"}) 

1228 def testDoRecurseGivenWithEnv(self): 

1229 with temporaryDirectory() as tmp_dir: 

1230 copy2(f"{TESTDIR}/data/tiny_success/tiny_success.dag", tmp_dir) 

1231 dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag" 

1232 submit = lssthtc.htc_create_submit_from_dag(str(dag_filename), {"do_recurse": True}) 

1233 self.assertIn("-do_recurse", submit["arguments"]) 

1234 self.assertEqual("true", os.environ["_CONDOR_DAGMAN_MANAGER_JOB_APPEND_GETENV"].lower()) 

1235 self.assertEqual("false", os.environ["_CONDOR_DAGMAN_GENERATE_SUBDAG_SUBMITS"].lower()) 

1236 

1237 @unittest.mock.patch.dict(os.environ, {"_CONDOR_DAGMAN_MANAGER_JOB_APPEND_GETENV": "*_DIR"}) 

1238 def testGetEnvOverridden(self): 

1239 with temporaryDirectory() as tmp_dir: 

1240 copy2(f"{TESTDIR}/data/tiny_success/tiny_success.dag", tmp_dir) 

1241 dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag" 

1242 submit = lssthtc.htc_create_submit_from_dag(str(dag_filename), {"do_recurse": True}) 

1243 self.assertIn("-do_recurse", submit["arguments"]) 

1244 self.assertEqual(os.environ["_CONDOR_DAGMAN_MANAGER_JOB_APPEND_GETENV"], "*_DIR") 

1245 

1246 

1247if __name__ == "__main__": 

1248 unittest.main()