blob: b1f23317f2451ae4eef24a8b078e91bc238a2657 [file] [log] [blame]
# Copyright 2019 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.
"""Defines classes which the `step` module uses to describe steps-to-run to the
RecipeEngine.
# TODO(iannucci): Simplify this.
"""
from builtins import int
from past.builtins import basestring
import datetime
import attr
from google.protobuf import json_format as jsonpb
from google.protobuf.message import Message
from .attr_util import attr_type, attr_dict_type, attr_seq_type, attr_value_is
from ..engine_types import FrozenDict, freeze, thaw, ResourceCost
from ..util import InputPlaceholder, OutputPlaceholder, Placeholder, sentinel
@attr.s(frozen=True)
class EnvAffix:
"""Expresses a mapping of environment keys to a list of paths.
This is used as StepConfig's "env_prefixes" and "env_suffixes" value.
"""
mapping = attr.ib(factory=dict,
validator=attr_dict_type(str, str, value_seq=True))
pathsep = attr.ib(default=None, validator=attr_type((str, type(None))))
def __attrs_post_init__(self):
object.__setattr__(self, 'mapping', freeze(self.mapping))
def _file_placeholder(base_placeholder_type):
"""Returns a attr validator for StepConfig.stdin/stdout/stderr."""
return [
attr_type((str, base_placeholder_type, type(None))),
attr_value_is(
'backed by a file',
lambda value: (
value is None or isinstance(value, str) or
value.backing_file is not Placeholder.backing_file
)
)
]
@attr.s
class StepConfig:
"""StepConfig is the representation of a raw step as the recipe_engine sees
it. You should use the standard 'step' recipe module, which will construct
and pass this data to the engine for you, instead. The only reason why you
would need to worry about this object is if you're modifying the step module
itself.
"""
# The name of the step to run within the current namespace.
#
# This will be deduplicated by the recipe engine.
name = attr.ib(validator=attr_type(str))
# List of args of the command to run. Acceptable types: Placeholder or any
# str()'able type.
cmd = attr.ib(
default=(),
converter=(lambda value: [
itm if isinstance(itm, Placeholder) else str(itm)
for itm in value
]),
validator=attr_seq_type((str, Placeholder)))
# Absolute path to working directory for the command.
cwd = attr.ib(
default=None,
validator=attr_type((str, type(None))))
# Step resource cost.
cost = attr.ib(default=None, validator=attr_type(ResourceCost, type(None)))
# Overrides for environment variables
#
# Each value is % formatted with the entire existing os.environ. A value of
# `None` will remove that envvar from the environ. e.g.
#
# {
# "envvar": "%(envvar)s-extra",
# "delete_this": None,
# "static_value": "something",
# }
#
# The "env_prefixes" parameter contain values that transform an environment
# variable into a "pathsep"-delimited sequence of items:
# - If an environment variable is also specified for this key, it will be
# appended as the last element: <prefix0>:...:<prefixN>:ENV
# - If no environment variable is specified, the current environment's value
# will be appended, unless it's empty: <prefix0>:...:<prefixN>[:ENV]?
# - If an environment variable with a value of None (delete) is specified,
# nothing will be appended: <prefix0>:...:<prefixN>
# "env_suffixes" is identical, except that it appends instead of prepends to
# the envvar.
#
# NOTE: Always prefer env_prefixes and env_suffixes to manually substituting
# variables with %(envvar)s.
env = attr.ib(factory=dict, validator=attr_dict_type(str, (str, type(None))))
env_prefixes = attr.ib(factory=EnvAffix, validator=attr_type(EnvAffix))
env_suffixes = attr.ib(factory=EnvAffix, validator=attr_type(EnvAffix))
# If True, lets the step emit its own @@@annotations@@@.
#
# TODO(iannucci): Delete when kitchen is gone.
#
# NOTE: Enabling this can cause some buggy behavior. Use
# step_result.presentation instead. If you have questions, please contact
# infra-dev@chromium.org.
allow_subannotations = attr.ib(default=False, validator=attr_type(bool))
# The time, in seconds, that this step is allowed to run for before timing
# out. This is calculated by adjusting the
# LUCI_CONTEXT['deadline']['soft_deadline'] field when the step is rendered to
# a StepRunner.Step.
#
# Also accepts a datetime.timedelta object.
#
# NOTE: This timeout applies AFTER the step has waited for `cost` to be
# available to it. If the recipe engine userspace merely adjusts
# LUCI_CONTEXT['deadline'] then it would include the time waiting for `cost`.
timeout = attr.ib(
default=None,
converter=(lambda val: val.total_seconds() if isinstance(
val, datetime.timedelta) else val),
validator=attr_type((int, float, type(None))))
# luci_context is the mapping of modified LUCI_CONTEXT sections to their
# respective section proto message.
#
# The engine will write this delta to disk and adjust the step's LUCI_CONTEXT
# environment variable pror to submitting it to the StepRunner for execution.
luci_context = attr.ib(default=None, validator=attr_dict_type(str, Message))
# Set of return codes allowed. If the step process returns something not on
# this list, it will raise a StepFailure (or InfraFailure if infra_step is
# True).
#
# Alternatively, the sentinel StepConfig.ALL_OK can be used to allow any
# return code.
ok_ret = attr.ib(default=(0,))
@ok_ret.validator
def _ok_ret_validator(self, attrib, value):
if value is self.ALL_OK:
return
attr_seq_type(int)(self, attrib, value)
# If True and the step returns an unacceptable return code (see `ok_ret`),
# this will cause the step's status to be EXCEPTION rather than FAILURE.
infra_step = attr.ib(default=False, validator=attr_type(bool))
# If True and the step's status is not SUCCESS, a StepFailure or InfraFailure
# will be raised, depending on the step's status (see `infra_step`). (An
# exception will be raised in the case of a canceled step regardless of the
# value of this attribute).
raise_on_failure = attr.ib(default=True, validator=attr_type(bool))
# If True, this step will be created as `merge step` and run a LUCI
# executable.
# If "legacy" then legacy_global_namespace will also be set.
# See: [luciexe recursive invocation](https://pkg.go.dev/go.chromium.org/luci/luciexe?tab=doc#hdr-Recursive_Invocation)
merge_step = attr.ib(default=False,
validator=attr.validators.in_((True, False, "legacy")))
# Standard handle redirection.
# If None, stdin is closed and stdout/stderr are routed to the UI.
# These placeholders require a non-default implementation of `backing_file`.
stdin = attr.ib(default=None, validator=_file_placeholder(InputPlaceholder))
stdout = attr.ib(default=None, validator=_file_placeholder(OutputPlaceholder))
stderr = attr.ib(default=None, validator=_file_placeholder(OutputPlaceholder))
# A function returning recipe_test_api.StepTestData.
#
# A factory which returns a StepTestData object that will be used as the
# default test data for this step. The recipe author can override/augment this
# object in the GenTests function.
step_test_data = attr.ib(
default=None,
validator=attr_value_is(
'None or callable',
lambda value: value is None or callable(value)))
def __attrs_post_init__(self):
object.__setattr__(self, 'cmd', tuple(self.cmd))
# if cmd is empty, then remove all values except for the few that actually
# apply with a null command.
if not self.cmd:
_keep_fields = ('name', 'cmd')
for attrib in attr.fields(self.__class__):
if attrib.name in _keep_fields:
continue
# cribbed from attr/_make.py; the goal is to compute the attribute's
# default value.
if isinstance(attrib.default, attr.Factory):
if attrib.default.takes_self:
val = attrib.default.factory(self)
else:
val = attrib.default.factory()
else:
val = attrib.default
if attrib.converter:
val = attrib.converter(val)
object.__setattr__(self, attrib.name, val)
object.__setattr__(self, 'cost', None)
return
if self.ok_ret is not self.ALL_OK:
object.__setattr__(self, 'ok_ret', frozenset(self.ok_ret))
object.__setattr__(self, 'env', freeze(self.env))
# Ensure that output placeholders don't have ambiguously overlapping names.
placeholders = set()
collisions = set()
ns_str = None
for itm in self.cmd:
if isinstance(itm, OutputPlaceholder):
key = itm.namespaces, itm.name
if key in placeholders:
ns_str = '.'.join(itm.namespaces)
if itm.name is None:
collisions.add("{} unnamed".format(ns_str))
else:
collisions.add("{} named {!r}".format(ns_str, itm.name))
else:
placeholders.add(key)
if collisions:
raise ValueError(
'Found conflicting Placeholders: {!r}. Please give these placeholders'
' unique "name"s and access them like `step_result.{}s[name]`.'
.format(list(collisions), ns_str))
# Used with to indicate that all retcodes values are acceptable.
ALL_OK = sentinel('ALL_OK')