Coverage for tests/test_lssthtc.py: 20%

540 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-06 01:43 -0800

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 HtcTweakJobInfoTestCase(unittest.TestCase): 

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

75 

76 def setUp(self): 

77 self.log_dir = tempfile.TemporaryDirectory() 

78 self.log_dirname = pathlib.Path(self.log_dir.name) 

79 self.job = { 

80 "Cluster": 1, 

81 "Proc": 0, 

82 "Iwd": str(self.log_dirname), 

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

84 "MyType": None, 

85 "TerminatedNormally": True, 

86 } 

87 

88 def tearDown(self): 

89 self.log_dir.cleanup() 

90 

91 def testDirectAssignments(self): 

92 lssthtc.htc_tweak_log_info(self.log_dirname, 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_dirname)) 

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

97 

98 def testIncompatibleAdPassThru(self): 

99 # Passing a job ad with insufficient information should be a no-op. 

100 expected = {"foo": "bar"} 

101 result = dict(expected) 

102 lssthtc.htc_tweak_log_info(self.log_dirname, result) 

103 self.assertEqual(result, expected) 

104 

105 def testJobStatusAssignmentJobAbortedEvent(self): 

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

107 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

108 self.assertTrue("JobStatus" in job) 

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

110 

111 def testJobStatusAssignmentExecuteEvent(self): 

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

113 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

114 self.assertTrue("JobStatus" in job) 

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

116 

117 def testJobStatusAssignmentSubmitEvent(self): 

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

119 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

120 self.assertTrue("JobStatus" in job) 

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

122 

123 def testJobStatusAssignmentJobHeldEvent(self): 

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

125 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

126 self.assertTrue("JobStatus" in job) 

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

128 

129 def testJobStatusAssignmentJobTerminatedEvent(self): 

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

131 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

132 self.assertTrue("JobStatus" in job) 

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

134 

135 def testJobStatusAssignmentPostScriptTerminatedEvent(self): 

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

137 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

138 self.assertTrue("JobStatus" in job) 

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

140 

141 def testJobStatusAssignmentReleaseEvent(self): 

142 job = self.job | {"MyType": "JobReleaseEvent"} 

143 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

144 self.assertTrue("JobStatus" in job) 

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

146 

147 def testAddingExitStatusSuccess(self): 

148 job = self.job | { 

149 "MyType": "JobTerminatedEvent", 

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

151 } 

152 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

153 self.assertIn("ExitBySignal", job) 

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

155 self.assertIn("ExitCode", job) 

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

157 

158 def testAddingExitStatusFailure(self): 

159 job = self.job | { 

160 "MyType": "JobHeldEvent", 

161 } 

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

163 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

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

165 

166 def testLoggingUnknownLogEvent(self): 

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

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

169 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

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

171 

172 def testMissingKey(self): 

173 job = self.job 

174 del job["Cluster"] 

175 with self.assertRaises(KeyError) as cm: 

176 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

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

178 

179 

180class HtcCheckDagmanOutputTestCase(unittest.TestCase): 

181 """Test htc_check_dagman_output function.""" 

182 

183 def test_missing_output_file(self): 

184 with temporaryDirectory() as tmp_dir: 

185 with self.assertRaises(FileNotFoundError): 

186 _ = lssthtc.htc_check_dagman_output(tmp_dir) 

187 

188 def test_permissions_output_file(self): 

189 with temporaryDirectory() as tmp_dir: 

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

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

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

193 results = lssthtc.htc_check_dagman_output(tmp_dir) 

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

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

196 

197 def test_submit_failure(self): 

198 with temporaryDirectory() as tmp_dir: 

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

200 results = lssthtc.htc_check_dagman_output(tmp_dir) 

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

202 

203 def test_tmpdir_abort(self): 

204 with temporaryDirectory() as tmp_dir: 

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

206 results = lssthtc.htc_check_dagman_output(tmp_dir) 

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

208 

209 def test_no_messages(self): 

210 with temporaryDirectory() as tmp_dir: 

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

212 results = lssthtc.htc_check_dagman_output(tmp_dir) 

213 self.assertEqual("", results) 

214 

215 

216class SummarizeDagTestCase(unittest.TestCase): 

217 """Test summarize_dag function.""" 

218 

219 def test_no_dag_file(self): 

220 with temporaryDirectory() as tmp_dir: 

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

222 self.assertFalse(len(job_name_to_pipetask)) 

223 self.assertFalse(len(job_name_to_type)) 

224 self.assertFalse(summary) 

225 

226 def test_success(self): 

227 with temporaryDirectory() as tmp_dir: 

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

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

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

231 self.assertEqual( 

232 job_name_to_label, 

233 { 

234 "pipetaskInit": "pipetaskInit", 

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

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

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

238 "finalJob": "finalJob", 

239 }, 

240 ) 

241 self.assertEqual( 

242 job_name_to_type, 

243 { 

244 "pipetaskInit": lssthtc.WmsNodeType.PAYLOAD, 

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

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

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

248 "finalJob": lssthtc.WmsNodeType.FINAL, 

249 }, 

250 ) 

251 

252 def test_service(self): 

253 with temporaryDirectory() as tmp_dir: 

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

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

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

257 self.assertEqual( 

258 job_name_to_label, 

259 { 

260 "pipetaskInit": "pipetaskInit", 

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

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

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

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

265 "finalJob": "finalJob", 

266 "provisioningJob": "provisioningJob", 

267 }, 

268 ) 

269 self.assertEqual( 

270 job_name_to_type, 

271 { 

272 "pipetaskInit": lssthtc.WmsNodeType.PAYLOAD, 

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

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

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

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

277 "finalJob": lssthtc.WmsNodeType.FINAL, 

278 "provisioningJob": lssthtc.WmsNodeType.SERVICE, 

279 }, 

280 ) 

281 

282 def test_noop(self): 

283 with temporaryDirectory() as tmp_dir: 

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

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

286 self.assertEqual( 

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

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

289 ) 

290 self.assertEqual( 

291 job_name_to_label, 

292 { 

293 "label1_val1a_val2a": "label1", 

294 "label1_val1a_val2b": "label1", 

295 "label1_val1b_val2a": "label1", 

296 "label1_val1b_val2b": "label1", 

297 "label1_val1c_val2a": "label1", 

298 "label1_val1c_val2b": "label1", 

299 "label2_val1a_val2a": "label2", 

300 "label2_val1a_val2b": "label2", 

301 "label2_val1b_val2a": "label2", 

302 "label2_val1b_val2b": "label2", 

303 "label2_val1c_val2a": "label2", 

304 "label2_val1c_val2b": "label2", 

305 "label3_val1a_val2a": "label3", 

306 "label3_val1a_val2b": "label3", 

307 "label3_val1b_val2a": "label3", 

308 "label3_val1b_val2b": "label3", 

309 "label3_val1c_val2a": "label3", 

310 "label3_val1c_val2b": "label3", 

311 "label4_val1a_val2a": "label4", 

312 "label4_val1a_val2b": "label4", 

313 "label4_val1b_val2a": "label4", 

314 "label4_val1b_val2b": "label4", 

315 "label4_val1c_val2a": "label4", 

316 "label4_val1c_val2b": "label4", 

317 "label5_val1a_val2a": "label5", 

318 "label5_val1a_val2b": "label5", 

319 "label5_val1b_val2a": "label5", 

320 "label5_val1b_val2b": "label5", 

321 "label5_val1c_val2a": "label5", 

322 "label5_val1c_val2b": "label5", 

323 "finalJob": "finalJob", 

324 "pipetaskInit": "pipetaskInit", 

325 "wms_noop_order1_val1a": "order1", 

326 "wms_noop_order1_val1b": "order1", 

327 }, 

328 ) 

329 self.assertEqual( 

330 job_name_to_type, 

331 { 

332 "label1_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

333 "label1_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

334 "label1_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

335 "label1_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

336 "label1_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

337 "label1_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

338 "label2_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

339 "label2_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

340 "label2_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

341 "label2_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

342 "label2_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

343 "label2_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

344 "label3_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

345 "label3_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

346 "label3_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

347 "label3_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

348 "label3_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

349 "label3_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

350 "label4_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

351 "label4_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

352 "label4_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

353 "label4_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

354 "label4_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

355 "label4_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

356 "label5_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

357 "label5_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

358 "label5_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

359 "label5_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

360 "label5_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

361 "label5_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

362 "finalJob": lssthtc.WmsNodeType.FINAL, 

363 "pipetaskInit": lssthtc.WmsNodeType.PAYLOAD, 

364 "wms_noop_order1_val1a": lssthtc.WmsNodeType.NOOP, 

365 "wms_noop_order1_val1b": lssthtc.WmsNodeType.NOOP, 

366 }, 

367 ) 

368 

369 def test_subdags(self): 

370 with temporaryDirectory() as tmp_dir: 

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

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

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

374 self.assertEqual( 

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

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

377 ) 

378 

379 self.assertEqual( 

380 job_name_to_label, 

381 { 

382 "pipetaskInit": "pipetaskInit", 

383 "label1_val1b_val2a": "label1", 

384 "label1_val1c_val2a": "label1", 

385 "label1_val1a_val2b": "label1", 

386 "label1_val1b_val2b": "label1", 

387 "label1_val1c_val2b": "label1", 

388 "label1_val1a_val2a": "label1", 

389 "label2_val1a_val2b": "label2", 

390 "label2_val1a_val2a": "label2", 

391 "label2_val1b_val2a": "label2", 

392 "label2_val1b_val2b": "label2", 

393 "label2_val1c_val2a": "label2", 

394 "label2_val1c_val2b": "label2", 

395 "label3_val1b_val2a": "label3", 

396 "label3_val1c_val2a": "label3", 

397 "label3_val1a_val2b": "label3", 

398 "label3_val1b_val2b": "label3", 

399 "label3_val1c_val2b": "label3", 

400 "label3_val1a_val2a": "label3", 

401 "label4_val1a_val2b": "label4", 

402 "label4_val1a_val2a": "label4", 

403 "label4_val1b_val2a": "label4", 

404 "label4_val1b_val2b": "label4", 

405 "label4_val1c_val2a": "label4", 

406 "label4_val1c_val2b": "label4", 

407 "label5_val1a_val2b": "label5", 

408 "label5_val1a_val2a": "label5", 

409 "label5_val1b_val2a": "label5", 

410 "label5_val1b_val2b": "label5", 

411 "label5_val1c_val2a": "label5", 

412 "label5_val1c_val2b": "label5", 

413 "finalJob": "finalJob", 

414 "provisioningJob": "provisioningJob", 

415 "wms_group_order1_val1a": "order1", 

416 "wms_group_order1_val1b": "order1", 

417 "wms_group_order1_val1c": "order1", 

418 "wms_check_status_wms_group_order1_val1a": "order1", 

419 "wms_check_status_wms_group_order1_val1b": "order1", 

420 "wms_check_status_wms_group_order1_val1c": "order1", 

421 }, 

422 ) 

423 

424 self.assertEqual( 

425 job_name_to_type, 

426 { 

427 "pipetaskInit": lssthtc.WmsNodeType.PAYLOAD, 

428 "label1_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

429 "label1_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

430 "label1_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

431 "label1_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

432 "label1_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

433 "label1_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

434 "label2_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

435 "label2_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

436 "label2_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

437 "label2_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

438 "label2_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

439 "label2_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

440 "label3_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

441 "label3_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

442 "label3_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

443 "label3_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

444 "label3_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

445 "label3_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

446 "label4_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

447 "label4_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

448 "label4_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

449 "label4_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

450 "label4_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

451 "label4_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

452 "label5_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

453 "label5_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

454 "label5_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

455 "label5_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

456 "label5_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

457 "label5_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

458 "finalJob": lssthtc.WmsNodeType.FINAL, 

459 "provisioningJob": lssthtc.WmsNodeType.SERVICE, 

460 "wms_group_order1_val1a": lssthtc.WmsNodeType.SUBDAG, 

461 "wms_group_order1_val1b": lssthtc.WmsNodeType.SUBDAG, 

462 "wms_group_order1_val1c": lssthtc.WmsNodeType.SUBDAG, 

463 "wms_check_status_wms_group_order1_val1a": lssthtc.WmsNodeType.SUBDAG_CHECK, 

464 "wms_check_status_wms_group_order1_val1b": lssthtc.WmsNodeType.SUBDAG_CHECK, 

465 "wms_check_status_wms_group_order1_val1c": lssthtc.WmsNodeType.SUBDAG_CHECK, 

466 }, 

467 ) 

468 

469 

470class ReadDagNodesLogTestCase(unittest.TestCase): 

471 """Test read_dag_nodes_log function.""" 

472 

473 def setUp(self): 

474 self.tmpdir = tempfile.mkdtemp() 

475 

476 def tearDown(self): 

477 rmtree(self.tmpdir, ignore_errors=True) 

478 

479 def testFileMissing(self): 

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

481 _ = lssthtc.read_dag_nodes_log(self.tmpdir) 

482 

483 def testRegular(self): 

484 with temporaryDirectory() as tmp_dir: 

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

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

487 results = lssthtc.read_dag_nodes_log(submit_dir) 

488 self.assertEqual(results["9231.0"]["Cluster"], 9231) 

489 self.assertEqual(results["9231.0"]["Proc"], 0) 

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

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

492 

493 def testSubdags(self): 

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

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

496 """ 

497 with temporaryDirectory() as tmp_dir: 

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

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

500 results = lssthtc.read_dag_nodes_log(submit_dir) 

501 # main dag 

502 self.assertEqual(results["10094.0"]["Cluster"], 10094) 

503 # subdag 

504 self.assertEqual(results["10112.0"]["Cluster"], 10112) 

505 self.assertEqual(results["10116.0"]["Cluster"], 10116) 

506 

507 

508class ReadNodeStatusTestCase(unittest.TestCase): 

509 """Test read_node_status function.""" 

510 

511 def setUp(self): 

512 self.tmpdir = tempfile.mkdtemp() 

513 

514 def tearDown(self): 

515 rmtree(self.tmpdir, ignore_errors=True) 

516 

517 def testServiceJobNotSubmitted(self): 

518 # tiny_prov_no_submit files have successful workflow 

519 # but provisioningJob could not submit. 

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

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

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

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

524 

525 jobs = lssthtc.read_node_status(self.tmpdir) 

526 found = [ 

527 id_ 

528 for id_ in jobs 

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

530 ] 

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

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

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

534 

535 def testMissingStatusFile(self): 

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

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

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

539 

540 jobs = lssthtc.read_node_status(self.tmpdir) 

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

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

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

544 found = [ 

545 id_ 

546 for id_ in jobs 

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

548 ] 

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

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

551 

552 def testSubdagsRunning(self): 

553 with temporaryDirectory() as tmp_dir: 

554 test_tmp_dir = pathlib.Path(tmp_dir) 

555 submit_dir = test_tmp_dir / "submit" 

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

557 jobs = lssthtc.read_node_status(submit_dir) 

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

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

560 job_name_to_id = {} 

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

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

563 job_type_to_names = {} 

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

565 job_type_to_names.setdefault( 

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

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

568 

569 # check counts 

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

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

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

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

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

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

576 

577 # spot check some statuses 

578 self.assertEqual( 

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

580 ) 

581 self.assertEqual( 

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

583 ) 

584 self.assertEqual( 

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

586 ) 

587 self.assertEqual( 

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

589 ) 

590 

591 def testSubdagsFailed(self): 

592 with temporaryDirectory() as tmp_dir: 

593 test_tmp_dir = pathlib.Path(tmp_dir) 

594 submit_dir = test_tmp_dir / "submit" 

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

596 jobs = lssthtc.read_node_status(submit_dir) 

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

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

599 job_name_to_id = {} 

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

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

602 job_type_to_names = {} 

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

604 job_type_to_names.setdefault( 

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

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

607 

608 # check counts 

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

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

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

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

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

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

615 

616 # spot check some statuses 

617 self.assertEqual( 

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

619 ) 

620 self.assertEqual( 

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

622 ) 

623 self.assertEqual( 

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

625 ) 

626 

627 self.assertEqual( 

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

629 ) 

630 self.assertEqual( 

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

632 ) 

633 self.assertEqual( 

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

635 lssthtc.NodeStatus.ERROR, 

636 ) 

637 

638 

639class HTCJobTestCase(unittest.TestCase): 

640 """Test HTCJob methods.""" 

641 

642 def testWriteDagCommandsPayload(self): 

643 job = lssthtc.HTCJob( 

644 "job1", 

645 "label1", 

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

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

648 ) 

649 job.subfile = "job1.sub" 

650 

651 mockfh = io.StringIO() 

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

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

654 

655 def testWriteDagCommandsNotJob(self): 

656 # Testing giving command_name, no dag_rel_path and no dir 

657 job = lssthtc.HTCJob( 

658 "finalJob", 

659 "finalJob", 

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

661 ) 

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

663 mockfh = io.StringIO() 

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

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

666 

667 def testWriteDagCommandsNoop(self): 

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

669 job.subfile = "notthere.sub" 

670 mockfh = io.StringIO() 

671 job.write_dag_commands(mockfh, "") 

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

673 

674 def testWriteSubmitFile(self): 

675 job = lssthtc.HTCJob( 

676 "job1", 

677 "label1", 

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

679 ) 

680 with temporaryDirectory() as tmp_dir: 

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

682 job.write_submit_file(filename.parent) 

683 self.assertTrue(filename.exists()) 

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

685 _ = lssthtc.htc_create_submit_from_file(filename) 

686 

687 def testWriteSubmitFileExists(self): 

688 job = lssthtc.HTCJob( 

689 "job1", 

690 "label1", 

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

692 ) 

693 with temporaryDirectory() as tmp_dir: 

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

695 job.subfile = filename 

696 with open(filename, "w"): 

697 pass # make empty file 

698 job.write_submit_file(filename.parent) 

699 # make sure didn't overwrite file 

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

701 

702 

703class HtcWriteJobCommands(unittest.TestCase): 

704 """Test _htc_write_job_commands function.""" 

705 

706 def testAllCommands(self): 

707 dag_cmds = { 

708 "pre": { 

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

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

711 "executable": "exec1", 

712 "arguments": "arg1 arg2", 

713 }, 

714 "post": { 

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

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

717 "executable": "exec2", 

718 "arguments": "arg3 arg4", 

719 }, 

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

721 "pre_skip": "1", 

722 "retry": 3, 

723 "retry_unless_exit": 1, 

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

725 } 

726 

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

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

729VARS job1 num="8" 

730VARS job1 spaces="a space" 

731PRE_SKIP job1 1 

732RETRY job1 3 UNLESS-EXIT 1 

733ABORT-DAG-ON job1 100 RETURN 4 

734""" 

735 mockfh = io.StringIO() 

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

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

738 

739 def testPartialCommands(self): 

740 # Trigger skipping the inner if clauses. 

741 dag_cmds = { 

742 "pre": { 

743 "executable": "exec1", 

744 }, 

745 "post": { 

746 "executable": "exec2", 

747 }, 

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

749 "pre_skip": "1", 

750 "retry": 3, 

751 } 

752 

753 truth = """SCRIPT PRE job1 exec1 

754SCRIPT POST job1 exec2 

755VARS job1 num="8" 

756VARS job1 spaces="a space" 

757PRE_SKIP job1 1 

758RETRY job1 3 

759""" 

760 mockfh = io.StringIO() 

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

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

763 

764 def testNoCommands(self): 

765 dag_cmds = {} 

766 mockfh = io.StringIO() 

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

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

769 

770 

771class HTCBackupFilesSinglePathTestCase(unittest.TestCase): 

772 """Test htc_backup_files_single_path function.""" 

773 

774 def testSrcDestSame(self): 

775 with temporaryDirectory() as tmp_dir: 

776 with self.assertRaisesRegex( 

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

778 ): 

779 lssthtc.htc_backup_files_single_path(tmp_dir, tmp_dir) 

780 

781 def testSuccess(self): 

782 with temporaryDirectory() as tmp_dir: 

783 test_tmp_dir = pathlib.Path(tmp_dir) 

784 submit_dir = test_tmp_dir / "the_src_dir" 

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

786 backup_dir = test_tmp_dir / "the_dest_dir" 

787 backup_dir.mkdir() 

788 lssthtc.htc_backup_files_single_path(submit_dir, backup_dir) 

789 result_submit = [] 

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

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

792 self.assertEqual( 

793 set(result_submit), 

794 { 

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

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

797 "./tiny_success.dag", 

798 }, 

799 ) 

800 result_backup = [] 

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

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

803 self.assertEqual( 

804 set(result_backup), 

805 { 

806 "./tiny_success.info.json", 

807 "./tiny_success.dag.metrics", 

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

809 "./tiny_success.node_status", 

810 }, 

811 ) 

812 

813 

814class HTCBackupFilesTestCase(unittest.TestCase): 

815 """Test htc_backup_files function.""" 

816 

817 def testDirectoryNotFound(self): 

818 with temporaryDirectory() as tmp_dir: 

819 test_tmp_dir = pathlib.Path(tmp_dir) 

820 submit_dir = test_tmp_dir / "submit" 

821 with self.assertRaises(FileNotFoundError): 

822 _ = lssthtc.htc_backup_files(submit_dir) 

823 

824 def testSuccess(self): 

825 with temporaryDirectory() as tmp_dir: 

826 test_tmp_dir = pathlib.Path(tmp_dir) 

827 submit_dir = test_tmp_dir / "submit" 

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

829 result_rescue = lssthtc.htc_backup_files(submit_dir) 

830 self.assertIsNone(result_rescue) 

831 result_submit = [] 

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

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

834 self.assertEqual( 

835 set(result_submit), 

836 { 

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

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

839 "./tiny_success.dag", 

840 "000/tiny_success.info.json", 

841 "000/tiny_success.dag.metrics", 

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

843 "000/tiny_success.node_status", 

844 }, 

845 ) 

846 

847 def testDestNotInSubmitDir(self): 

848 with temporaryDirectory() as tmp_dir: 

849 test_tmp_dir = pathlib.Path(tmp_dir) 

850 submit_dir = test_tmp_dir / "submit" 

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

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

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

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

855 result_rescue = lssthtc.htc_backup_files(submit_dir) 

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

857 result_submit = [] 

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

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

860 self.assertEqual( 

861 set(result_submit), 

862 { 

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

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

865 "./tiny_problems.dag", 

866 "./tiny_problems.dag.rescue001", 

867 "001/tiny_problems.info.json", 

868 "001/tiny_problems.dag.metrics", 

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

870 "001/tiny_problems.node_status", 

871 }, 

872 ) 

873 

874 def testDestInSubmitDir(self): 

875 with temporaryDirectory() as tmp_dir: 

876 test_tmp_dir = pathlib.Path(tmp_dir) 

877 submit_dir = test_tmp_dir / "submit" 

878 backup_dir = submit_dir / "subdir" 

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

880 result_rescue = lssthtc.htc_backup_files(submit_dir, backup_dir) 

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

882 result_submit = [] 

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

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

885 self.assertEqual( 

886 set(result_submit), 

887 { 

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

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

890 "./tiny_problems.dag", 

891 "./tiny_problems.dag.rescue001", 

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

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

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

895 "subdir/001/tiny_problems.node_status", 

896 }, 

897 ) 

898 

899 def testRelativeSubdir(self): 

900 with temporaryDirectory() as tmp_dir: 

901 test_tmp_dir = pathlib.Path(tmp_dir) 

902 submit_dir = test_tmp_dir / "submit" 

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

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

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

906 result_submit = [] 

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

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

909 self.assertEqual( 

910 set(result_submit), 

911 { 

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

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

914 "./tiny_problems.dag", 

915 "./tiny_problems.dag.rescue001", 

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

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

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

919 "reldir/001/tiny_problems.node_status", 

920 }, 

921 ) 

922 

923 def testSubdags(self): 

924 with temporaryDirectory() as tmp_dir: 

925 test_tmp_dir = pathlib.Path(tmp_dir) 

926 submit_dir = test_tmp_dir / "submit" 

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

928 result_rescue = lssthtc.htc_backup_files(submit_dir) 

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

930 result_submit = [] 

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

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

933 self.assertEqual( 

934 set(result_submit), 

935 { 

936 "./group_failed_1.dag", 

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

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

939 "./group_failed_1.dag.rescue001", 

940 "subdags/wms_group_order1_val1a/group_order1_val1a.dag", 

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

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

943 "subdags/wms_group_order1_val1b/group_order1_val1b.dag", 

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

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

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

947 "subdags/wms_group_order1_val1c/group_order1_val1c.dag", 

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

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

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

951 "001/group_failed_1.info.json", 

952 "001/group_failed_1.node_status", 

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

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

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

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

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

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

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

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

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

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

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

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

965 }, 

966 ) 

967 

968 

969class UpdateRescueFileTestCase(unittest.TestCase): 

970 """Test _update_rescue_file function.""" 

971 

972 def testSuccess(self): 

973 self.maxDiff = None 

974 with temporaryDirectory() as tmp_dir: 

975 test_tmp_dir = pathlib.Path(tmp_dir) 

976 submit_dir = test_tmp_dir / "submit" 

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

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

979 lssthtc._update_rescue_file(rescue_file) 

980 

981 with open(rescue_file) as fh: 

982 lines = fh.readlines() 

983 results = "".join(lines) 

984 

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

986# the u_testuser_DM-46294_group_fail_20250310T160455Z.dag DAG file 

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

988# Rescue DAG version: 2.0.1 (partial) 

989# 

990# Total number of Nodes: 26 

991# Nodes premarked DONE: 21 

992# Nodes that failed: 2 

993# wms_group_order1_val1b,finalJob,<ENDLIST> 

994 

995DONE pipetaskInit 

996DONE label1_val1c_val2a 

997DONE label1_val1b_val2b 

998DONE label1_val1b_val2a 

999DONE label1_val1c_val2b 

1000DONE label1_val1a_val2a 

1001DONE label1_val1a_val2b 

1002DONE label3_val1c_val2a 

1003DONE label3_val1b_val2b 

1004DONE label3_val1b_val2a 

1005DONE label3_val1c_val2b 

1006DONE label3_val1a_val2a 

1007DONE label3_val1a_val2b 

1008DONE wms_group_order1_val1a 

1009DONE label5_val1a_val2a 

1010DONE label5_val1a_val2b 

1011DONE wms_group_order1_val1c 

1012DONE label5_val1c_val2a 

1013DONE label5_val1c_val2b 

1014DONE wms_check_status_wms_group_order1_val1a 

1015DONE wms_check_status_wms_group_order1_val1c 

1016""" 

1017 

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

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

1020 self.assertEqual(results, truth) 

1021 

1022 

1023class ReadDagStatusTestCase(unittest.TestCase): 

1024 """Test read_dag_status function and read_single_dag_status.""" 

1025 

1026 def testFileMissing(self): 

1027 with temporaryDirectory() as tmp_dir: 

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

1029 _ = lssthtc.read_dag_status(tmp_dir) 

1030 

1031 def testRegular(self): 

1032 with temporaryDirectory() as tmp_dir: 

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

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

1035 results = lssthtc.read_dag_status(submit_dir) 

1036 truth = { 

1037 "JobProcsHeld": 0, 

1038 "NodesPost": 0, 

1039 "JobProcsIdle": 0, 

1040 "NodesTotal": 6, 

1041 "NodesFailed": 2, 

1042 "NodesDone": 3, 

1043 "NodesQueued": 0, 

1044 "NodesPre": 0, 

1045 "NodesFutile": 1, 

1046 "NodesUnready": 0, 

1047 } 

1048 self.assertEqual(results, results | truth) 

1049 

1050 def testSubdags(self): 

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

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

1053 """ 

1054 self.maxDiff = None 

1055 with temporaryDirectory() as tmp_dir: 

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

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

1058 results = lssthtc.read_dag_status(submit_dir) 

1059 truth = { 

1060 "JobProcsHeld": 0, 

1061 "NodesPost": 0, 

1062 "JobProcsIdle": 0, 

1063 "NodesTotal": 34, 

1064 "NodesFailed": 0, 

1065 "NodesDone": 17, 

1066 "NodesQueued": 3, 

1067 "NodesPre": 0, 

1068 "NodesFutile": 0, 

1069 "NodesUnready": 14, 

1070 } 

1071 self.assertEqual(results, results | truth) 

1072 

1073 

1074class ReadDagInfoTestCase(unittest.TestCase): 

1075 """Test read_dag_info function.""" 

1076 

1077 def testFileMissing(self): 

1078 with temporaryDirectory() as tmp_dir: 

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

1080 _ = lssthtc.read_dag_info(tmp_dir) 

1081 

1082 def testSuccess(self): 

1083 with temporaryDirectory() as tmp_dir: 

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

1085 results = lssthtc.read_dag_info(tmp_dir) 

1086 

1087 truth = { 

1088 "test02": { 

1089 "9208.0": { 

1090 "ClusterId": 9208, 

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

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

1093 "bps_project": "dev", 

1094 "bps_payload": "tiny", 

1095 "bps_operator": "testuser", 

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

1097 "bps_provisioning_job": "provisioningJob", 

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

1099 "bps_campaign": "quick", 

1100 "bps_runsite": "testpool", 

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

1102 "bps_run": "u_testuser_tiny_20250213T164427Z", 

1103 "bps_isjob": "True", 

1104 } 

1105 } 

1106 } 

1107 

1108 self.assertEqual(results, truth) 

1109 

1110 def testPermissionError(self): 

1111 with temporaryDirectory() as tmp_dir: 

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

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

1114 mocked_open.side_effect = PermissionError 

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

1116 results = lssthtc.read_dag_info(tmp_dir) 

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

1118 self.assertEqual({}, results) 

1119 

1120 

1121class HtcWriteCondorFileTestCase(unittest.TestCase): 

1122 """Test htc_write_condor_file function.""" 

1123 

1124 def testSuccess(self): 

1125 with temporaryDirectory() as tmp_dir: 

1126 job_name = "job1" 

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

1128 job = { 

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

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

1131 "request_memory": "2000", 

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

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

1134 } 

1135 job_attrs = { 

1136 "bps_job_name": job_name, 

1137 "bps_job_label": "label1", 

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

1139 } 

1140 expected = [ 

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

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

1143 "request_memory=2000\n", 

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

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

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

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

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

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

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

1151 "queue\n", 

1152 ] 

1153 

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

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

1156 actual = f.readlines() 

1157 

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

1159 self.assertTrue(filename.exists()) 

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

1161 _ = lssthtc.htc_create_submit_from_file(filename) 

1162 

1163 

1164class HtcCreateSubmitFromDagTestCase(unittest.TestCase): 

1165 """Test htc_create_submit_from_dag function.""" 

1166 

1167 @classmethod 

1168 def setUpClass(cls): 

1169 cls.bindir = None 

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

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

1172 cls.bindir = tempfile.TemporaryDirectory() 

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

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

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

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

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

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

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

1180 

1181 @classmethod 

1182 def tearDownClass(cls): 

1183 if cls.bindir: 

1184 cls.bindir.cleanup() 

1185 

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

1187 def testMaxIdleEnvVar(self): 

1188 with temporaryDirectory() as tmp_dir: 

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

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

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

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

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

1194 

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

1196 def testMaxIdleGiven(self): 

1197 with temporaryDirectory() as tmp_dir: 

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

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

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

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

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

1203 

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

1205 def testNoMaxJobsIdle(self): 

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

1207 HTCondor version when no MaxIdle passed to from_dag, not 

1208 checking arguments string here. Instead just making sure 

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

1210 """ 

1211 with temporaryDirectory() as tmp_dir: 

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

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

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

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

1216 mock_param.__contains__.return_value = False 

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

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

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

1220 

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

1222 def testDoRecurseGivenWithNoEnv(self): 

1223 with temporaryDirectory() as tmp_dir: 

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

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

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

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

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

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

1230 

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

1232 def testDoRecurseGivenWithEnv(self): 

1233 with temporaryDirectory() as tmp_dir: 

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

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

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

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

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

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

1240 

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

1242 def testGetEnvOverridden(self): 

1243 with temporaryDirectory() as tmp_dir: 

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

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

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

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

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

1249 

1250 

1251if __name__ == "__main__": 

1252 unittest.main()