Skip to content

Commit 5572498

Browse files
committed
Add builddecisionscript
Port the build-decision code to run as a scriptworker task.
1 parent 1b0c284 commit 5572498

31 files changed

+1675
-0
lines changed

builddecisionscript/README.md

Whitespace-only changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/bin/bash
2+
set -o errexit -o pipefail
3+
4+
test_var_set() {
5+
local varname=$1
6+
7+
if [[ -z "${!varname}" ]]; then
8+
echo "error: ${varname} is not set"
9+
exit 1
10+
fi
11+
}
12+
13+
test_var_set 'TASKCLUSTER_ROOT_URL'
14+
15+
export VERIFY_CHAIN_OF_TRUST=false
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
work_dir: { "$eval": "WORK_DIR" }
2+
artifact_dir: { "$eval": "ARTIFACTS_DIR" }
3+
verbose: { "$eval": "VERBOSE == 'true'" }

builddecisionscript/pyproject.toml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
[project]
2+
name = "builddecisionscript"
3+
version = "1.0.0"
4+
description = "Scriptworker script to create build decision tasks for hg-push and cron triggers"
5+
url = "https://github.com/mozilla-releng/scriptworker-scripts/"
6+
license = "MPL-2.0"
7+
readme = "README.md"
8+
authors = [
9+
{ name = "Mozilla Release Engineering", email = "release+python@mozilla.com" }
10+
]
11+
classifiers = [
12+
"Programming Language :: Python :: 3",
13+
"Programming Language :: Python :: 3.11",
14+
]
15+
dependencies = [
16+
"attrs",
17+
"json-e",
18+
"jsonschema>4.18",
19+
"pyyaml",
20+
"redo",
21+
"referencing",
22+
"requests",
23+
"scriptworker-client",
24+
"slugid",
25+
"taskcluster",
26+
]
27+
28+
[dependency-groups]
29+
dev = [
30+
"tox",
31+
"tox-uv",
32+
"coverage>=4.2",
33+
"pytest",
34+
"pytest-asyncio<1.0",
35+
"pytest-cov",
36+
"pytest-mock",
37+
"pytest-scriptworker-client",
38+
"responses",
39+
]
40+
41+
[tool.uv.sources]
42+
scriptworker-client = { workspace = true }
43+
pytest-scriptworker-client = { workspace = true }
44+
45+
[build-system]
46+
requires = ["hatchling"]
47+
build-backend = "hatchling.build"
48+
49+
[tool.hatch.build]
50+
include = [
51+
"src",
52+
]
53+
54+
[tool.hatch.build.targets.wheel.sources]
55+
"src/" = ""
56+
57+
[project.scripts]
58+
builddecisionscript = "builddecisionscript.script:main"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# This Source Code Form is subject to the terms of the Mozilla Public License,
2+
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
3+
# obtain one at http://mozilla.org/MPL/2.0/.
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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+
import logging
6+
import traceback
7+
from pathlib import Path
8+
9+
from requests.exceptions import HTTPError
10+
11+
from ..repository import NoPushesError
12+
from ..util.keyed_by import evaluate_keyed_by
13+
from ..util.schema import Schema
14+
from . import action, decision
15+
from .util import calculate_time, match_utc
16+
17+
# Functions to handle each `job.type` in `.cron.yml`. These are called with
18+
# the contents of the `job` property from `.cron.yml` and should return a
19+
# sequence of (taskId, task) tuples which will subsequently be fed to
20+
# createTask.
21+
JOB_TYPES = {
22+
"decision-task": decision.run_decision_task,
23+
"trigger-action": action.run_trigger_action,
24+
}
25+
26+
logger = logging.getLogger(__name__)
27+
28+
_cron_yml_schema = Schema.from_file(Path(__file__).with_name("schema.yml"))
29+
30+
31+
def load_jobs(repository, revision):
32+
try:
33+
cron_yml = repository.get_file(".cron.yml", revision=revision)
34+
except HTTPError as e:
35+
if e.response.status_code == 404:
36+
return {}
37+
raise
38+
_cron_yml_schema.validate(cron_yml)
39+
40+
jobs = cron_yml["jobs"]
41+
return {j["name"]: j for j in jobs}
42+
43+
44+
def should_run(job, *, time, project):
45+
if "run-on-projects" in job:
46+
if project not in job["run-on-projects"]:
47+
return False
48+
when = evaluate_keyed_by(
49+
job.get("when", []),
50+
"Cron job " + job["name"],
51+
{"project": project},
52+
)
53+
if not any(match_utc(time=time, sched=sched) for sched in when):
54+
return False
55+
return True
56+
57+
58+
def run_job(job_name, job, *, repository, push_info, cron_input=None, dry_run=False):
59+
job_type = job["job"]["type"]
60+
if job_type in JOB_TYPES:
61+
JOB_TYPES[job_type](
62+
job_name,
63+
job["job"],
64+
repository=repository,
65+
push_info=push_info,
66+
cron_input=cron_input or {},
67+
dry_run=dry_run,
68+
)
69+
else:
70+
raise Exception(f"job type {job_type} not recognized")
71+
72+
73+
def run(*, repository, branch, force_run, cron_input=None, dry_run):
74+
time = calculate_time()
75+
76+
try:
77+
push_info = repository.get_push_info(branch=branch)
78+
except NoPushesError:
79+
logger.info("No pushes found; doing nothing.")
80+
return
81+
82+
jobs = load_jobs(repository, revision=push_info["revision"])
83+
84+
if force_run:
85+
job_name = force_run
86+
logger.info(f'force-running cron job "{job_name}"')
87+
run_job(
88+
job_name,
89+
jobs[job_name],
90+
repository=repository,
91+
push_info=push_info,
92+
cron_input=cron_input,
93+
dry_run=dry_run,
94+
)
95+
return
96+
97+
failed_jobs = []
98+
for job_name, job in sorted(jobs.items()):
99+
if should_run(job, time=time, project=repository.project):
100+
logger.info(f'running cron job "{job_name}"')
101+
try:
102+
run_job(
103+
job_name,
104+
job,
105+
repository=repository,
106+
push_info=push_info,
107+
cron_input=cron_input,
108+
dry_run=dry_run,
109+
)
110+
except Exception as exc:
111+
failed_jobs.append((job_name, exc))
112+
traceback.print_exc()
113+
logger.error(f'cron job "{job_name}" run failed; continuing to next job')
114+
115+
else:
116+
logger.info(f'not running cron job "{job_name}"')
117+
118+
_format_and_raise_error_if_any(failed_jobs)
119+
120+
121+
def _format_and_raise_error_if_any(failed_jobs):
122+
if failed_jobs:
123+
failed_job_names = [job_name for job_name, _ in failed_jobs]
124+
failed_job_names_with_exceptions = (f'"{job_name}": "{exc}"' for job_name, exc in failed_jobs)
125+
raise RuntimeError(
126+
"Cron jobs {} couldn't be triggered properly. Reason(s):\n * {}\nSee logs above for details.".format(
127+
failed_job_names, "\n * ".join(failed_job_names_with_exceptions)
128+
)
129+
)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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+
import logging
6+
7+
import taskcluster
8+
9+
from ..util.http import SESSION
10+
from ..util.trigger_action import render_action
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
def find_decision_task(repository, revision):
16+
"""Given repository and revision, find the taskId of the decision task."""
17+
index = taskcluster.Index(taskcluster.optionsFromEnvironment(), session=SESSION)
18+
decision_index = f"{repository.trust_domain}.v2.{repository.project}.revision.{revision}.taskgraph.decision"
19+
logger.info("Looking for index: %s", decision_index)
20+
task_id = index.findTask(decision_index)["taskId"]
21+
logger.info("Found decision task: %s", task_id)
22+
return task_id
23+
24+
25+
def run_trigger_action(job_name, job, *, repository, push_info, cron_input=None, dry_run):
26+
action_name = job["action-name"]
27+
decision_task_id = find_decision_task(repository, push_info["revision"])
28+
29+
action_input = {}
30+
31+
if job.get("include-cron-input") and cron_input:
32+
action_input.update(cron_input)
33+
34+
if job.get("extra-input"):
35+
action_input.update(job["extra-input"])
36+
37+
hook = render_action(
38+
action_name=action_name,
39+
task_id=None,
40+
decision_task_id=decision_task_id,
41+
action_input=action_input,
42+
)
43+
44+
hook.display()
45+
if not dry_run:
46+
hook.submit()
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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+
import copy
6+
import logging
7+
import os
8+
import shlex
9+
10+
from ..decision import render_tc_yml
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
def make_arguments(job):
16+
arguments = []
17+
if "target-tasks-method" in job:
18+
arguments.append("--target-tasks-method={}".format(job["target-tasks-method"]))
19+
if job.get("optimize-target-tasks") is not None:
20+
arguments.append(
21+
"--optimize-target-tasks={}".format(
22+
str(job["optimize-target-tasks"]).lower(),
23+
)
24+
)
25+
if "include-push-tasks" in job:
26+
arguments.append("--include-push-tasks")
27+
if "rebuild-kinds" in job:
28+
for kind in job["rebuild-kinds"]:
29+
arguments.append(f"--rebuild-kind={kind}")
30+
return arguments
31+
32+
33+
def run_decision_task(job_name, job, *, repository, push_info, cron_input=None, dry_run):
34+
"""Generate a basic decision task, based on the root .taskcluster.yml"""
35+
push_info = copy.deepcopy(push_info)
36+
push_info["owner"] = "cron"
37+
38+
taskcluster_yml = repository.get_file(".taskcluster.yml", revision=push_info["revision"])
39+
40+
arguments = make_arguments(job)
41+
42+
effective_cron_input = {}
43+
if job.get("include-cron-input") and cron_input:
44+
effective_cron_input.update(cron_input)
45+
46+
cron_info = {
47+
"task_id": os.environ.get("TASK_ID", "<cron task id>"),
48+
"job_name": job_name,
49+
"job_symbol": job["treeherder-symbol"],
50+
# args are shell-quoted since they are given to `bash -c`
51+
"quoted_args": " ".join(shlex.quote(a) for a in arguments),
52+
"input": effective_cron_input,
53+
}
54+
55+
task = render_tc_yml(
56+
taskcluster_yml,
57+
taskcluster_root_url=os.environ["TASKCLUSTER_ROOT_URL"],
58+
tasks_for="cron",
59+
repository=repository.to_json(),
60+
push=push_info,
61+
cron=cron_info,
62+
)
63+
64+
task.display()
65+
if not dry_run:
66+
task.submit()

0 commit comments

Comments
 (0)