Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

73

74

75

76

77

78

79

80

81

82

83

84

85

86

87

88

89

90

91

92

93

94

95

96

97

98

99

100

101

102

103

104

105

106

107

108

109

110

111

112

113

114

115

116

117

118

119

120

121

122

123

124

125

126

127

128

129

130

131

132

133

134

135

136

137

138

139

140

141

142

143

144

145

146

147

148

149

150

151

152

153

154

155

156

157

158

159

160

161

162

163

164

165

166

167

168

169

170

171

172

173

174

175

176

177

178

179

180

181

182

183

184

185

186

187

188

189

190

191

192

193

194

195

196

197

198

199

200

201

202

203

204

205

206

207

208

209

210

211

212

213

214

215

216

217

218

219

220

221

222

223

224

225

226

227

228

229

230

231

232

233

234

235

236

237

238

239

240

241

242

243

244

245

246

247

248

249

250

251

252

253

254

255

256

257

258

259

260

261

262

263

264

265

266

267

268

269

270

271

272

273

274

275

276

277

278

279

280

281

282

283

284

285

286

287

288

289

290

291

292

293

294

295

296

297

298

299

300

301

302

303

304

305

306

307

308

309

310

311

312

313

314

315

316

317

318

319

320

321

322

323

# (c) 2016 Matt Clay <matt@mystile.com> 

# (c) 2017 Ansible Project 

# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 

 

from __future__ import (absolute_import, division, print_function) 

__metaclass__ = type 

 

DOCUMENTATION = ''' 

callback: junit 

type: aggregate 

short_description: write playbook output to a JUnit file. 

version_added: historical 

description: 

- This callback writes playbook output to a JUnit formatted XML file. 

- "Tasks show up in the report as follows: 

'ok': pass 

'failed' with 'EXPECTED FAILURE' in the task name: pass 

'failed' with 'TOGGLE RESULT' in the task name: pass 

'ok' with 'TOGGLE RESULT' in the task name: failure 

'failed' due to an exception: error 

'failed' for other reasons: failure 

'skipped': skipped" 

options: 

output_dir: 

name: JUnit output dir 

default: ~/.ansible.log 

description: Directory to write XML files to. 

env: 

- name: JUNIT_OUTPUT_DIR 

task_class: 

name: JUnit Task class 

default: False 

description: Configure the output to be one class per yaml file 

env: 

- name: JUNIT_TASK_CLASS 

fail_on_change: 

name: JUnit fail on change 

default: False 

description: Consider any tasks reporting "changed" as a junit test failure 

env: 

- name: JUNIT_FAIL_ON_CHANGE 

fail_on_ignore: 

name: JUnit fail on ignore 

default: False 

description: Consider failed tasks as a junit test failure even if ignore_on_error is set 

env: 

- name: JUNIT_FAIL_ON_IGNORE 

requirements: 

- whitelist in configuration 

- junit_xml (python lib) 

''' 

 

import os 

import time 

import re 

 

from ansible.module_utils._text import to_bytes, to_text 

from ansible.plugins.callback import CallbackBase 

 

try: 

from junit_xml import TestSuite, TestCase 

HAS_JUNIT_XML = True 

except ImportError: 

HAS_JUNIT_XML = False 

 

try: 

from collections import OrderedDict 

HAS_ORDERED_DICT = True 

except ImportError: 

try: 

from ordereddict import OrderedDict 

HAS_ORDERED_DICT = True 

except ImportError: 

HAS_ORDERED_DICT = False 

 

 

class CallbackModule(CallbackBase): 

""" 

This callback writes playbook output to a JUnit formatted XML file. 

 

Tasks show up in the report as follows: 

'ok': pass 

'failed' with 'EXPECTED FAILURE' in the task name: pass 

'failed' with 'TOGGLE RESULT' in the task name: pass 

'ok' with 'TOGGLE RESULT' in the task name: failure 

'failed' due to an exception: error 

'failed' for other reasons: failure 

'skipped': skipped 

 

This plugin makes use of the following environment variables: 

JUNIT_OUTPUT_DIR (optional): Directory to write XML files to. 

Default: ~/.ansible.log 

JUNIT_TASK_CLASS (optional): Configure the output to be one class per yaml file 

Default: False 

JUNIT_FAIL_ON_CHANGE (optional): Consider any tasks reporting "changed" as a junit test failure 

Default: False 

JUNIT_FAIL_ON_IGNORE (optional): Consider failed tasks as a junit test failure even if ignore_on_error is set 

Default: False 

 

Requires: 

junit_xml 

 

""" 

 

CALLBACK_VERSION = 2.0 

CALLBACK_TYPE = 'aggregate' 

CALLBACK_NAME = 'junit' 

CALLBACK_NEEDS_WHITELIST = True 

 

def __init__(self): 

super(CallbackModule, self).__init__() 

 

self._output_dir = os.getenv('JUNIT_OUTPUT_DIR', os.path.expanduser('~/.ansible.log')) 

self._task_class = os.getenv('JUNIT_TASK_CLASS', 'False').lower() 

self._fail_on_change = os.getenv('JUNIT_FAIL_ON_CHANGE', 'False').lower() 

self._fail_on_ignore = os.getenv('JUNIT_FAIL_ON_IGNORE', 'False').lower() 

self._playbook_path = None 

self._playbook_name = None 

self._play_name = None 

self._task_data = None 

 

self.disabled = False 

 

124 ↛ 129line 124 didn't jump to line 129, because the condition on line 124 was never false if not HAS_JUNIT_XML: 

self.disabled = True 

self._display.warning('The `junit_xml` python module is not installed. ' 

'Disabling the `junit` callback plugin.') 

 

129 ↛ 132line 129 didn't jump to line 132, because the condition on line 129 was never false if HAS_ORDERED_DICT: 

self._task_data = OrderedDict() 

else: 

self.disabled = True 

self._display.warning('The `ordereddict` python module is not installed. ' 

'Disabling the `junit` callback plugin.') 

 

136 ↛ 137line 136 didn't jump to line 137, because the condition on line 136 was never true if not os.path.exists(self._output_dir): 

os.mkdir(self._output_dir) 

 

def _start_task(self, task): 

""" record the start of a task for one or more hosts """ 

 

uuid = task._uuid 

 

if uuid in self._task_data: 

return 

 

play = self._play_name 

name = task.get_name().strip() 

path = task.get_path() 

 

if not task.no_log: 

args = ', '.join(('%s=%s' % a for a in task.args.items())) 

if args: 

name += ' ' + args 

 

self._task_data[uuid] = TaskData(uuid, name, path, play) 

 

def _finish_task(self, status, result): 

""" record the results of a task for a single host """ 

 

task_uuid = result._task._uuid 

 

if hasattr(result, '_host'): 

host_uuid = result._host._uuid 

host_name = result._host.name 

else: 

host_uuid = 'include' 

host_name = 'include' 

 

task_data = self._task_data[task_uuid] 

 

if self._fail_on_change == 'true' and status == 'ok' and result._result.get('changed', False): 

status = 'failed' 

 

# ignore failure if expected and toggle result if asked for 

if status == 'failed' and 'EXPECTED FAILURE' in task_data.name: 

status = 'ok' 

elif 'TOGGLE RESULT' in task_data.name: 

if status == 'failed': 

status = 'ok' 

elif status == 'ok': 

status = 'failed' 

 

task_data.add_host(HostData(host_uuid, host_name, status, result)) 

 

def _build_test_case(self, task_data, host_data): 

""" build a TestCase from the given TaskData and HostData """ 

 

name = '[%s] %s: %s' % (host_data.name, task_data.play, task_data.name) 

duration = host_data.finish - task_data.start 

 

if self._task_class == 'true': 

junit_classname = re.sub(r'\.yml:[0-9]+$', '', task_data.path) 

else: 

junit_classname = task_data.path 

 

if host_data.status == 'included': 

return TestCase(name, junit_classname, duration, host_data.result) 

 

res = host_data.result._result 

rc = res.get('rc', 0) 

dump = self._dump_results(res, indent=0) 

dump = self._cleanse_string(dump) 

 

if host_data.status == 'ok': 

return TestCase(name, junit_classname, duration, dump) 

 

test_case = TestCase(name, junit_classname, duration) 

 

if host_data.status == 'failed': 

if 'exception' in res: 

message = res['exception'].strip().split('\n')[-1] 

output = res['exception'] 

test_case.add_error_info(message, output) 

elif 'msg' in res: 

message = res['msg'] 

test_case.add_failure_info(message, dump) 

else: 

test_case.add_failure_info('rc=%s' % rc, dump) 

elif host_data.status == 'skipped': 

if 'skip_reason' in res: 

message = res['skip_reason'] 

else: 

message = 'skipped' 

test_case.add_skipped_info(message) 

 

return test_case 

 

def _cleanse_string(self, value): 

""" convert surrogate escapes to the unicode replacement character to avoid XML encoding errors """ 

return to_text(to_bytes(value, errors='surrogateescape'), errors='replace') 

 

def _generate_report(self): 

""" generate a TestSuite report from the collected TaskData and HostData """ 

 

test_cases = [] 

 

for task_uuid, task_data in self._task_data.items(): 

for host_uuid, host_data in task_data.host_data.items(): 

test_cases.append(self._build_test_case(task_data, host_data)) 

 

test_suite = TestSuite(self._playbook_name, test_cases) 

report = TestSuite.to_xml_string([test_suite]) 

 

output_file = os.path.join(self._output_dir, '%s-%s.xml' % (self._playbook_name, time.time())) 

 

with open(output_file, 'wb') as xml: 

xml.write(to_bytes(report, errors='surrogate_or_strict')) 

 

def v2_playbook_on_start(self, playbook): 

self._playbook_path = playbook._file_name 

self._playbook_name = os.path.splitext(os.path.basename(self._playbook_path))[0] 

 

def v2_playbook_on_play_start(self, play): 

self._play_name = play.get_name() 

 

def v2_runner_on_no_hosts(self, task): 

self._start_task(task) 

 

def v2_playbook_on_task_start(self, task, is_conditional): 

self._start_task(task) 

 

def v2_playbook_on_cleanup_task_start(self, task): 

self._start_task(task) 

 

def v2_playbook_on_handler_task_start(self, task): 

self._start_task(task) 

 

def v2_runner_on_failed(self, result, ignore_errors=False): 

if ignore_errors and self._fail_on_ignore != 'true': 

self._finish_task('ok', result) 

else: 

self._finish_task('failed', result) 

 

def v2_runner_on_ok(self, result): 

self._finish_task('ok', result) 

 

def v2_runner_on_skipped(self, result): 

self._finish_task('skipped', result) 

 

def v2_playbook_on_include(self, included_file): 

self._finish_task('included', included_file) 

 

def v2_playbook_on_stats(self, stats): 

self._generate_report() 

 

 

class TaskData: 

""" 

Data about an individual task. 

""" 

 

def __init__(self, uuid, name, path, play): 

self.uuid = uuid 

self.name = name 

self.path = path 

self.play = play 

self.start = None 

self.host_data = OrderedDict() 

self.start = time.time() 

 

def add_host(self, host): 

if host.uuid in self.host_data: 

if host.status == 'included': 

# concatenate task include output from multiple items 

host.result = '%s\n%s' % (self.host_data[host.uuid].result, host.result) 

else: 

raise Exception('%s: %s: %s: duplicate host callback: %s' % (self.path, self.play, self.name, host.name)) 

 

self.host_data[host.uuid] = host 

 

 

class HostData: 

""" 

Data about an individual host. 

""" 

 

def __init__(self, uuid, name, status, result): 

self.uuid = uuid 

self.name = name 

self.status = status 

self.result = result 

self.finish = time.time()