Skip to content

Commit a709a00

Browse files
Introduce CustomShellCommand with failure handlers
Motivation: MDBF-916: Buildbot's MTR step is deprecated in newer releases. Folding related tasks, such as saving logs and publishing MTR results to CrossReference, into one step reduces UI clutter. Description: Replace ShellCommandWithURL while retaining rendered artifact URLs. Run named commands after a primary failure without masking it, while propagating exceptions, retries, and cancellations. Support Buildbot 2.7 and 4.x.
1 parent 86acd60 commit a709a00

2 files changed

Lines changed: 213 additions & 24 deletions

File tree

configuration/steps/commands/base.py

Lines changed: 211 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
from twisted.internet import defer
66

77
from buildbot.plugins import steps, util
8+
from buildbot.process import remotecommand
89
from buildbot.process.properties import Interpolate
10+
from buildbot.process.results import CANCELLED, EXCEPTION, FAILURE, RETRY
11+
from buildbot.util import flatten
912

1013
# Use if you need to load script files to commands
1114
COMMAND_SCRIPT_BASE_DIR = Path(__file__).parent / "scripts"
@@ -117,28 +120,214 @@ def _url_text(self) -> Interpolate:
117120
)
118121

119122

120-
class ShellCommandWithURL(steps.ShellCommand):
121-
"""
122-
This class extend's Buildbot's base ShellCommand, to allow rendering
123-
an additional url in the interface.
124-
The URL can point to relevant artifacts for developers to use.
125-
"""
123+
_TERMINAL_RESULTS = (EXCEPTION, RETRY, CANCELLED)
124+
125+
126+
def _iterFailureCommands(specifications):
127+
"""Validate and yield named failure commands."""
128+
129+
used_names = set()
130+
131+
for specification in specifications:
132+
if not isinstance(specification, dict):
133+
raise TypeError("each commandsOnFailure entry must be a dictionary")
134+
135+
if "name" not in specification:
136+
raise TypeError("each commandsOnFailure entry requires 'name'")
137+
138+
if "command" not in specification:
139+
raise TypeError("each commandsOnFailure entry requires 'command'")
140+
141+
name = specification["name"]
142+
command = specification["command"]
143+
144+
if not isinstance(name, str) or not name:
145+
raise TypeError("failure command name must be a non-empty string")
146+
147+
if name in used_names:
148+
raise ValueError("duplicate failure command name: {!r}".format(name))
149+
150+
used_names.add(name)
151+
152+
yield {
153+
"name": name,
154+
"logName": "stdio {}".format(name),
155+
"command": command,
156+
}
157+
126158

127-
# Add URL and URL text to the renderables list (use with Interpolate)
128-
renderables = ["url", "urlText"]
159+
class _CustomShellCommandBase(steps.ShellCommand):
160+
"""Common configuration for the Buildbot 2.7 and 4.x variants."""
161+
162+
# Parent renderables are accumulated by Buildbot, so only the new
163+
# attributes need to be listed here.
164+
renderables = [
165+
"url",
166+
"urlText",
167+
"commandsOnFailure",
168+
]
169+
170+
def __init__(self, url=None, commandsOnFailure=None, **kwargs):
171+
if url is not None and not isinstance(url, URL):
172+
raise TypeError("url must be a URL instance or None")
129173

130-
def __init__(self, url: URL = None, **kwargs):
131174
super().__init__(**kwargs)
132-
# Need to set the url and urlText so they can be rendered
133-
self.url = url._url if isinstance(url, URL) else None
134-
self.urlText = url._url_text if isinstance(url, URL) else None
135-
136-
# FIXME Replace start() with run() when upgrading to Buildbot 4.x
137-
@defer.inlineCallbacks
138-
def start(self):
139-
if self.url is not None:
140-
yield self.addURL(self.urlText, self.url)
141-
142-
# Return to the original method
143-
res = yield super().start()
144-
return res
175+
176+
if url is None:
177+
self.url = None
178+
self.urlText = None
179+
else:
180+
self.url = url._url
181+
self.urlText = url._url_text
182+
183+
if commandsOnFailure is None:
184+
self.commandsOnFailure = []
185+
else:
186+
self.commandsOnFailure = commandsOnFailure
187+
188+
189+
if hasattr(steps.ShellCommand, "makeRemoteShellCommand"):
190+
# Buildbot 4.x:
191+
# ShellCommand inherits ShellMixin and implements run().
192+
193+
class CustomShellCommand(_CustomShellCommandBase):
194+
195+
@defer.inlineCallbacks
196+
def run(self):
197+
if self.url is not None:
198+
yield self.addURL(self.urlText, self.url)
199+
200+
# Run the primary command using the normal ShellCommand logic.
201+
primary_result = yield super().run()
202+
203+
# Run failure commands only for a genuine FAILURE.
204+
if primary_result != FAILURE:
205+
return primary_result
206+
207+
final_result = primary_result
208+
209+
# makeRemoteShellCommand changes self.command. It also uses
210+
# self.logfiles while setting up logs, even if logfiles={} is
211+
# supplied as an override.
212+
primary_command = self.command
213+
primary_logfiles = self.logfiles
214+
215+
try:
216+
# The primary command's watched files must not be attached
217+
# again to every failure command.
218+
self.logfiles = {}
219+
220+
for specification in _iterFailureCommands(self.commandsOnFailure):
221+
failure_command = yield self.makeRemoteShellCommand(
222+
command=specification["command"],
223+
stdioLogName=specification["logName"],
224+
logfiles={},
225+
)
226+
227+
yield self.runCommand(failure_command)
228+
229+
handler_result = failure_command.results()
230+
231+
# An ordinary nonzero exit is ignored, and processing
232+
# continues with the next failure command. Infrastructure
233+
# errors and cancellation stop the sequence.
234+
if handler_result in _TERMINAL_RESULTS:
235+
final_result = handler_result
236+
break
237+
finally:
238+
self.command = primary_command
239+
self.logfiles = primary_logfiles
240+
241+
return final_result
242+
243+
else:
244+
# Buildbot 2.7:
245+
# ShellCommand is legacy-style and implements start().
246+
247+
class CustomShellCommand(_CustomShellCommandBase):
248+
249+
@defer.inlineCallbacks
250+
def start(self):
251+
if self.url is not None:
252+
yield self.addURL(self.urlText, self.url)
253+
254+
# Construct the primary command using Buildbot 2.7's
255+
# ShellCommand implementation.
256+
warnings = []
257+
kwargs = self.buildCommandKwargs(warnings)
258+
259+
primary_command = remotecommand.RemoteShellCommand(**kwargs)
260+
self.setupEnvironment(primary_command)
261+
262+
self.stdio_log = primary_log = self.addLog("stdio")
263+
primary_command.useLog(
264+
primary_log,
265+
closeWhenFinished=True,
266+
)
267+
268+
for warning in warnings:
269+
primary_log.addHeader(warning)
270+
271+
self.setupLogfiles(primary_command, self.logfiles)
272+
273+
yield self.runCommand(primary_command)
274+
275+
yield defer.maybeDeferred(
276+
self.commandComplete,
277+
primary_command,
278+
)
279+
280+
yield defer.maybeDeferred(
281+
self.createSummary,
282+
primary_command.logs["stdio"],
283+
)
284+
285+
primary_result = yield defer.maybeDeferred(
286+
self.evaluateCommand,
287+
primary_command,
288+
)
289+
290+
final_result = primary_result
291+
292+
# Do not run for EXCEPTION, RETRY, CANCELLED, WARNINGS,
293+
# SUCCESS, or SKIPPED.
294+
if primary_result == FAILURE:
295+
for specification in _iterFailureCommands(self.commandsOnFailure):
296+
log_name = specification["logName"]
297+
298+
warnings = []
299+
kwargs = self.buildCommandKwargs(warnings)
300+
kwargs["command"] = flatten(
301+
specification["command"],
302+
(list, tuple),
303+
)
304+
305+
# Do not attach the primary command's watched files.
306+
kwargs["logfiles"] = {}
307+
308+
# The remote stdio name must match the Buildbot log name.
309+
kwargs["stdioLogName"] = log_name
310+
311+
failure_command = remotecommand.RemoteShellCommand(**kwargs)
312+
self.setupEnvironment(failure_command)
313+
314+
failure_log = self.addLog(log_name)
315+
failure_command.useLog(
316+
failure_log,
317+
closeWhenFinished=True,
318+
logfileName=log_name,
319+
)
320+
321+
for warning in warnings:
322+
failure_log.addHeader(warning)
323+
324+
yield self.runCommand(failure_command)
325+
326+
handler_result = failure_command.results()
327+
328+
if handler_result in _TERMINAL_RESULTS:
329+
final_result = handler_result
330+
break
331+
332+
yield self.setStatus(primary_command, final_result)
333+
return final_result

configuration/steps/remote.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from buildbot.plugins import steps, util
33
from buildbot.process.results import SUCCESS, WARNINGS
44
from configuration.steps.base import BaseStep, StepOptions
5-
from configuration.steps.commands.base import URL, Command, ShellCommandWithURL
5+
from configuration.steps.commands.base import URL, Command, CustomShellCommand
66

77

88
class ShellStep(BaseStep):
@@ -51,7 +51,7 @@ def __init__(
5151

5252
def generate(self) -> IBuildStep:
5353
workdir = self._set_workdir()
54-
return ShellCommandWithURL(
54+
return CustomShellCommand(
5555
name=self.name,
5656
command=[*self.prefix_cmd, *self.command.as_cmd_arg()],
5757
interruptSignal=self.interrupt_signal,

0 commit comments

Comments
 (0)