Skip to content

Commit cd6e308

Browse files
authored
feat: add support for custom context to task-context (#971)
* refactor: split paramatrized task_context tests into separate ones This will make adding tests for `from-custom` context a bit more sane. It also corrects a small weirdness where `EXPECTED_DESCRIPTION` is a global, but it's actually just the expected value for one test. * feat: add support for custom context to task-context This is somewhat of a continuation to #968 in that it would allow for more `resolve_keyed_by` calls to be replaced by `task-context`. As a concrete example, we use [`by-release-level` in many places in gecko](https://searchfox.org/firefox-main/search?q=by-release-level&path=&case=false&regexp=false). Its value is [derived from parameters and some constants](https://searchfox.org/firefox-main/source/taskcluster/gecko_taskgraph/util/attributes.py#157). With this patch, we could register it as custom context and replace a whole bunch of `resolve_keyed_by` calls in transforms, eg: In some python code that's called at taskgraph registration: ``` @custom_context("release-level") def release_level_context(config, task): """Provide ``release-level`` ("production" or "staging"), derived from parameters, so ``by-release-level`` fields can be resolved declaratively.""" return {"release-level": release_level(config.params)} ``` And in various kinds we'd end up with things like: ``` transforms: - taskgraph.transforms.task_context task-defaults: task-context: from-custom: - release-level substitution-fields: - scopes scopes: by-release-level: production: - project:releng:ship-it:server:production - project:releng:ship-it:action:create-new-release staging: - project:releng:ship-it:server:staging - project:releng:ship-it:action:create-new-release ```
1 parent 4c4088a commit cd6e308

5 files changed

Lines changed: 278 additions & 64 deletions

File tree

docs/reference/transforms/task_context.rst

Lines changed: 81 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -98,35 +98,6 @@ with the this ``kind``:
9898
...the description will bring in the value ``foo`` from the parameters if
9999
present, or ``default`` otherwise.
100100

101-
from-file
102-
~~~~~~~~~
103-
104-
Context may also be provided from a defined yaml file. The provided file
105-
should usually only contain top level keys and values (eg: nested objects
106-
will not be interpolated - they will be substituted as text representations
107-
of the object).
108-
109-
For example, with this kind definition:
110-
111-
.. code-block:: yaml
112-
113-
tasks:
114-
build:
115-
description: my description {foo}
116-
task-context:
117-
from-file: some_file.yaml
118-
substitution-fields:
119-
- description
120-
121-
And this in ``some_file.yaml``:
122-
123-
.. code-block:: yaml
124-
125-
foo: from a file
126-
127-
...description will end up with "my description from a file".
128-
129-
130101
from-object
131102
~~~~~~~~~~~
132103

@@ -158,6 +129,86 @@ For example:
158129
This will give build1 and build2 descriptions with their ``extra_desc``
159130
included while allowing them to share the rest of their task definition.
160131

132+
from-custom
133+
~~~~~~~~~~~
134+
135+
Context may be provided by custom providers, which must be registered prior
136+
to this transform being run. This allows the creation of context that is
137+
derived from parameters, code constants, or anything else accessible to
138+
taskgraph.
139+
140+
For example, you may have a custom context handler set-up in
141+
``taskcluster/my_taskgraph/custom_context.py``:
142+
143+
.. code-block:: python
144+
145+
NON_PRODUCTION_BRANCHES = ["maple", "pine"]
146+
147+
@custom_context("release-level")
148+
def release_level_context(config, task):
149+
# Despite being level 3, some branches are not truly considered "production"
150+
# in the sense of creating releases that ship to users.
151+
if config.params["level"] == "1" or config.params["project"] in NON_PRODUCTION_BRANCHES:
152+
return "staging"
153+
return "production"
154+
155+
156+
In your ``register`` function you will need to ensure you import this module.
157+
Eg: ``taskcluster/my_taskgraph/__init__.py``:
158+
159+
.. code-block:: python
160+
161+
def register(graph_config):
162+
from my_taskgraph import custom_contexts # trigger custom task-context registration
163+
164+
165+
Now you can use ``release-level`` as context in a kind:
166+
167+
.. code-block:: yaml
168+
169+
task-defaults:
170+
task-context:
171+
from-custom:
172+
- release-level
173+
substitution-fields:
174+
- scopes
175+
176+
scopes:
177+
by-release-level:
178+
staging:
179+
- secrets:get:staging_creds
180+
production:
181+
- secrets:get:production_creds
182+
183+
184+
from-file
185+
~~~~~~~~~
186+
187+
Context may also be provided from a defined yaml file. The provided file
188+
should usually only contain top level keys and values (eg: nested objects
189+
will not be interpolated - they will be substituted as text representations
190+
of the object).
191+
192+
For example, with this kind definition:
193+
194+
.. code-block:: yaml
195+
196+
tasks:
197+
build:
198+
description: my description {foo}
199+
task-context:
200+
from-file: some_file.yaml
201+
substitution-fields:
202+
- description
203+
204+
And this in ``some_file.yaml``:
205+
206+
.. code-block:: yaml
207+
208+
foo: from a file
209+
210+
...description will end up with "my description from a file".
211+
161212
Implicit Context
162213
~~~~~~~~~~~~~~~~
163214

@@ -185,7 +236,7 @@ Keys will be resolved on ``substitution-fields`` first, then substitution
185236
will be performed on the resolved value.
186237

187238
If the same key is found in multiple places the order of precedence is as
188-
follows: ``from-parameters``, ``from-object`` keys, ``from-file`` and finally
239+
follows: ``from-parameters``, ``from-object`` keys, ``from-custom`` providers, ``from-file`` and finally
189240
implicit context.
190241

191242
That is to say: parameters will always override anything else.

src/taskgraph/transforms/task_context.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from taskgraph.transforms.base import TransformSequence
44
from taskgraph.util.schema import Schema, resolve_keyed_by
5+
from taskgraph.util.task_context import CUSTOM_CONTEXT_MAP
56
from taskgraph.util.templates import deep_get, substitute_task_fields
67
from taskgraph.util.yaml import load_yaml
78

@@ -28,6 +29,12 @@ class TaskContextConfig(Schema):
2829
from_file: Optional[str] = None
2930
# Key/value pairs to be used as task context
3031
from_object: Optional[object] = None
32+
# Retrieve task context values from registered custom providers. Each
33+
# entry is the name of a provider registered via
34+
# ``taskgraph.util.task_context.custom_context``. Providers are called
35+
# with the ``TransformConfig`` and the task being rendered, and must
36+
# return a dict of key/value pairs to add to the context.
37+
from_custom: Optional[list[str]] = None
3138
resolve_keyed_by_options: Optional[ResolveKeyedByConfigOptions] = None
3239

3340

@@ -51,6 +58,7 @@ class TaskContextSchema(Schema, forbid_unknown_fields=False, kw_only=True):
5158
# is as follows:
5259
# - Parameters
5360
# - `from-object` keys
61+
# - Custom providers (`from-custom`)
5462
# - File
5563
#
5664
# That is to say: parameters will always override anything else.
@@ -89,10 +97,18 @@ def render_task(config, tasks):
8997
if from_file:
9098
file_context = load_yaml(from_file)
9199

100+
custom_context = {}
101+
for name in sub_config.pop("from-custom", None) or []:
102+
if name not in CUSTOM_CONTEXT_MAP:
103+
raise ValueError(f"no provider found for custom context '{name}'")
104+
105+
custom_context.update(CUSTOM_CONTEXT_MAP[name](config, task))
106+
92107
fields = sub_config.pop("substitution-fields")
93108

94109
subs = {}
95110
subs.update(file_context)
111+
subs.update(custom_context)
96112
# We've popped away the configuration; everything left in `sub_config` is
97113
# substitution key/value pairs.
98114
subs.update(sub_config.pop("from-object", {}))

src/taskgraph/util/task_context.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# This Source Code Form is subject to the terms of the Mozilla Public
2+
# License, v. 2.0. If a copy of the MPL was not distributed with this
3+
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+
5+
# Define a collection of custom task-context providers.
6+
# Note: this is stored here instead of where it is used in the
7+
# `task_context` transform to give consumers a chance to register their own
8+
# providers before the `task_context` schema is created.
9+
CUSTOM_CONTEXT_MAP = {}
10+
11+
12+
def custom_context(name):
13+
def wrapper(func):
14+
assert name not in CUSTOM_CONTEXT_MAP, (
15+
f"duplicate custom_context function name {name} ({func} and {CUSTOM_CONTEXT_MAP[name]})"
16+
)
17+
CUSTOM_CONTEXT_MAP[name] = func
18+
return func
19+
20+
return wrapper

test/data/task_context.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
file: file
2+
file_and_custom: file_and_custom
23
direct_and_file: shouldn't be used
34
file_and_param: shouldn't be used
45
direct_file_and_param: shouldn't be used

0 commit comments

Comments
 (0)