blob: d0a1007565a5a773072185bd54e6524084bdafe0 [file] [log] [blame]
# Copyright 2017 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.
"""The context module provides APIs for manipulating a few pieces of 'ambient'
data that affect how steps are run.
The pieces of information which can be modified are:
* cwd - The current working directory.
* env - The environment variables.
* infra_step - Whether or not failures should be treated as infrastructure
failures vs. normal failures.
The values here are all scoped using Python's `with` statement; there's no
mechanism to make an open-ended adjustment to these values (i.e. there's no way
to change the cwd permanently for a recipe, except by surrounding the entire
recipe with a with statement). This is done to avoid the surprises that
typically arise with things like os.environ or os.chdir in a normal python
program.
Example:
```python
with api.context(cwd=api.path.start_dir.join('subdir')):
# this step is run inside of the subdir directory.
api.step("cat subdir/foo", ['cat', './foo'])
```
"""
import collections
import copy
from contextlib import contextmanager
from google.protobuf import json_format as jsonpb
from recipe_engine import recipe_api
from recipe_engine.config_types import Path
from recipe_engine.engine_types import PerGreenletState, freeze
from PB.go.chromium.org.luci.lucictx import sections as sections_pb2
def check_type(name, var, expect):
if not isinstance(var, expect): # pragma: no cover
raise TypeError('%s is not %s: %r (%s)' % (
name, expect.__name__, var, type(var).__name__))
class State(PerGreenletState):
# Default to immutable types to prevent these from accidentally becoming
# global variables.
cwd = None
env_prefixes = freeze({})
env_suffixes = freeze({})
env = freeze({})
infra_steps = False
luci_context = freeze({})
def _get_setter_on_spawn(self):
old_cwd = self.cwd
old_env_prefixes = self.env_prefixes
old_env_suffixes = self.env_suffixes
old_env = self.env
old_infra_steps = self.infra_steps
old_luci_context = self.luci_context
def _inner():
self.cwd = old_cwd
self.env_prefixes = old_env_prefixes
self.env_suffixes = old_env_suffixes
self.env = old_env
self.infra_steps = old_infra_steps
self.luci_context = old_luci_context
return _inner
class ContextApi(recipe_api.RecipeApi):
_lucictx_client = recipe_api.RequireClient('lucictx')
# TODO(iannucci): move implementation of these data directly into this class.
def __init__(self, **kwargs):
super(ContextApi, self).__init__(**kwargs)
self._state = State()
self._test_counter = 0
def initialize(self):
ctx = self._lucictx_client.initial_context
if ctx:
# Add other LUCI_CONTEXT sections in the following dict to support
# modification through this module.
init_sections = {
'deadline': sections_pb2.Deadline,
'luciexe': sections_pb2.LUCIExe,
'realm': sections_pb2.Realm,
'resultdb': sections_pb2.ResultDB,
}
# reset luci_context so that when we write into it without it becoming
# a global variable.
self._state.luci_context = {}
for section_key, section_msg_class in init_sections.items():
if section_key in ctx:
self._state.luci_context[section_key] = (
jsonpb.ParseDict(ctx[section_key],
section_msg_class(),
ignore_unknown_fields=True))
@contextmanager
def __call__(self, cwd=None, env_prefixes=None, env_suffixes=None, env=None,
infra_steps=None, luciexe=None, realm=None, deadline=None):
"""Allows adjustment of multiple context values in a single call.
Args:
* cwd (Path) - the current working directory to use for all steps.
To 'reset' to the original cwd at the time recipes started, pass
`api.path.start_dir`.
* env_prefixes (dict) - Environmental variable prefix augmentations. See
below for more info.
* env_suffixes (dict) - Environmental variable suffix augmentations. See
below for more info.
* env (dict) - Environmental variable overrides. See below for more info.
* infra_steps (bool) - if steps in this context should be considered
infrastructure steps. On failure, these will raise InfraFailure
exceptions instead of StepFailure exceptions.
* luciexe (sections_pb2.LUCIExe) - The override value for 'luciexe'
section in LUCI_CONTEXT. This is currently used to modify the
`cache_dir` for all launched LUCI Executable (via
`api.step.sub_build(...)`).
* realm (str) - allows changing the current LUCI realm. It is used when
creating new LUCI resources (e.g. spawning new Swarming tasks). Pass an
empty string to disassociate the context from a realm, emulating an
environment prior to LUCI realms. This is useful during the transitional
period.
* deadline (sections_pb2.Deadline) - Deadline information to set; See
LUCI_CONTEXT documentation for how this section works. Automatically
adjusted by steps with `timeout` set.
Environmental Variable Overrides:
Env is a mapping of environment variable name to the value you want that
environment variable to have. The value is one of:
* None, indicating that the environment variable should be removed from
the environment when the step runs.
* A string value. Note that string values will be %-formatted with the
current value of the environment at the time the step runs. This means
that you can have a value like:
"/path/to/my/stuff:%(PATH)s"
Which, at the time the step executes, will inject the current value of
$PATH.
"env_prefix" and "env_suffix" are a list of Path or strings that get
prefixed (or suffixed) to their respective environment variables, delimited
with the system's path separator. This can be used to add entries to
environment variables such as "PATH" and "PYTHONPATH". If prefixes are
specified and a value is also defined in "env", the value will be installed
as the last path component if it is not empty.
Look at the examples in "examples/" for examples of context module usage.
"""
# Mapping of state member to value to assign on exit of this function.
deferred_assignments = {}
def _push(state_member, new):
deferred_assignments[state_member] = _get_current(state_member)
setattr(self._state, state_member, new)
def _get_current(state_member):
return getattr(self._state, state_member)
def _add_to_context(state_member, to_add, adder_func):
if to_add is not None and to_add:
check_type(state_member, to_add, dict)
new = dict(_get_current(state_member))
for key, val in to_add.items():
adder_func(key, val, new)
_push(state_member, new)
def _as_env_prefixes(key, val, new):
if val:
new[key] = tuple(val) + new.get(key, ())
def _as_env_suffixes(key, val, new):
if val:
new[key] = new.get(key, ()) + tuple(val)
def _as_env(key, val, new):
if val is not None:
val = str(val)
try:
# This odd little piece of code does the following:
# * add a bogus dictionary format %(foo)s to val. This forces %
# into 'dictionary lookup' mode
# * format the result with a defaultdict. This allows all
# `%(key)s` format lookups to succeed, but any sequential `%s`
# lookups to fail.
# If the string contains any accidental sequential lookups, this
# will raise an exception. If not, then this is a plausible format
# string.
('%(foo)s' + val) % collections.defaultdict(str)
except Exception:
raise ValueError(('Invalid %%-formatting parameter in envvar, '
'only %%(ENVVAR)s allowed: %r') % (val,))
new[key] = val
def _override(key, val, new):
new[key] = val
try:
if cwd is not None:
check_type('cwd', cwd, Path)
_push('cwd', cwd)
if infra_steps is not None:
check_type('infra_steps', infra_steps, bool)
_push('infra_steps', infra_steps)
section_pb_values = {}
if luciexe:
check_type('luciexe', luciexe, sections_pb2.LUCIExe)
section_pb_values['luciexe'] = copy.deepcopy(luciexe)
if realm is not None:
section_pb_values['realm'] = (
sections_pb2.Realm(name=realm) if realm else None)
if deadline is not None:
check_type('deadline', deadline, sections_pb2.Deadline)
cur_deadline = self.deadline
if (cur_deadline.soft_deadline and
cur_deadline.soft_deadline < deadline.soft_deadline):
raise ValueError(
"Deadline.soft_deadline being increased: %f->%f" % (
cur_deadline.soft_deadline, deadline.soft_deadline))
if cur_deadline.grace_period < deadline.grace_period:
raise ValueError(
"Deadline.grace_period being increased: %f->%f" % (
cur_deadline.grace_period, deadline.grace_period))
section_pb_values['deadline'] = copy.deepcopy(deadline)
if section_pb_values:
_add_to_context('luci_context', section_pb_values, _override)
_add_to_context('env_prefixes', env_prefixes, _as_env_prefixes)
_add_to_context('env_suffixes', env_suffixes, _as_env_suffixes)
_add_to_context('env', env, _as_env)
yield
finally:
for state_member, val in deferred_assignments.items():
setattr(self._state, state_member, val)
@property
def cwd(self):
"""Returns the current working directory that steps will run in.
**Returns (Path|None)** - The current working directory. A value of None is
equivalent to api.path.start_dir, though only occurs if no cwd has been
set (e.g. in the outermost context of RunSteps).
"""
return self._state.cwd
@property
def env(self):
"""Returns modifications to the environment.
By default this is empty. If you want to observe the program's startup
environment, see `ENV_PROPERTIES` in
https://chromium.googlesource.com/infra/luci/recipes-py/+/refs/heads/main/doc/user_guide.md#properties-and-env_properties
**Returns (dict)** - The env-key -> value mapping of current environment
modifications.
"""
# TODO(iannucci): store env in an immutable way to avoid excessive copies.
# TODO(iannucci): handle case-insensitive keys on windows
return dict(self._state.env)
@property
def env_prefixes(self):
"""Returns Path prefix modifications to the environment.
This will return a mapping of environment key to Path tuple for Path
prefixes registered with the environment.
**Returns (dict)** - The env-key -> value(Path) mapping of current
environment prefix modifications.
"""
# TODO(iannucci): store env in an immutable way to avoid excessive copies.
# TODO(iannucci): handle case-insensitive keys on windows
return dict(self._state.env_prefixes)
@property
def env_suffixes(self):
"""Returns Path suffix modifications to the environment.
This will return a mapping of environment key to Path tuple for Path
suffixes registered with the environment.
**Returns (dict)** - The env-key -> value(Path) mapping of current
environment suffix modifications.
"""
# TODO(iannucci): store env in an immutable way to avoid excessive copies.
# TODO(iannucci): handle case-insensitive keys on windows
return dict(self._state.env_suffixes)
@property
def infra_step(self):
"""Returns the current value of the infra_step setting.
**Returns (bool)** - True iff steps are currently considered infra steps.
"""
return self._state.infra_steps
@property
def luci_context(self):
"""Returns the currently tracked LUCI_CONTEXT sections as a dict of proto
messages.
Only contains `luciexe`, `realm`, 'resultdb' and `deadline`.
"""
ret = {}
for section, msg in self._state.luci_context.items():
ret[section] = copy.deepcopy(msg)
return ret
@property
def luciexe(self):
"""Returns the current value (sections_pb2.LUCIExe) of luciexe section in
the current LUCI_CONTEXT. Returns None if luciexe is not defined."""
ret = None
if 'luciexe' in self._state.luci_context:
ret = sections_pb2.LUCIExe()
ret.CopyFrom(self._state.luci_context['luciexe'])
return ret
@property
def realm(self):
"""Returns the LUCI realm of the current context.
May return None if the task is not running in the realm-aware mode. This is
a transitional period. Eventually all tasks will be associated with realms.
"""
sec = self._state.luci_context.get('realm')
return sec.name if sec and sec.name else None
@property
def deadline(self):
"""Returns the current value (sections_pb2.Deadline) of deadline section in
the current LUCI_CONTEXT. Returns `{grace_period: 30}` if deadline is not
defined, per LUCI_CONTEXT spec."""
if 'deadline' in self._state.luci_context:
ret = sections_pb2.Deadline()
ret.CopyFrom(self._state.luci_context['deadline'])
return ret
return sections_pb2.Deadline(grace_period=30)
@property
def resultdb_invocation_name(self):
"""Returns the ResultDB invocation name of the current context.
Returns None if resultdb is not defined.
"""
resultdb = self._state.luci_context.get('resultdb')
return (resultdb.current_invocation.name if
resultdb and resultdb.current_invocation else '')