blob: b267dabb115359f5cb5e746b36a3caf44d0a8193 [file] [log] [blame] [view]
# Recipes
Recipes are a Python3 framework for writing Continuous Integration scripts (i.e.
what you might otherwise write as a bash script). Unlike bash scripts, they
are meant to:
* Be testable
* Be cross-platform
* Allow code-sharing
* Be locally runnable
* Integrate with the LUCI UI (i.e. https://ci.chromium.org) to display
subprocesses as "steps", and other UI attributes (step color, descriptive
text, debugging logs, etc.)
*** note
For a more detailed guide to writing recipes, see the [recipe walkthrough](./walkthrough.md).
***
*** note
For more implementation details, please see [implementation_details].
***
[walkthrough]: ./walkthrough.md
[implementation_details]: ./implementation_details.md
[TOC]
## Background
Chromium previously used BuildBot for its builds, which stored the definition of
a builder's actions as 'Build Factories' on the service side, requiring service
redeployment (including temporary outages) in order to make changes to them.
Additionally the build factories produced all steps-to-be-run at the beginning
of the build; there was no easy way to calculate a future step from the results
of running an intermediate step. This made it very difficult to iterate on the
builds.
We initially introduced a protocol to BuildBot to allow a script running in the
build to control the presentation of the build on BuildBot (i.e. telling the
service that new steps were running and their status, etc.). For a while
a couple sub-teams used bash scripts to execute their builds, manually emitting
the signalling protocol interspersed with stdout. This provided the ability
to de-couple working on what-the-build-does from the running BuildBot service,
allowing changes to the build without redeployment of the service.
Recipes were the evolution of these bash scripts; they are written in Python3,
allow code sharing across repos, have a testing mechanism, etc. Arguably,
recipes have *too many* features at present, but we're hard at work removing
them where we can, to keep them simple :-)
## Introduction
This user guide will attempt to bootstrap your understanding of the recipes
ecosystem. This includes the setup of a recipe repo, user of the recipes.py
script, and the development flow for writing and testing recipes and recipe
modules.
## Runtime dependencies
Recipes depend on a few tools to be in the environment:
* python: Currently recipes rely on Python 3.8 via `vpython3`.
* `vpython3`: This is a LUCI tool to manage Python3 VirtualEnvs. Recipes
rely on this for the recipe engine runtime dependencies (like the python
protobuf libraries, etc.)
* `cipd`: This is a LUCI tool to manage binary package distribution.
Additionally, most existing recipes depend on the following:
* `luci-auth` - This is a LUCI tool to manage OAuth tokens; on bots it
can mint tokens for service accounts installed as part of the Swarming task,
and on dev machines it can mint tokens based on locally stored credentials
(i.e. run `luci-auth login` to locally store credentials).
## Recipe repo setup
A recipe repo has a few essential requirements:
* It is a git repo.
* It contains a file called `//infra/config/recipes.cfg`. For historical
reasons, this is a non-configurable path.
* It contains the `recipes`, `recipe_modules`, and/or `recipe_proto` folders
(in the `recipes_path` folder indicated by `recipes.cfg`. By default they
are located at the base of the repository).
* It contains a copy of [recipes.py] in its `recipes_path` folder.
[recipes.py]: /recipes.py
### The config file recipes.cfg
The `recipes.cfg` file is a JSONPB file, which is defined by the
[recipes_cfg.proto] protobuf file.
Its purpose is to tell the recipe engine about this repo, and indicate any
other repos that this repo depends on (including precise dependency pins). All
recipe repos will need to depend on the 'recipe_engine' repo (the repo
containing this user guide).
As part of this config, the repo needs an `id`, which should match the
LUCI-config project id for the repo; this `id` will show up when other recipe
repos depend on your repo.
Example [recipes.cfg](https://chromium.googlesource.com/chromium/tools/build/+/HEAD/infra/config/recipes.cfg).
[recipes_cfg.proto]: /recipe_engine/recipes_cfg.proto
### The recipes folder
The recipes folder contains a collection of python files and subfolders
containing python files, as well as subfolders containing JSON 'expectation'
files. Recipes are named by their file path (minus the `.py` extension).
A recipe in a subfolder includes that subfolder in its name; so
`/path/to/recipes/subdir/recipe.py` would have the name "subdir/recipe".
Example [recipes folder](https://chromium.googlesource.com/chromium/tools/build/+/HEAD/recipes/recipes).
### The recipe_modules folder
The `recipe_modules` folder contains subfolders, one per module. Unlike recipes,
the module namespace is flat in each repo. A recipe module directory contains
these files:
* `__init__.py`: Contains the `DEPS`, `PROPERTIES`, etc. declarations for the
recipe_module.
* `api.py`: Contains the implementation of the recipe module.
* `test_api.py`: Contains the implementation of the recipe module's fakes.
Example [recipe_modules folder](https://chromium.googlesource.com/chromium/tools/build/+/HEAD/recipes/recipe_modules).
### The recipe_proto folder
See [Working with Protobuf files](#Working-with-Protobuf-files) for details on
this folder and its contents.
## The recipes.py script
The `recipes.py` script is the entry point to the recipe_engine and to running
your recipe. Its primary functionality is to clone a copy of the recipe_engine
repo (matching the version in your `recipes.cfg` file), and then invoke the
main recipe_engine code with whatever command line you gave it.
This script invokes the recipe engine with `vpython3`, which picks up a python
VirtualEnv suitable for the recipe engine (it includes things like
py-cryptography and the protobuf library).
There are a couple of important subcommands that you'll use frequently:
* `run`: This command actually executes a single recipe.
* `test`: This command runs the simulation tests and trains the generated
README.recipes.md file as well as simulation expectation files. This also
has a 'debug' option which is pretty helpful.
Less often-used:
* `autoroll`: Automatically updates your `recipes.cfg` file with newer
versions of the dependencies there. This rolls the recipes.cfg version.
and also runs simulation tests to try to detect the largest 'trivial' roll,
or the smallest 'non-trivial' roll.
* `manual_roll`: Updates your `recipes.cfg` file with the smallest valid roll
possible, but doesn't do any automated testing. It's useful for when you
need to manually roll recipes (i.e. the automated roll doesn't find a
valid trivial or non-trivial roll, due to API changes, etc.).
* `bundle`: Extracts all files necessary to run the recipe without making any
network requests (i.e. no git repository operations).
And very infrequently used:
* `doc`: Shows/generates documentation for the recipes and modules from their
python docstrings. However the `test train` subcommand will generate
Markdown automatically from the docstrings, so you don't usually need to
invoke this subcommand explicitly.
* `fetch`: Explicitly runs the 'fetch' phase of the recipe engine (to sync
all local git repos to the versions in `recipes.cfg`). However, this happens
implicitly for all subcommands, and the `bundle` command is a superior way
to prepare recipes for offline use.
* `lint`: Runs some very simple static analysis on the recipes. This command
is mostly invoked automatically from PRESUBMIT scripts so you don't need to
run it manually.
It also has a couple tools for analyzing the recipe dependency graph:
* `analyze`: Answers questions about the recipe dependency graph (for use in
continuous integration scenarios).
### Overriding dependencies
If you're developing recipes locally, you may find the need to work on changes
in multiple recipe repos simultaneously. You can override a dependency for
a recipe repo with the `-O` option to `recipes.py`, for any of its subcommands.
For example, you may want to change the behavior of the upstream repo and
see how it affects the behavior of the recipes in the `dependent` repo (which
presumably depends on the upstream repo). To do this you would:
$ # Hack on the upstream repo locally to make your change
$ cd /path/to/dependent/repo
$ ./recipes.py -O upstream_id=/path/to/upstream/repo test train
<uses your local upstream repo, regardless of what recipe.cfg specifies>
This works for all dependency repos, and can be specified multiple times to
override more than one dependency.
### The run command
TODO(iannucci) - Document
### The test command
TODO(iannucci) - Document
### The autoroll command
TODO(iannucci) - Document
### The manual_roll command
Updates your repo's `recipes.cfg` file with the smallest valid roll possible.
This means that for all dependencies your repo has, the smallest number of
commits change between the previous value of recipes.cfg and the new value of
recipes.cfg.
This will print out the effective changelog to stdout as well, for help in
preparing a manual roll CL.
You can run this command repeatedly to find successive roll candidates.
### The bundle command
TODO(iannucci) - Document
## Writing recipes
A "recipe" is a Python3 script which the recipe engine can run and test. This
script:
* Must have a RunSteps function
* Must have a GenTests generator
* May have a DEPS list
* May have a `PROPERTIES` declaration
* May have a `ENV_PROPERTIES` declaration
Recipes must exist in one of the following places in a recipe repo:
* Under the `recipes` directory
* Under a `recipe_modules/*/examples` directory
* Under a `recipe_modules/*/tests` directory
* Under a `recipe_modules/*/run` directory
Recipes in subfolders of these are also permitted. Recipes in the global recipe
directory have a name which is the path of the recipe script relative to the
recipe folder containing it. If the recipe is located under a recipe module
folder, the name is prepended with the module's name and a colon. For example:
//recipes/something.py -> "something"
//recipes/sub/something.py -> "sub/something"
//recipe_modules/foo/tests/something.py -> "foo:tests/something"
//recipe_modules/foo/run/sub/something.py -> "foo:run/sub/something"
Here's a simple example recipe:
DEPS = [
"recipe_engine/step",
]
def RunSteps(api):
# This runs a single step called "say hello" which executes the `echo`
# program to print 'hello' to stdout. `echo` is assumed to be resolvable
# via $PATH here.
api.step('say hello', ['echo', 'hello'])
def GenTests(api):
# yields a single test case called 'basic' which has no particular inputs
# and asserts that the step 'say hello' runs.
yield (
api.test('basic')
+ api.post_check(lambda check, steps: check('say hello' in steps))
)
For a more detailed guide to writing recipes and recipe modules, see the
[walkthrough](./walkthrough.md)
### RunSteps
The `RunSteps` function has a signature like:
# RunSteps(api[, properties][, env_properties])
# For example:
# Neither PROPERTIES or ENV_PROPERTIES are declared
def RunSteps(api):
# PROPERTIES(proto message)
def RunSteps(api, properties):
# PROPERTIES(proto message) and ENV_PROPERTIES(proto message)
def RunSteps(api, properties, env_properties):
# ENV_PROPERTIES(proto message)
def RunSteps(api, env_properties):
# (DEPRECATED) Old style PROPERTIES declaration.
def RunSteps(api, name, of, properties):
Where `api` is a python object containing all loaded `DEPS` (see section on
`DEPS` below), and the properties arguments are loaded from the properties
passed in to the recipe when the recipe is started.
The `RunSteps` function may invoke any recipe module it wants via `api` (at its
most basic, a recipe would run steps via `api.step(...)` after including
'recipe_engine/step' in `DEPS`).
Additionally, the `RunSteps` function can return a summary and status of the build.
This is done by returning a `RawResult` object, which can be done like this:
# Import proto that has RawResult object
from PB.recipe_engine.result import RawResult
# Import proto that has Status object
from PB.go.chromium.org.luci.buildbucket.proto import common
def RunSteps(api):
# Run some recipe step
try:
api.step('do example', ...)
return RawResult(
status=common.SUCCESS,
summary_markdown='Ran the example!',
)
except api.step.StepFailure:
# The example will output json in the form of `{"error": string}`
step_json = api.step.active_result.json.output
return RawResult(
status=common.FAILURE,
summary_markdown=step_json['error'],
)
*** note
Currently (as of 2019/06/24) the Recipe Engine still uses the `@@@annotation@@@` protocol
which prevents the `summary_markdown` field from propagating on `SUCCESS` statuses. So,
you can set `summary_markdown` in all cases from the recipe, but it will only be visible
on the build in conjunction with non-SUCCESS status value.
***
### GenTests
The `GenTests` function is a generator which yields test cases. Every test case:
* Has a unique name
* Specifies input properties for the test
* Specifies input data for recipe modules
* e.g. 'paths which exist' for the `recipe_engine/path` module, what OS and
architecture the `recipe_engine/platform` module should simulate, etc.
* Specifies the behavior of various steps by name (i.e. their return code, the
output from placeholders)
* Assertions about steps which should have run (or should not have run) given
those inputs.
* Filters for the 'test expectation' of the test case to omit details from the
test expectations which aren't relevant to the test case.
Each test case also produces a test expectation file adjacent to the recipe; the
final state of the recipe execution in the form of a listing of the steps that
have run. The test expectation files are written to a folder which is generated
by replacing the '.py' extension of the recipe script with '.expected/'.
### DEPS
The DEPS section of the recipe specifies what recipe modules this recipe depends
on. The DEPS section has two possible forms, a list and a dict.
As a list, DEPS can specify a module by its fully qualified name, like
`recipe_engine/step`, or its unqualified name (for modules in the same recipe
repo as the recipe) like `step`. The 'local name' of an entry is the last token
of the fully qualified name, or the whole name for an unqualified name (in this
example, the local name for both of these is just 'step').
As a dict, DEPS maps from a local name of your choosing to either the fully
qualified or unqualified name of the module. This would allow you to
disambiguate between modules which would end up having the same local name. For
example `{'my_archive': 'archive', 'archive': 'recipe_engine/archive'}`.
The recipe engine, when running your recipe, will inject an instance of each
DEPS'd recipe module into the `api` object passed to `RunSteps`. The instance
will be injected with the local name of the dependency. Within a given execution
of a recipe module instances behave like singletons; if a recipe and a module
both DEPS in the same other module (say 'tertiary'), there will only be one
instance of the 'tertiary' module.
### PROPERTIES and ENV_PROPERTIES
Recipe code has a couple ways to observe the input properties. Currently the
best way is to define a proto message and then set this as the `PROPERTIES`
value in your recipe:
# my_recipe.proto
syntax = "proto3";
package recipes.repo_name.my_recipe;
message InputProperties {
int32 an_int = 1;
string some_string = 2;
bool a_bool = 3;
}
message EnvProperties {
int32 SOME_ENVVAR = 1; // All envvar keys must be capitalized.
string OTHER_ENVVAR = 2;
}
Then import the new proto message in the recipe.
# my_recipe.py
from PB.recipes.repo_name.my_recipe import InputProperties
from PB.recipes.repo_name.my_recipe import EnvProperties
# Setting this here makes it available in the
# parameters of RunSteps(...)
PROPERTIES = InputProperties
ENV_PROPERTIES = EnvProperties
# More information on the RunStep signature can
# be found in the RunSteps section above
def RunSteps(api, properties, env_properties):
# properties and env_properties are instances of their respective proto
# messages.
# example use case
if properties.a_bool:
# do something
elif properties.some_string == 'something important':
if env_properties.SOME_ENVVAR == 0:
# do something else
...
The properties can be set during testing by using the `properties` dependency.
This enables the ability to test different parts of the run step in the recipe.
# my_recipe.py
from PB.recipes.repo_name.my_recipe import InputProperties
from PB.recipes.repo_name.my_recipe import EnvProperties
DEPS = [
'recipe_engine/properties'
]
PROPERTIES = InputProperties
ENV_PROPERTIES = EnvProperties
def RunSteps(api, properties, env_properties):
# properties and env_properties are instances of their respective proto
# messages.
# example use case
if properties.a_bool:
# do something
elif properties.some_string == 'something important':
if env_properties.SOME_ENVVAR == 0:
# do something else
...
# This tests the above code
def GenTests(api):
yield (
api.test('properties example') +
api.properties(
InputProperties(
an_int=-1,
some_string='something important',
a_bool=False
)
) +
api.properties.environ(
EnvProperties(SOME_ENVVAR=0)
)
)
In a recipe, `PROPERTIES` is populated by taking the input property JSON object
for the recipe engine, removing all keys beginning with '$' and then decoding
the remaining object as JSONPB into the `PROPERTIES` message. Keys beginning
with '$' are reserved by the recipe engine and/or recipe modules.
The `ENV_PROPERTIES` is populated by taking the current environment variables
(i.e. `os.environ`), capitalizing all keys (i.e. `key.upper()`) then decoding
that into the `ENV_PROPERTIES` message.
The other way to access properties (which will eventually be deprecated) is
directly via the `recipe_engine/properties` module. This method is very loose
compared to direct PROPERTIES declarations and can lead to difficult to debug
recipe code (i.e. different recipes using the same property for different
things, or dozens of seemingly unrelated places all interpreting the same
property, different default values, etc.). Additionally, `api.properties` does
not allow access to environment variables.
There's another way to define `PROPERTIES` which is deprecated, but it has no
advantages over the proto method, and will (hopefully) be deleted soon.
## Writing recipe_modules
TODO(iannucci) - Document
See the relevant section in the [walkthrough](./walkthrough.md).
### PROPERTIES, GLOBAL_PROPERTIES and ENV_PROPERTIES
In a recipe module's `__init__.py`, you may specify `PROPERTIES` and
`ENV_PROPERTIES` the same way that you do for a recipe, with the exception that
a recipe module's `PROPERTIES` object will be decoded from the input property of
the form `"$recipe_repo/module_name"`. This input property is expected to be
a JSON object, and will be decoded as JSONPB into the `PROPERTIES` message of
the recipe module.
For legacy reasons, some recipe modules are actually configured by top-level
(non-namespaced) properties. To support this, recipe modules may also specify
a `GLOBAL_PROPERTIES` message which is decoded in the same way a recipe's
`PROPERTIES` message is decoded (i.e. all the input properties sans properties
beginning with '$').
Example:
# recipe_modules/something/my_proto.proto
syntax = "proto3";
package recipe_modules.repo_name.something;
message InputProperties {
string some_string = 1;
}
message GlobalProperties {
string global_string = 1;
}
message EnvProperties {
string ENV_STRING = 1; // All envvar keys must be capitalized.
}
# __init__.py
from PB.recipe_modules.repo_name.something import my_proto
PROPERTIES = my_proto.InputProperties
# Note: if you're writing NEW module code that uses global properties, you
# should strongly consider NOT doing that. Please talk to your local recipe
# expert if you're unsure.
GLOBAL_PROPERTIES = my_proto.GlobalProperties
ENV_PROPERTIES = my_proto.EnvProperties
# api.py
from recipe_engine.recipe_api import RecipeApi
class MyApi(RecipeApi):
def __init__(self, props, globals, envvars, **kwargs):
super(MyApi, self).__init__(**kwargs)
self.prop = props.some_string
self.global_prop = globals.global_string
self.env_prop = envvars.ENV_STRING
In this example, you could set `prop` and `global_prop` with the following
property JSON:
{
"global_string": "value for global_prop",
"$repo_name/something": {
"some_string": "value for prop",
},
}
And `env_prop` could be set by setting the environment variable `$ENV_STRING`.
### Accessing recipe_modules as python modules
While recipe modules provide a way to share 'recipe' code (via `DEPS`), they are
also regular python modules, and occasionally you may find yourself wishing to
directly `import` some code from a recipe module.
You may do this by importing the module from the special `RECIPE_MODULES`
namespace; This namespace contains all reachable modules (i.e. from repos
specified in your `recipes.cfg` file) sub namespaced by `repo_name` and
`module_name`. This looks like:
from RECIPE_MODULES.repo_name.module_name import python_module
from RECIPE_MODULES.repo_name.module_name.python_module import Object
etc. Everything past the `RECIPE_MODULES.repo_name.module_name` bit works
exactly like any regular python import statement.
### Writing recipe_module config.py
*** note
The config subsystem of recipes is very messy and we do not recommend adding
additional dependencies on it. However some important modules (like `gclient` in
depot_tools) still use it, and so this documentation section exists.
We're looking to introduce native protobuf support as a means of fully
deprecating and eventually removing config.py, so this section is very sparse
without a "TODO" to document it more. I'll be adding additional documentation
for it as strictly necessary.
***
### Extending config.py
If you need to extend the configurations provided by another recipe module,
1. That other module MUST export it's `CONFIG_CTX` in `__init__.py`:
from .config import TheConfigRoot as CONFIG_CTX
1. Write your extensions in a file ending with `_config.py` in your recipe
module and then import that other module's `CONFIG_CTX` to add additional
named configurations to it (yes, this has very messy implications). You can
import the upstream module's `CONFIG_CTX` by using the recipe module import
syntax. For example, importing from the 'gclient' module in 'depot_tools'
looks like:
from RECIPE_MODULES.depot_tools.gclient import CONFIG_CTX
## How recipes execute
TODO(iannucci) - Document
For more details, see [implementation_details.md](./implementation_details.md).
### Engine Properties
[engine_properties.proto] defines a list of properties that dynamically adjust the behavior of recipe engine. These properties are associated with key `$recipe_engine` in the input properties.
[engine_properties.proto]: /recipe_engine/engine_properties.proto
## How recipe simulation tests work
TODO(iannucci) - Document
### Protobufs in tests
Because `PROPERTIES` (and friends) may be defined in terms of protobufs, you may
also pass proto messages in your tests when using the `properties` recipe
module.
For example:
# global properties used by this recipe
from PB.recipes.my_repo.my_recipe import InputProps
# global properties used by e.g. 'bot_update'
from PB.recipe_modules.depot_tools.bot_update.protos import GlobalProps
# module-specific properties used by 'cool_module'
from PB.recipe_modules.my_repo.cool_module.protos import CoolProps
DEPS = [
"recipe_engine/properties",
"some_repo/cool_module",
]
PROPERTIES = InputProps
def RunSteps(api, props):
...
def GenTests(api):
yield (
api.test('basic')
+ api.properties(
InputProps(...),
GlobalProps(...),
**{
# The dollar sign is a literal character and must be included.
'$my_repo/cool_module': CoolProps(...),
}
)
)
## Recipe and module 'resources'
Recipes and Recipe modules can both have "resources" which are arbitrary
files that will be bundled with your recipe and available to use when the
recipe runs. These are most typically used to include additional python scripts
which will be invoked as steps during the execution of your recipe or module.
A given recipe "X" can store resource files in the adjacent folder
"X.resources". Similarly, a recipe module "M" can have a subdirectory "resources".
To get the Path to files within this folder, use `api.resource("filename")`. This
method supports multiple path segments as well, so something like
`api.resource("subdir", "filename")` works as well.
As an example, you might run a python script like:
```
# If this is the recipe file "//recipes/hello.py" then this would run
# "//recipes/hello.resources/my_script.py", and with the vpython spec
# "//recipes/hello.resources/.vpython3".
api.step("run my_script", ["vpython3", "-u", api.resource("my_script.py")])
```
These resources should be considered *implementation details* of your recipe or
module. It's not recommended to allow outside programs to use these resources
except via your recipe or module interface.
## Structured data passing for steps
TODO(iannucci) - Document
## Build UI manipulation
TODO(iannucci) - Document
## Testing recipes and recipe_modules
TODO(iannucci) - Document
## Issuing warnings in recipe modules
While recipe modules provide a way to share code across different repos (via
`DEPS`), it also means that changing the behavior of a recipe module may
potentially break recipes or recipe modules in downstream repos which depend on
it. Figuring out all such recipes or recipe modules requires substantial effort.
Recipes have a "warnings" feature that allows recipe authors to better alert
downstream consumers about upcoming breaking changes in a recipe module. The
recipe engine will issue notifications for all warnings hit during the
execution of simulation tests (i.e. `recipes.py test run` or `recipes.py test
train`). The engine groups the notifications by warning names.
### Defining a warning
A warning is defined in the file `recipe.warnings` under the recipe folder in a
repo. `recipes.warnings` is a text proto formatted file of
`DefinitionCollection` Message in [warning.proto]. Example as follows:
google_issue_default {
host: "crbug.com"
}
warning {
name: "MYMODULE_SWIZZLE_BADARG_USAGE"
description: "The `badarg` argument on mymodule.swizzle is deprecated and replaced with swizmod."
deadline: "2020-01-01"
google_issue { id: 123456 }
}
warning {
name: "MYMODULE_DEPRECATION"
# You can also write multiple line description
description: "Deprecating MyModule in recipe_engine."
description: "Use the equivalent MyModule in infra repo instead."
description: "" # blank line
description: "MyModule contains infra specific logic."
deadline: "2020-12-31"
google_issue { id: 987654 }
google_issue { id: 654321 }
}
The `google_issue_default` will populate the `host` field of `google_issue`, and
the combination of these messages will produce bug links like:
https://{google_issue.host}/{google_issue.id}
The name of the warning must be unique within the `recipes.warnings` file. The
recipe engine will take the warning name and generate a fully-qualified warning
name of "$repo_name/$warning_name". This implies that multiple repos could
define warnings with the same repo-local name, since the engine will always
qualify them by the repo names (which already must be globally unique).
[warning.proto]: /recipe_engine/warning.proto
### Issuing a warning
A warning can be either issued in the recipe module code or for an entire
recipe module.
To issue warnings in your module code, declare a dependency to
`recipe_engine/warning` module via your module's `DEPS` first and then call the
`issue` method at the location where you want the warning to be issued. E.g.
```python
class MyModuleAPI(RecipeApi):
def swizzle(self, arg1, arg2, badarg=None):
if badarg is not None:
self.m.warning.issue('MYMODULE_SWIZZLE_BADARG_USAGE')
# the code will continue executing
# ...
```
To issue warnings for the entire recipe module, set a `WARNINGS` variable in the
`__init__.py` of the targeted recipe module. E.g.
```python
# ${PATH_TO_RECIPE_FOLDER}/recipe_modules/my_module/__init__.py
DEPS = [
# DEPS declaration
]
WARNINGS = [
'MYMODULE_DEPRECATION'
]
```
### Running a simulation test for warnings
If the recipe code within a repo hits any issued warnings, the test summary
will contain output like:
**********************************************************************
WARNING: depot_tools/MYMODULE_SWIZZLE_BADARG_USAGE
Found 5 call sites and 0 import sites
**********************************************************************
Description:
The `badarg` argument on mymodule.swizzle is deprecated and replaced with swizmod
Deadline: 2020-01-01
Bug Link: https://bugs.chromium.org/p/chromium/issues/detail?id=123456
Call Sites:
/path/to/recipe/folder/recipes/A.py:123 (and 234, 456)
/path/to/recipe/folder/recipe_modules/B/api.py:567 (and 789)
**********************************************************************
WARNING: recipe_engine/MYMODULE_DEPRECATION
Found 0 call sites and 2 import sites
**********************************************************************
Description:
Deprecating MyModule in recipe_engine.
Use the equivalent MyModule in infra repo instead.
MyModule contains infra specific logic.
Deadline: 2020-12-31
Bug Links:
https://bugs.chromium.org/p/chromium/issues/detail?id=987654
https://bugs.chromium.org/p/chrome-operations/issues/detail?id=654321
Import Sites:
/path/to/recipe/folder/recipes/C.py
/path/to/recipe/folder/recipe_modules/D/__init__.py
All issued warnings will be grouped by their fully qualified names. All
information in the definition will be displayed to provide more context about
each warning followed by `Call Sites` or `Import Sites` (could possibly have
both). Only `Call Sites` or `Import Sites` from the repo where simulation test
runs will be shown.
**Call Sites**: If a warning is issued in a function or method body of a recipe
module during a test run, Call Sites is all the unique locations where that
function/method are called. In other word, the direct outer frame of the frame
where warning is issued.
**Import Sites**: If a warning is issued for the entire recipe module via the
`WARNING` variable, Import Sites is the list of recipes and recipe modules which
have declared a dependency on that module.
### Advanced features of warnings
#### Escaping warnings
The recipe engine also provides a way to exclude code in a function from being
attributed to the call site for certain warnings. This is achieved by applying
the `@recipe_api.escape_warnings(*warning_regexps)` decorator to that function.
Each regex is matched against the fully-qualified name of the issued warning.
For example, the following code snippet attributes warning `FOO` from
`recipe_engine` repo or any warnings that ends with `BAR` emitted by
`method_contains_warning` to the CALLER of `cool_method`, instead of to
`cool_method` itself. Note that multiple frames in the call stack could be
escaped in this fashion, the recipe engine will walk the stack until it finds a
frame which is not escaped.
```python
from recipe_engine import recipe_api
class FooApi(recipe_api.RecipeApi):
@original_decorator
# escape_warnings decorator needs to be the innermost decorator
@recipe_api.escape_warnings('^recipe_engine/FOO$', '^.*BAR$')
def cool_method(self):
# warning will be issued in the following call
self.m.bar.method_contains_warning()
pass
```
There is also a shorthand decorator (`@recipe_api.escape_all_warnings`) which
escape the decorated function from all warnings.
#### CLI options for warnings
TODO(yiwzhang) - Document when CLI options are ready
## IDE setup
This section will discuss various affordances that the Recipe ecosystem exposes
to make IDE development of recipes easier.
See Also: Debugger Configuration
### Python/VirtualEnv
NOTE: Windows platforms may require additional permissions (e.g. Developer Mode
or SeCreateSymbolicLinkPrivilege).
Recipes use pinned virtual environments described in the `.*.vpython3` files
in this repo. These files are interpreted by `vpython3`, which transforms them
into a VirtualEnv, using a pinned version of python, and pinned versions of all
dependencies (e.g. google.protobuf, gevent, etc.).
As a convenience, running recipe commands (such as `recipes.py fetch`) will
generate symlinks under `.recipe_deps/_venv` to the virtualenv. The
`.recipe_deps` folder will be right next to your repo's copy of the `recipes.py`
file. The available virtualenvs are `normal`, `vscode` and `pycharm`, each
corresponding to the respective `vpython3` environment. (Note: to get the vscode
and pycharm environments respectively, you need to run `recipes.py` with these
protocols selected by the `RECIPE_DEBUGGER` environment variable. See the
Debugger Configuration section).
You can use these virtualenvs to enter the recipe engine's pinned python
environment, but you can also configure your IDE/editor environment to use these
environments by default.
You can configure pyright (e.g. in pyproject.toml) to set `.recipe_deps` as
`venvPath` and `_venv` as the `venv` configuration:
[tool.pyright]
venvPath = ".recipe_deps/_venv"
venv = "normal"
NOTE: Any time the recipe_engine's .vpython3 files change, the values of these
symlinks will be regenerated. Depending on the IDE this may mean that the IDE
will need to be restarted, in case it's cacheing an old value of the symlink.
The .vpython3 files change fairly infrequently, however.
## Debugging
The `recipes.py` CLI includes a subcommand to enter the debugger for a single
recipe + test case:
```
./recipes.py debug [recipe_name[.test_name]]
```
If you do not include a recipe name and test name (that is, just `./recipes.py
debug`), then the tool will try to find a recently failed test case, and run
that. If there are no recently failing test cases, then the tool will instead
print all available recipes to stdout.
If you omit the test_name, the tool will debug the first test case for the
recipe.
For reference, recipes inside of modules are always spelled
`module_name:path_to/recipe`. So if you have a module "my_module" and it has a
test recipe "tests/some_feature.py", this recipe name is
`my_module:tests/some_feature`.
Finally, the tool will execute RunSteps with the mocks from the selected test
case. As you step through with the debugger, calls to `api.step` will return
the mocked step_data defined by the test case.
By default (without configuring a Remote Debugger - see the Debugger
Configuration section), the debug command will use `pdb`. In this mode, `pdb`
will break at the very top of the selected recipe file (allowing you to debug
import statements, or set breakpoints in e.g. `GenTests`), and it will break
again at the very top of `RunSteps` after loading the selected test case. In the
event of a crash, it will also load the crash for postmortem debugging.
Note that you can add standard python3 `breakpoint()` calls to add additional
breakpoints directly in the code, too.
### Debugging other commands
By default, only the `recipes.py debug` command attaches the debugger, but you
can actually attach a debugger for ANY subcommand by setting the environment
variable `RECIPE_DEBUG_ALL=1` in addition to the `RECIPE_DEBUGGER` environment
variable (see Debugger Configuration).
### Debugger Configuration (VSCode, PyCharm)
The recipe engine also implements support for remote debuggers.
To use this, set up your debugging server as usual, and then set the following
environment variable like:
RECIPE_DEBUGGER=protocol[://host[:port]]
Valid protocols are `vscode`, `pycharm` and `pdb` (though pdb will not work with
`recipes.py test {run, train}` due to multiprocess usage).
If `host` is omitted or blank, it defaults to "localhost".
If `port` is omitted, it defaults to 5678 (which is the default for both IDEs).
Under the hood, `vscode` uses the `debugpy` library, and `pycharm` uses
`pydevd-pycharm` - Other editors which can use these remote debugging protocols
can also integrate using the RECIPE_DEBUGGER environment variable (I have
personally gotten `vscode` to work using `nvim-dap` in NeoVim).
The versions of these libraries are pinned in the `.vscode.vpython3` and
`.pycharm.vpython3` files, respectively.
## Detecting memory leaks with Pympler
To help detect memory leaks, recipe engine has a [property](#engine-properties)
named `memory_profiler.enable_snapshot`. It is false by default. If it is set
to true, the recipe engine will snapshot the memory before each step execution,
compare it with the snapshot of previous step and then print the diff to the
`$debug` log stream. This feature is backed by [Pympler] and the output in
debug log will look like as follows:
types | # objects | total size
====== | =========== | ============
list | 5399 | 553.40 KB
str | 5398 | 323.04 KB
int | 640 | 15.00 KB
[Pympler]: https://github.com/pympler/pympler
## Working with Protobuf files
The recipe engine facilitates the use of protobufs with builtin `protoc`
capabilities.
Due to the nature of `.proto` imports, the import lines in the generated python
code 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.
### Where Recipe Engine looks for .proto files
Recipe engine will look for proto files in 3 places in your recipe repo:
* Mixed among the `recipe_modules` in your repo
* Mixed among the recipes in your repo
* In a `recipe_proto` directory (adjacent to your 'recipes' and/or
`recipe_modules` directories)
For proto files which are only used in the recipe ecosystem, you should put
them either in `recipes/*` or `recipe_modules/*`. For proto files which
originate outside the recipe ecosystem (e.g. their source of truth is some
other repo), place them into the `recipe_proto` directory in an appropriate
subdirectory (so that `protoc` will find them where other protos expect to
import them).
### Protos in recipe modules
Your recipe modules can have any .proto files they want, in any subdirectory
structure that they want (the subdirectories do not need to be python modules,
i.e. they are not required to have an `__init__.py` file). So you could have:
recipe_modules/
module_A/
cool.proto
other.proto
subdir/
sub.proto
*** note
The 'package' line in the protos MUST be in the form of:
package "recipe_modules.repo_name.module_name.path.holding_file";
So if you had `.../recipe_modules/foo/path/holding_file/file.proto` in the
"build" repo, its package must be `recipe_modules.build.foo.path.holding_file`.
Note that this is the traditional way to namespace proto files in the same
directory, but that this differs from how the package line for `recipes` works
below.
***
The proto files are importable in other proto files as e.g.:
import "recipe_modules/repo_name/module_name/path/to/file.proto";
The generated protobuf libraries are importable as e.g.:
import PB.recipe_modules.repo_name.module_name.path.to.file
### Protos in the recipes folder
Your recipes may also define protos. It's required that the protos in the
recipe folder correspond 1:1 with an actual recipe. The name for this proto
file should be the recipe's name, but with '.proto' instead of '.py'. So, you
could have:
recipes/
my_recipe.py
my_recipe.proto
subdir/
sub_recipe.py
sub_recipe.proto
If you need to have common messages which are shared between recipes, put them
under the `recipe_modules` directory.
*** note
The 'package' line in the proto MUST be in the form of:
package "recipes.repo_name.path.to.file";
So if you had a proto named `.../recipes/path/to/file.proto` in the "build"
repo, its package must be "recipes.build.path.to.file".
**Note that this includes the proto file name!**
This is done because otherwise all (unrelated) recipe protos in the same
directory would have to share a namespace, and we'd like to permit common
message names like `Input` and `Output` on a per-recipe basis instead of
`RecipeNameInput`, etc.
***
The proto files are importable in other proto files as e.g.:
import "recipes/repo_name/path/to/file.proto";
The generated protobuf libraries are importable as e.g.:
import PB.recipes.repo_name.path.to.file
### The special case of recipe_engine protos
The recipe engine repo itself also has some protos defined within it's own
`recipe_engine` folder. These are the proto files [here](/recipe_engine).
The proto files are importable in other proto files as e.g.:
import "recipe_engine/file.proto";
The generated protobuf libraries are importable as e.g.:
import PB.recipe_engine.file
### The recipe_proto folder
The 'recipe_proto' directory can have arbitrary proto files in it from external
sources (i.e. from other repos), and organized using that project's folder
naming scheme. This is important to allow external proto files to work without
modification (due to `import` lines in proto files; if proto A imports
"go.chromium.org/luci/something/something.proto", then protoc needs to find
"something.proto" in the "go.chromium.org/luci/something" subdirectory).
Note that the following top-level folders are reserved under `recipe_proto`. All
of these directories are managed by the recipe engine (as documented above):
* `recipe_engine`
* `recipe_modules`
* `recipes`
These are ALSO reserved proto package namespaces, i.e. it's invalid to have
a proto under a `recipe_proto` folder whose proto package line starts with
'recipes.'.
It's invalid for two recipe repos to both define protos under their
`recipe_proto` folders with the same path. This will cause proto compilation in
the downstream repo to fail. This usually just means that the downstream repo
needs to stop including those proto files, since it will be able to import them
from the upstream repo which now includes them.
### Using generated protos in your recipes and recipe modules
Once the protos are generated, you can import them anywhere in the recipe
ecosystem by doing:
# from recipe_proto/external.example.com/repo_name/proto_name.proto
from PB.external.example.com.repo_name import proto_name
# from recipe_engine/proto_name.proto
from PB.recipe_engine import proto_name
# from repo_name.git//.../recipe_modules/module_name/proto_name.proto
from PB.recipe_modules.repo_name.module_name import proto_name
# from repo_name.git//.../recipes/recipe_name.proto
from PB.recipes.repo_name import recipe_name
## Productionizing
TODO(iannucci) - Document
### Bundling
TODO(iannucci) - Document
### Rolling
TODO(iannucci) - Document
## Recipe Philosophy
TODO(iannucci) - Document
* Recipes are glorified shell scripts
* Recipes should be functions (small set of documented inputs and outputs).
* Recipe inputs should have predictable effects on the behavior of the Recipe.
To document/discuss:
* Structured data communication to/from steps
* When to put something in a helper script or directly in the recipe
## Glossary
**recipe repo**: A git repository with an `infra/config/recipes.cfg` file.
**recipe**: An entry point into the recipes ecosystem, each recipe is a
Python file with a `RunSteps` function.
**recipe_module**: A piece of shared code that multiple recipes can use.
**DEPS**: An list of the dependencies from a recipe to recipe modules,
or from one recipe module to another.
**repo_name**: The name of a recipe repo, as indicated by the `repo_name`
field in it's `recipes.cfg` file. This is used to qualify module dependencies
from other repos.
**properties**: A JSON object that every recipe is started with; These are
the input parameters to the recipe.
**output properties**: Similar to input properties but writeable. These
properties are viewable in the LUCI UI and can be read by other systems
that ingest LUCI builds.
**PROPERTIES**: An expression of a recipe or recipe module of the properties
that it relies on.