blob: e403266c276cc93896c7d4869e5a5373b419824e [file] [log] [blame] [view]
# Recipe engine implementation details
This doc covers implementation details of the recipe engine and its processes.
Read this if you want to understand/modify how the recipes as a system work. For
general recipe development, please see [user_guide.md](./user_guide.md).
[TOC]
## Recipe engine subcommands
All recipe engine subcommands live in the [commands] folder. The `__init__.py`
file here contains the entry point for all subcommand parsing `parse_and_run`
which is invoked from [main.py].
The commands module contains (as submodules) all of the subcommands that the
recipe engine supports. The protocol is pretty simple:
* The subcommand lives in a submodule (either directory or .py file).
* Each submodule has a `add_arguments(parser)` function (for directories, this
is expected to be in the `__init__.py` file).
* Each submodule may also define an optional `__cmd_priority__` field. This
should be an integer which will be used to rank commands (e.g. so that 'run'
and 'test' can precede all other subcommands). Commands will be ordered
first by __cmd_priority__ (lower values sort earlier) and then
alphabetically. This is currently used to put `run` and `test` as the
topmost arguments in the `recipes.py` help output.
* The `add_arguments` function takes an argparse parser, and adds flags to it.
The parser will be created:
* using the module's name as the command name
* using the module's `__doc__` to generate both the description and 'help'
for the parser (help will be the first paragraph of `__doc__`).
* In addition to adding flags, the function must also call:
```
parser.set_defaults(
postprocess_func=function(error, args), # optional
func=function(args)) # required
```
* Where the 'args' parameter is the parsed CLI arguments and 'error' is the
function to call if the preconditions for the subcommand aren't met.
* postprocess_func should do any post-CLI checks and call `error(msg)` if the
checks don't pass. Most subcommands don't need this, but it's a nice way to
verify preconditions for the command.
* func executes the actual subcommand.
The reason for this structure is so that the actual `func` can do lazy
importing; this is necessary if the subcommand requires protobufs to operate
correctly (which are only available after the CLI has successfully parsed).
All commands have `args.recipe_deps`, which is the resolved RecipeDeps instance
to use.
## Loading
This section talks about how the recipe engine gets from the recipes.py
command invocation to the point where it begins executing the recipe.
### Repo configuration
Recipes have a bit of an interesting multi-step loading process, though it has
gotten simpler over the years.
Every recipe repo has at least two things:
* A `recipes.py` script (which is a literal copy of [recipes.py]).
* A `recipes.cfg` config file located at `//infra/config/recipes.cfg`
* (The hard-coded location is possibly not ideal, but it does keep things
simple.)
* This cfg file conforms to [recipes_cfg.proto], but also is parsed by
[simple_cfg.py].
The recipes.cfg contains a field `recipes_path` (aka `$recipes_path` for this
doc) which is a path inside the repo of where the following can exist:
* A `recipes` folder - contains entry point scripts (recipes) for the repo.
* A `recipe_modules` folder - contains modules which may be depended on (used
by) both recipe scripts as well as other modules (in this repo and any other
repos which depend on it).
Additionally, `recipes.cfg` describes dependencies with a git URL, commit
and fetch ref.
### Repo loading
When a dev runs `recipes.py` in their repo (their repo's copy of [recipes.py]),
it will find and parse the repo's `recipes.cfg` file, and identify the version
of the `recipe_engine` repo that the repo currently depends on.
It will then bootstrap (with git) a clone of the recipe engine repo in the
repo's `$recipes_path/.recipe_deps/recipe_engine` folder, and will invoke
[main.py] in that clone with the `--package` argument pointing to the absolute
path of the repo's recipes.cfg file.
Once `main.py` is running, it parses the `-O` overrides and the `--package`
flags, and builds a [RecipeDeps] object which owns the whole
`$recipes_path/.recipe_deps` folder. Constructing this object includes syncing
(with git) all dependencies described in `recipes.cfg`. Every dependency
will be checked out at `$recipes_path/.recipe_deps/$dep_name`.
[RecipeDeps]: /recipe_engine/internal/recipe_deps.py
*** note
When dependencies are overridden on the command line with the `-O` flag, the
path specified for the dependency is used verbatim as the root of that
dependency repo; no git operations are performed.
This is the mechanism that the `recipes.py bundle` command employs to make
a hermetic recipe bundle; it generates a `recipes` script which passes `-O`
flags for ALL dependencies, causing the engine to run without doing any git
operations.
***
The `RecipeDeps` object also traverses (by scanning the checked-out state) all
the dependent repos to find their recipes and recipe_modules. It does not yet
read the code inside the files.
At this point, the chosen recipe subcommand's main function (e.g. [`run`],
[`test`], etc.) executes with the loaded `RecipeDeps` object, as well as any
other command-line flags the subcommand has defined.
Some commands just work on the structure of the `RecipeDeps` object, but most
will need to actually parse the recipe code from disk (e.g. to run it in one
form or another).
[run]: /recipe_engine/run.py
[test]: /recipe_engine/test.py
### Proto compilation
The recipe engine facilitates the use of protobufs with builtin `protoc`
capabilities. This is all implemented in [proto_support.py].
*** note
For the purposes of bundled recipes, it's possible to completely skip the proto
compilation step by using the --proto-override option to the engine. This is
exactly what `recipes.py bundle` generates so that builders don't need to do any
`protoc` activity on their startup.
***
Due to the nature of .proto imports, the generated python code (specifically
w.r.t. the generated `import` lines), and the layout of recipes and modules
(specifically, across multiple repos), is a bit more involved than just putting
the .proto files in a directory, running 'protoc' and calling it a day.
After loading all the repos, the engine gathers and compiles any `.proto` files
they contain into a single global namespace. The recipe engine looks for proto
files in 4 places in a repo:
* Under the `recipe_modules` directory
* Placed into the global namespace as `recipe_modules/$repo_name/*`.
* Under the `recipes` directory
* Placed into the global namespace as `recipes/$repo_name/*`.
* Under the `recipe_engine` directory (only in the actual `recipe_engine`
repo).
* Placed into the global namespace as `recipe_engine/*`.
* In a `recipe_proto` directory (adjacent to the 'recipes' and/or
'recipe_modules' directories).
* Placed directly into the global namespace.
* May not contain anything in the `recipe_engine`, `recipes` or
`recipe_modules` subdirectories.
While the engine gathers all the proto files, it sorts them and generates
a checksum of their contents. This is a SHA2 of the following:
* `RECIPE_PB_VERSION` | `NUL` - The version of the recipe engine's compilation
algorithm.
* `PROTOC_VERSION` | `NUL` - The version of the protobuf library/compiler
we're using.
* `repo_name` | `NUL` - The name of the repo.
Then, for every .proto in the repo we hash:
* `relative_path_in_repo` | `NUL`
* `relative_path_of_global_destination` | `NUL`
* `githash_of_content` | `NUL`
The `githash_of_content` is defined by git's "blob" hashing scheme (but is
currently implemented in pure Python).
Once we've gathered all proto files and have computed the checksum, we verify
the checksum against `.recipe_deps/_pb/PB/csum`. If it's the same, we conclude
that the currently cached protos are the same as what we're about to compile.
If not, we copy all protos to a temporary directory reflecting their expected
structure (see remarks about "global namespace" above). This structure is
important to allow `protoc` to correctly resolve `import` lines in proto files,
as well as to make the correct python import lines in the generated code.
Once the proto files are in place, we compile them all with `protoc` into
another tempdir.
We then rewrite and rename all of the generated `_pb2` files to change their
import lines from:
from path.to.package import blah_pb2 as <unique_id_in_file>
to:
from PB.path.to.package import blah as <unique_id_in_file>
And rename them from `*_pb2` to `*`. We also generate empty `__init__.py` files.
After this, we write `csum`, and do a rename-swap of this tempdir to
`.recipe_deps/_pb/PB`. Finally, we put `.recipe_deps/_pb` onto `sys.path`.
### Recipe Module loading
Modules are loaded by calling the `RecipeModule.do_import()` function. This is
equivalent in all ways to doing:
from RECIPE_MODULES.repo_name import module_name
For example:
from RECIPE_MODULES.depot_tools import gclient
This import magic is accomplished by installing a [PEP302] 'import hook' on
`sys.meta_path`. The hook is implemented in [recipe_module_importer.py]. Though
this sounds scary, it's actually the least-scary way to implement the recipe
module loading system, since it meshes with the way that python imports are
actually meant to be extended. You can read [PEP302] for details on how these
hooks are meant to work, but the TL;DR is that they are an object with two
methods, `find_module` and `load_module`. The first function is responsible for
saying "Yes! I know how to import the module you're requesting", or "No, I have
no idea what that is". The second function is responsible for actually loading
the code for the module and returning the module object.
Our importer behaves specially:
* `RECIPE_MODULES` - Returns an empty module marked as a 'package' (i.e.,
a module with submodules).
* `RECIPE_MODULES.repo_name` - Verifies that the given project actually
exists in `RecipeDeps`, then returns an empty module marked as a 'package'.
* `RECIPE_MODULES.repo_name.module_name` - Verifies that the given module
exists in this project, then uses `imp.find_module` and `imp.load_module` to
actually do the loading. These are the bog-standard implementations for
loading regular python modules.
* `RECIPE_MODULES.repo_name.module_name....` - All submodules are imported
without any alteration using `imp.find_module` and `imp.load_module`.
### Recipe loading
Recipe loading is substantially simpler than loading modules. The recipe `.py`
file is exec'd with
[`execfile`](https://docs.python.org/2/library/functions.html#execfile), and
then it's PROPERTIES dict (if any) is bound the same way as it is for Recipe
Modules.
### Instantiating '**api**' objects
Now that we know how to load the __code__ for modules and recipes, we need to
actually instantiate them. This process starts at the recipe's `DEPS`
description, and walks down the entire DEPS tree, instantiating recipe modules
on the way back up (so, they're instantiated in topological order from bottom to
top of the dependency tree).
Instantiation can either be done in 'API' mode or 'TEST_API' mode. 'API' mode is
to generate the `api` object which is passed to `RunSteps`. 'TEST_API' mode is
to generate the `api` object which is passed to `GenTests`. Both modes traverse
the dependency graph the same way, but 'API' mode does a superset of the work
(since all `RecipeApi` objects have a reference to their `test_api` as
`self.test_api`).
Both `RecipeTestApi` and `RecipeApi` classes have an `m` member injected
into them after construction, which contains all of the DEPS'd-in modules as
members. So if a DEPS entry looks like:
DEPS = [
"some_repo_name/module",
"other_module",
]
Then the `api` and `test_api` instances will have an 'm' member which contains
`module` and `other_module` as members, each of which is an instance of their
respective instantiated `api` class.
As the loader walks up the tree, each recipe module's `RecipeTestApi` (if any)
subclass is instantiated by calling its `__init__` and then injecting its `m`
object.
If the loader is in 'API' mode, then the module's RecipeApiPlan subclass is also
instantiated, using the declared PROPERTIES as arguments to __init__, along with
`test_data`, which may be provided if the `api` is being used from the
`recipes.py test` subcommand to provide mock data for the execution of the test.
The `m` object is injected, and then any `UnresolvedRequirement` objects are
injected as well. Finally, after `m` has been injected and all
`UnresolvedRequirement` objects are injected, the loader calls the instance's
`initialize()` method to allow it to do post-dependency initialization.
*** note
The `UnresolvedRequirement` objects are currently only used to provide limited
'pinhole' interfaces into the recipe engine, such as the ability to run
a subprocess (step), or get access to the global properties that the recipe was
started with, etc. Typically these are only used by a single module somewhere in
the `recipe_engine` repo; user recipe modules are not expected to use these.
***
## Simulation tests
This section talks about the code that implements the test command of
recipes.py.
### Post-process hooks
As part of the definition of a simulation test, the user can add post-process
hooks to filter and/or make assertions on the expectations recorded for the test
run. Hooks can be added using either the `post_process` or `post_check` methods
of RecipeTestApi. The code that runs these hooks as well as the implementation
of the `check` callable passed as the first argument to hooks is located in
[magic_check_fn.py].
The `check` callable passed to post-process hooks is an instance of `Checker`.
The `Checker` class is responsible for recording failed checks, including
determining the relevant stack frames to be included in the failure output.
The `Checker` is instantiated in `post_process` and assigned to a local
variable. The local variable is important because the `Checker` object uses the
presence of itself in the frame locals to define the boundary between engine
code and post-process hook code. In the event of a failure, the `Checker`
iterates over the stack frames, starting from the outermost frame and proceeding
towards the current execution point. The first frame where the `Checker` appears
in the frame locals is the last frame of engine code and the relevant frames
begin starting at the next frame and excluding the 2 innermost frames (the
`__call__` method calls `_call_impl` which is where the frame walking takes
place.
Failures may also be recorded in the case of a KeyError. KeyErrors are caught in
`post_process` and the frames to be included in the failure are extracted from
the exception info.
Once relevant frames are determined by `Checker` or `post_process`,
`Check.create` is called to create a representation of the failure containing
processed frames. The frames are converted to `CheckFrame`, a representation
that holds information about the point in code that the frame refers to without
keeping all of the frame locals alive.
Processing a frame involves extracting the filename, line number and function
name from the frame and where possible reconstructing the expression being
evaluated in that frame. To reconstruct the expression, `CheckFrame` maintains a
cache that maps filename and line number to AST nodes corresponding to
expressions whose definitions end at that line number. The end line is the line
that will appear as the line number of a frame executing that expression. The
cache is populated by parsing the source code into nodes and then examining the
nodes. Nodes that define expressions or simple statements (statements that can't
have additional nested statements) are added to the cache. Other statements
result in nested statements or expressions being added to the queue. When a
simple statement or expression is added to the cache, we also walk over all of
its nested nodes to find any lambda definitions. Lambda definitions within a
larger expression may result in line numbers in an execution frame that doesn't
correspond with the line number of the larger expression, so in order to display
code for frames that occur in lambdas we add them to the cache separately.
In the case of the innermost frame, the `CheckFrame` also includes information
about the values of the variables and expressions relevant for determining the
exact nature of the check failure. `CheckFrame` has a varmap field that is a
dict mapping a string representation for a variable or expression to the value
of that variable or expression (e.g. 'my_variable' -> 'foo'). The expression
may not be an expression that actually appears in the code if the expression
would actually be more useful than the actual expression in the code (e.g.
`some_dict.keys()` will appear if the call `check('x' in some_dict)` fails
because the values in `some_dict` aren't relevant to whether `'x'` is in it).
This varmap is constructed by the `_checkTransformer` class, which is a
subclass of `ast.NodeTransformer`. `ast.NodeTransformer` is an instance of the
visitor design pattern containing methods corresponding to each node subclass.
These methods can be overridden to modify or replace the nodes in the AST.
`_checkTransformer` overrides some of these methods to replace nodes with
resolved nodes where possible. Resolved nodes are represented by `_resolved`, a
custom node subclass that records a string representation and a value for a
variable or expression. It also records whether the node is valid. The node
would not be valid if the recorded value doesn't correspond to the actual value
in the code, which is the case if we replace an expression with an expression
more useful for the user (e.g. showing only the keys of a dict when a membership
check fails). The node types handled by `_checkTransformer` are:
* `Name` nodes correspond to a variable or constant and have the following
fields:
* `id` - string that acts as a key into one of frame locals, frame globals
or builtins
If `id` is one of the constants `True`, `False` and `None` the node is
replaced with a resolved node with the name of the constant as the
representation and the constant itself as the value. Otherwise, if the name
is found in either the frame locals or the frame globals, the node is
replaced with a resolved node with `id` as the representation and the looked
up value as the value.
* `Attribute` nodes correspond to an expression such as `x.y` and have the
following fields:
* `value` - node corresponding to the expression an attribute is looked up
on (`x`)
* `attr` - string containing the attribute to look up (`y`)
If `value` refers to a resolved node, then we have been able to resolve the
preceding expression and so we replace the node with a resolved node with
the value of the lookup.
* `Compare` nodes correspond to an expression performing a series of
comparison operations and have the following fields:
* `left` - node corresponding the left-most argument of the comparison
* `ops` - sequence of nodes corresponding to the comparison operators
* `cmps` - sequence of nodes corresponding to the remaining arguments of the
comparison
The only change we make to `Compare` nodes is to prevent the full display of
dictionaries when a membership check is performed; if the expression
`x in y` fails when y is a dict, we do not actually care about the values of
`y`, only its keys. If `ops` has only a single element that is an instance
of either `ast.In` or `ast.NotIn` and `cmps` has only a single element that
is a resolved node referring to a dict, then we make a node that replaces
the `cmps` with a single resolved node with the dict's keys as its value.
The new resolved node is marked not valid because we wouldn't expect
operations that work against the dict to necessarily work against its keys.
* `Subscript` nodes correspond to an expression such as `x[y]` and contains
the following fields:
* `value` - node corresponding to the expression being indexed (`x`)
* `slice` - node corresponding to the subscript expression (`y`), which may
be a simple index, a slice or an ellipsis
If `value` is a valid resolved node and `slice` is a simple index (instance
of `ast.Index`), then we attempt to create a resolved node with the value of
the lookup as its value. We don't attempt a lookup if the `value` is an
invalid resolved node because we would expect the lookup to raise an
exception or return a different value then the actual code would. In the
case that we do perform the lookup, it still may fail (e.g.
`check('x' in y and y['x'] == 'z')` when 'x' is not in `y`). If the lookup
fails and `value` is a dict, then we return a new invalid resolved node with
the dict's keys as its value so that the user has some helpful information
about what went wrong.
The nodes returned by the transformer are walked to find the resolved nodes and
the varmap is populated mapping the resolved nodes' representations to their
values that have been rendered in a user-friendly fashion.
## How recipes are run
Once the recipe is loaded, the running subcommand (i.e. `run`, `test`,
`luciexe`) selects a [StreamEngine] and a [StepRunner]. The StreamEngine is
responsible for exporting the state of the running recipe to the outside world,
and the StepRunner is responsible for running steps (or simulating them for
tests).
Once the subcommand has selected the relevant engine and runner, it then hands
control off to `RecipeEngine.run_steps`, which orchestrates the actual execution
of the recipe (namely; running steps, handling errors and updating presentation
via the StreamEngine).
### StreamEngine
The StreamEngine's responsibility is to accept reports about the UI and data
export ("output properties") state of the recipe, and channel them to an
appropriate backend service which can render them. The UI backend is the LUCI
service called "Milo", which runs on https://ci.chromium.org.
There are 2 primary implementations of the StreamEngine; one for the old
`@@@annotation@@@` protocol, and another which directly emits [build.proto] via
logdog.
The entire recipe engine was originally written to support the
`@@@annotation@@@` protocol, and thus StreamEngine is very heavily informed by
this. It assumes that all data is 'append only', and structures things as
commands to a backend, rather than setting state on a persistent object and
assuming that the StreamEngine will worry about state replication to the
backend.
The 'build.proto' (LUCI) engine maps the command-oriented StreamEngine interface
onto a persistent `buildbucket.v2.Build` protobuf message, and then replicates
the state of this message to the backend via 'logdog' (which is LUCI's log
streaming service).
Going forward the plan is to completely remove the `@@@annotation@@@` engine in
favor of the LUCI engine.
### StepRunner
The StepRunner's responsibility is to translate between the recipe and the
operating system (and by extension, anything outside of the memory of the
process executing the recipe). This includes things like mediating access to the
filesystem and actually executing subprocesses for steps. This interface is
currently an ad-hoc collection of functions pertaining to the particulars of how
recipes work today (i.e. the `placeholder` methods returning test data).
*** note
TODO:
Give StepRunner a full vfs-style interface; Instead of doing weird mocks in the
path module for asserting that files exist, and having placeholder-specific
data, the user could manipulate the state of the filesystem in their test and
then the placeholders would be implemented against the (virtual) filesystem
directly.
***
There are two implementations of the StepRunner; A "real" implementation and
a "simulation" implementation.
The real implementation actually talks to the real filesystem and executes
subprocesses when asked for the execution result of steps.
The simulation implementation supplies responses to the RecipeEngine for
placeholder test data and step results.
One feature of the StepRunner implementations is that they don't raise
exceptions; In particular the 'run' function should return an ExecutionResult
even if the step crashes, doesn't exist or whatever other terrible condition
it may have.
### Running steps
Within a chunk of recipe user code, steps are executed sequentially. When a step
runs (i.e. the recipe user code invokes `api.step(...)`), a number of things
happens:
1. Inform the StepRunner that we're about to run the step
1. Create a new `step_stream` with the StreamEngine so the UI knows about the
step.
1. Open a debug log '$debug'. The progress of the engine running the step will
be recorded here, along with any exceptions raised.
1. Open a log "$execution details" which contains all the data associated with
running the step (command, env, etc.) and also the final exit code of the
step.
1. The engine renders all input placeholders attached to the step. This
typically involves writing data to disk (but it depends on the placeholder
implementation).
1. The engine runs the step to get an ExecutionResult.
1. The step's "initial status" is calculated. This is a function of running
the step (its ExecutionResult) and also properties like `ok_ret` and
`infra_step`.
1. The output placeholders are resolved.
If an exception is raised during this process it's logged (to `$debug`) and then
saved while the engine does the final processing.
Currently, when a step has finished execution, its `step_stream` is kept open
and the step is pushed onto a stack of `ActiveStep`s. Depending on the
configuration of the step (`ok_ret`, `infra_step`, etc.) the engine will raise
an exception back into user code. If something broke while running the step (like
a bad placeholder, or the user asked to run a non-executable file... you know,
the usual stuff), this exception will be re-raised after the engine finalizes
the StepData, and sets up the presentation status (likely, "EXCEPTION").
#### Drawbacks of current presentation/exception handling implementation.
However, the step remains open until **the next step runs!**
The step's presentation can be accessed, _and modified_ in a couple ways:
* Via the return value of `api.step()`
* Via the '.result' property of the caught StepFailure exception.
* Via the magical `api.step.active_result` property.
The first one isn't too bad, but the last two are pretty awful. This means that
a user of your module function can get access to your step result and:
* Modify the presentation of your step
* (not too bad, though kinda weird... if the changes they make to your step
don't mesh well with your own changes it'll look like you goofed something
up).
* Directly read the data results of your step's placeholders.
* Change your implementation to use 2 placeholders instead of one? Surprise!
You've now broken all your downstream users.
* This is pretty bad.
* Read the output properties from your step!
* Hey! Those were supposed to be write-only! What the heck?
[build.proto]: https://chromium.googlesource.com/infra/luci/luci-go/+/master/buildbucket/proto/build.proto
[commands]: /recipe_engine/internal/commands
[engine]: /recipe_engine/internal/engine.py
[magic_check_fn.py]: /recipe_engine/internal/test/magic_check_fn.py
[main.py]: /recipe_engine/main.py
[PEP302]: https://www.python.org/dev/peps/pep-0302/
[proto_support.py]: /recipe_engine/internal/proto_support.py
[recipe_module_importer.py]: /recipe_engine/internal/recipe_module_importer.py
[recipes.py]: /recipes.py
[recipes_cfg.proto]: /recipe_engine/recipes_cfg.proto
[simple_cfg.py]: /recipe_engine/internal/simple_cfg.py
[StepRunner]: /recipe_engine/internal/step_runner/__init__.py
[StreamEngine]: /recipe_engine/internal/stream/__init__.py