blob: 0fa6a873c68a901556bfa3714b5dd391454b5499 [file] [log] [blame]
# Copyright 2018 The LUCI Authors. All rights reserved.
# Use of this source code is governed under the Apache License, Version 2.0
# that can be found in the LICENSE file.
"""An interface to call the led tool."""
from builtins import range
from future.moves.urllib.parse import urlparse
from future.utils import iteritems
import hashlib
import attr
from recipe_engine import recipe_api
from recipe_engine import recipe_test_api
from PB.go.chromium.org.luci.led.job import job
class LedApi(recipe_api.RecipeApi):
"""Interface to the led tool.
"led" stands for LUCI editor. It allows users to debug and modify LUCI jobs.
It can be used to modify many aspects of a LUCI build, most commonly
including the recipes used.
The main interface this module provides is a direct call to the led binary:
led_result = api.led(
'get-builder', ['luci.chromium.try:chromium_presubmit'])
final_data = led_result.then('edit-recipe-bundle').result
See the led binary for full documentation of commands.
"""
@attr.s(frozen=True, slots=True)
class LedLaunchData(object):
swarming_hostname = attr.ib()
task_id = attr.ib()
@property
def swarming_task_url(self):
return 'https://%s/task?id=%s' % (self.swarming_hostname, self.task_id)
class LedResult(object):
"""Holds the result of a led operation. Can be chained using |then|."""
def __init__(self, result, module):
if isinstance(result, LedApi.LedLaunchData):
self._launch_result = result
self._result = result
self._module = None
else:
self._launch_result = None
self._result = result
self._module = module
@property
def result(self):
"""The mutable job.Definition proto message from the previous led call.
If the previous led call was `launch`, then this will be None, and
launch_result will be populated.
"""
return self._result
@property
def launch_result(self):
"""A LedLaunchData object. Only set when the previous led call was
'led launch'."""
return self._launch_result
@property
def edit_rbh_value(self):
"""Returns either the user_payload or cas_user_payload value suitable to
pass to `led edit -rbh`.
Returns `None` if this information is not set.
"""
r = self._result
if r:
if r.cas_user_payload.digest.hash:
return "%s/%d" % (r.cas_user_payload.digest.hash,
r.cas_user_payload.digest.size_bytes)
def then(self, *cmd):
"""Invoke led, passing it the current `result` data as input.
Returns another LedResult object with the output of the command.
"""
if self._module is None: # pragma: no cover
raise ValueError(
'Cannot call LedResult.then on the result of `led launch`')
return self.__class__(
self._module._run_command(self._result, *cmd), self._module)
def __init__(self, props, **kwargs):
super(LedApi, self).__init__(**kwargs)
self._run_id = props.led_run_id
if props.HasField('rbe_cas_input'):
self._rbe_cas_input = props.rbe_cas_input
else:
self._rbe_cas_input = None
if props.HasField('cipd_input'):
self._cipd_input = props.cipd_input
else:
self._cipd_input = None
def initialize(self):
if self._test_data.enabled:
self._get_mocks = {
key[len('get:'):]: value
for key, value in iteritems(self._test_data)
if key.startswith('get:')
}
self._mock_edits = self.test_api.standard_mock_functions()
sorted_edits = sorted([
(int(key[len('edit:'):]), value)
for key, value in iteritems(self._test_data)
if key.startswith('edit:')
])
self._mock_edits.extend(value for _, value in sorted_edits)
@property
def launched_by_led(self):
"""Whether the current build is a led job."""
return bool(self._run_id)
@property
def run_id(self):
"""A unique string identifier for this led job.
If the current build is *not* a led job, value will be an empty string.
"""
return self._run_id
@property
def rbe_cas_input(self):
"""The location of the rbe-cas containing the recipes code being run.
If set, it will be a `swarming.v1.CASReference` protobuf;
otherwise, None.
"""
return self._rbe_cas_input
@property
def cipd_input(self):
"""The versioned CIPD package containing the recipes code being run.
If set, it will be an `InputProperties.CIPDInput` protobuf; otherwise None.
"""
return self._cipd_input
def __call__(self, *cmd):
"""Runs led with the given arguments. Wraps result in a `LedResult`."""
return self.LedResult(self._run_command(None, *cmd), self)
def inject_input_recipes(self, led_result):
"""Sets the version of recipes used by led to correspond to the version
currently being used.
If neither the `rbe_cas_input` nor the `cipd_input` property is set,
this is a no-op.
Args:
* led_result: The `LedResult` whose job.Definition will be passed into the
edit command.
"""
if self.rbe_cas_input:
return led_result.then(
'edit',
'-rbh',
'%s/%s' % (
self.rbe_cas_input.digest.hash, self.rbe_cas_input.digest.size_bytes))
if self.cipd_input:
return led_result.then(
'edit',
'-rpkg', self.cipd_input.package,
'-rver', self.cipd_input.version)
# TODO(iannucci): Check for/inject buildbucket exe package/version
return led_result
def _get_mock(self, cmd):
"""Returns a StepTestData for the given command."""
job_def = None
def _pick_mock(prefix, specific_key):
# We do multiple lookups potentially, depending on what level of
# specificity the user has mocked with.
toks = specific_key.split('/')
for num_toks in range(len(toks), -1, -1):
key = '/'.join([prefix] + toks[:num_toks])
if key in self._get_mocks:
return self._get_mocks[key]
return job.Definition()
if cmd[0] == 'get-builder':
bucket, builder = cmd[-1].split(':', 1)
if bucket.startswith('luci.'):
project, bucket = bucket[len('luci.'):].split('.', 1)
else:
project, bucket = bucket.split('/', 1)
mocked = _pick_mock(
'buildbucket/builder',
'%s/%s/%s' % (project, bucket, builder))
if mocked is not None:
job_def = job.Definition()
job_def.CopyFrom(mocked)
job_def.buildbucket.bbagent_args.build.builder.project = project
job_def.buildbucket.bbagent_args.build.builder.bucket = bucket
job_def.buildbucket.bbagent_args.build.builder.builder = builder
elif cmd[0] == 'get-build':
build_id = str(cmd[-1]).lstrip('b')
mocked = _pick_mock('buildbucket/build', build_id)
if mocked is not None:
job_def = job.Definition()
job_def.CopyFrom(mocked)
job_def.buildbucket.bbagent_args.build.id = int(build_id)
elif cmd[0] == 'get-swarm':
task_id = cmd[-1]
mocked = _pick_mock('swarming/task', task_id)
if mocked is not None:
job_def = job.Definition()
job_def.CopyFrom(mocked)
job_def.swarming.task.task_id = task_id
if job_def is not None:
return self.test_api.m.proto.output_stream(job_def)
ret = recipe_test_api.StepTestData()
ret.retcode = 1
return ret
def _run_command(self, previous, *cmd):
"""Runs led with a given command and arguments.
Args:
* cmd: The led command to run, e.g. 'get-builder', 'edit', along with any
arguments.
* previous: The previous led step's json result, if any. This can be
used to chain led commands together. See the tests for an example of
this.
Ensures that led is checked out on disk before trying to execute the
command.
Returns either a job.Definition or a LedLaunchData.
"""
is_launch = cmd[0] == 'launch'
if is_launch:
kwargs = {
'stdout': self.m.json.output(),
}
if self._test_data.enabled:
# To allow easier test mocking with e.g. the swarming.collect step, we
# take the task_id as build.infra.swarming.task_id, if it's set, and
# otherwise use a fixed string.
#
# We considered hashing the payload to derived the task id, but some
# recipes re-launch the same led task multiple times. In that case they
# usually need to manually provide the task id anyway.
task_id = previous.buildbucket.bbagent_args.build.infra.swarming.task_id
if not task_id:
task_id = 'fake-task-id'
kwargs['step_test_data'] = lambda: self.test_api.m.json.output_stream({
'swarming': {
'host_name': urlparse(self.m.swarming.current_server).netloc,
'task_id': task_id,
}
})
else:
kwargs = {
'stdout': self.m.proto.output(job.Definition, 'JSONPB'),
}
if self._test_data.enabled:
if cmd[0].startswith('get-'):
kwargs['step_test_data'] = lambda: self._get_mock(cmd)
else:
# We run this outside of the step_test_data callback to make the stack
# trace a bit more obvious.
build = self.test_api._transform_build(
previous, cmd, self._mock_edits,
str(self.m.context.cwd or self.m.path['start_dir']))
kwargs['step_test_data'] = (
lambda: self.test_api.m.proto.output_stream(build))
if previous is not None:
kwargs['stdin'] = self.m.proto.input(previous, 'JSONPB')
result = self.m.step(
'led %s' % cmd[0], ['led'] + list(cmd), **kwargs)
if is_launch:
# If we launched a task, add a link to the swarming task.
retval = self.LedLaunchData(
swarming_hostname=result.stdout['swarming']['host_name'],
task_id=result.stdout['swarming']['task_id'])
result.presentation.links['Swarming task'] = retval.swarming_task_url
else:
retval = result.stdout
return retval