| # 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. |
| |
| """API for interacting with the LUCI Scheduler service. |
| |
| Depends on 'prpc' binary available in $PATH: |
| https://godoc.org/go.chromium.org/luci/grpc/cmd/prpc |
| Documentation for scheduler API is in |
| https://chromium.googlesource.com/infra/luci/luci-go/+/master/scheduler/api/scheduler/v1/scheduler.proto |
| RPCExplorer available at |
| https://luci-scheduler.appspot.com/rpcexplorer/services/scheduler.Scheduler |
| """ |
| |
| import copy |
| import uuid |
| |
| from google.protobuf import json_format |
| |
| from recipe_engine import recipe_api |
| |
| from PB.go.chromium.org.luci.scheduler.api.scheduler.v1 import ( |
| triggers as triggers_pb2) |
| |
| |
| class SchedulerApi(recipe_api.RecipeApi): |
| """A module for interacting with LUCI Scheduler service.""" |
| |
| def __init__(self, init_state, **kwargs): |
| super(SchedulerApi, self).__init__(**kwargs) |
| self._host = init_state.get('hostname') or 'luci-scheduler.appspot.com' |
| self._fake_uuid_count = 0 |
| |
| self._triggers = [] |
| for t_dict in init_state.get('triggers') or []: |
| self._triggers.append( |
| json_format.ParseDict(t_dict, triggers_pb2.Trigger())) |
| |
| @property |
| def triggers(self): |
| """Returns a list of triggers that triggered the current build. |
| |
| A trigger is an instance of triggers_pb2.Trigger. |
| """ |
| return copy.copy(self._triggers) |
| |
| @property |
| def host(self): |
| """Returns the backend hostname used by this module.""" |
| return self._host |
| |
| def set_host(self, host): |
| """Changes the backend hostname used by this module. |
| |
| Args: |
| host (str): server host (e.g. 'luci-scheduler.appspot.com'). |
| """ |
| self._host = host |
| |
| |
| # TODO(tandrii): remove in favor of triggers_pb2 types |
| class Trigger(object): |
| """Generic Trigger accepted by LUCI Scheduler API. |
| |
| All supported triggers are documented here: |
| https://chromium.googlesource.com/infra/luci/luci-go/+/master/scheduler/api/scheduler/v1/triggers.proto |
| """ |
| def __init__(self, id=None, title=None, url=None, payload=None): |
| self._id = id |
| self._title = title |
| self._url = url |
| self._payload = payload |
| |
| def _serialize(self, api_self): |
| t = {} |
| t['id'] = self._id or api_self._next_uuid() |
| t['title'] = self._title or ('%s/%s' % ( |
| api_self.m.buildbucket.builder_name, |
| api_self.m.buildbucket.build.number)) |
| # TODO(tandrii): find a way to get URL of current build. |
| if self._url: |
| t['url'] = self._url |
| t.update(self._serialize_payload(api_self)) |
| return t |
| |
| def _serialize_payload(self, api_self): |
| return self._payload |
| |
| |
| class BuildbucketTrigger(Trigger): |
| """Trigger with buildbucket payload for buildbucket jobs. |
| |
| Args: |
| * properties (dict, optional): key -> value properties. |
| * tags (dict, optional): custom tags to add. See also `inherit_tags`. |
| If tag's value is None, this tag will be removed from resulting tags, |
| however if you rely on this, consider using `inherit_tags=False` |
| instead. |
| * inherit_tags (bool): if true (default), auto-adds tags using |
| `api.buildbucket.tags_for_child_build` api. |
| """ |
| def __init__(self, properties=None, tags=None, inherit_tags=True, **kwargs): |
| super(SchedulerApi.BuildbucketTrigger, self).__init__(**kwargs) |
| self._properties = properties |
| self._tags = tags |
| self._inherit_tags = inherit_tags |
| |
| def _serialize_payload(self, api_self): |
| tags = {} |
| if self._inherit_tags: |
| tags = api_self.m.buildbucket.tags_for_child_build.copy() |
| if self._tags: |
| tags.update(self._tags) |
| return {'buildbucket': { |
| 'properties': self._properties or {}, |
| 'tags': map(':'.join, sorted( |
| (k, v) for k, v in tags.iteritems() if v is not None)), |
| }} |
| |
| |
| class GitilesTrigger(Trigger): |
| """Trigger with new Gitiles commit payload, typically for buildbucket jobs. |
| |
| Args: |
| repo (str): URL of a repo that changed. |
| ref (str): a ref that changed, in full, e.g. "refs/heads/master". |
| revision (str): a revision (SHA1 in hex) pointed to by the ref. |
| """ |
| def __init__(self, repo, ref, revision, **kwargs): |
| kwargs['payload'] = {'gitiles': { |
| 'repo': repo, |
| 'ref': ref, |
| 'revision': revision, |
| }} |
| super(SchedulerApi.GitilesTrigger, self).__init__(**kwargs) |
| |
| |
| def emit_trigger(self, trigger, project, jobs, step_name=None): |
| """Emits trigger to one or more jobs of a given project. |
| |
| Args: |
| trigger (Trigger): defines payload to trigger jobs with. |
| project (str): name of the project in LUCI Config service, which is used |
| by LUCI Scheduler instance. See https://luci-config.appspot.com/. |
| jobs (iterable of str): job names per LUCI Scheduler config for the given |
| project. These typically are the same as builder names. |
| """ |
| return self.emit_triggers([(trigger, project, jobs)], step_name=step_name) |
| |
| def emit_triggers( |
| self, trigger_project_jobs, timestamp_usec=None, step_name=None): |
| """Emits a batch of triggers spanning one or more projects. |
| |
| Up to date documentation is at |
| https://chromium.googlesource.com/infra/luci/luci-go/+/master/scheduler/api/scheduler/v1/scheduler.proto |
| |
| Args: |
| trigger_project_jobs (iterable of tuples(trigger, project, jobs)): |
| each tuple corresponds to parameters of `emit_trigger` API above. |
| timestamp_usec (int): unix timestamp in microseconds. |
| Useful for idempotency of calls if your recipe is doing its own retries. |
| https://chromium.googlesource.com/infra/luci/luci-go/+/master/scheduler/api/scheduler/v1/triggers.proto |
| """ |
| req = { |
| 'batches': [ |
| { |
| 'trigger': trigger._serialize(self), |
| 'jobs': [{'project': project, 'job': job} for job in jobs], |
| } |
| for trigger, project, jobs in trigger_project_jobs |
| ], |
| } |
| if timestamp_usec: |
| assert isinstance(timestamp_usec, int), timestamp_usec |
| else: |
| timestamp_usec = int(self.m.time.time() * 1e6) |
| req['timestamp'] = timestamp_usec |
| |
| # There is no output from EmitTriggers API. |
| self._run( |
| 'EmitTriggers', req, step_name=step_name, |
| step_test_data=lambda: self.m.json.test_api.output_stream({})) |
| |
| def _run(self, method, input_data, step_test_data=None, step_name=None): |
| assert self.m.runtime.is_luci, 'scheduler module only works on LUCI stack' |
| # TODO(tandrii): encapsulate running prpc command in a standalone module. |
| step_name = step_name or ('luci-scheduler.' + method) |
| args = ['prpc', 'call', '-format=json', self._host, |
| 'scheduler.Scheduler.' + method] |
| step_result = None |
| try: |
| step_result = self.m.step( |
| step_name, |
| args, |
| stdin=self.m.json.input(input_data), |
| stdout=self.m.json.output(add_json_log='on_failure'), |
| infra_step=True, |
| step_test_data=step_test_data) |
| # TODO(tandrii): add hostname to step presentation's links. |
| # TODO(tandrii): handle errors nicely. |
| finally: |
| self.m.step.active_result.presentation.logs['input'] = self.m.json.dumps( |
| input_data, indent=4).splitlines() |
| |
| return step_result.stdout |
| |
| def _next_uuid(self): |
| if self._test_data.enabled: |
| self._fake_uuid_count += 1 |
| return '6a0a73b0-070b-492b-9135-9f26a2a' + '%05d' % ( |
| self._fake_uuid_count,) |
| else: # pragma: no cover |
| return str(uuid.uuid4()) |