diff options
Diffstat (limited to 'taskcluster/gecko_taskgraph/actions/create_interactive.py')
-rw-r--r-- | taskcluster/gecko_taskgraph/actions/create_interactive.py | 192 |
1 files changed, 192 insertions, 0 deletions
diff --git a/taskcluster/gecko_taskgraph/actions/create_interactive.py b/taskcluster/gecko_taskgraph/actions/create_interactive.py new file mode 100644 index 0000000000..c8d196782e --- /dev/null +++ b/taskcluster/gecko_taskgraph/actions/create_interactive.py @@ -0,0 +1,192 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + + +import logging +import os +import re + +import taskcluster_urls +from taskgraph.util.taskcluster import get_root_url, get_task_definition, send_email + +from gecko_taskgraph.actions.registry import register_callback_action +from gecko_taskgraph.actions.util import create_tasks, fetch_graph_and_labels + +logger = logging.getLogger(__name__) + +EMAIL_SUBJECT = "Your Interactive Task for {label}" +EMAIL_CONTENT = """\ +As you requested, Firefox CI has created an interactive task to run {label} +on revision {revision} in {repo}. Click the button below to connect to the +task. You may need to wait for it to begin running. +""" + +### +# Security Concerns +# +# An "interactive task" is, quite literally, shell access to a worker. That +# is limited by being in a Docker container, but we assume that Docker has +# bugs so we do not want to rely on container isolation exclusively. +# +# Interactive tasks should never be allowed on hosts that build binaries +# leading to a release -- level 3 builders. +# +# Users must not be allowed to create interactive tasks for tasks above +# their own level. +# +# Interactive tasks must not have any routes that might make them appear +# in the index to be used by other production tasks. +# +# Interactive tasks should not be able to write to any docker-worker caches. + +SCOPE_WHITELIST = [ + # these are not actually secrets, and just about everything needs them + re.compile(r"^secrets:get:project/taskcluster/gecko/(hgfingerprint|hgmointernal)$"), + # public downloads are OK + re.compile(r"^docker-worker:relengapi-proxy:tooltool.download.public$"), + re.compile(r"^project:releng:services/tooltool/api/download/public$"), + # internal downloads are OK + re.compile(r"^docker-worker:relengapi-proxy:tooltool.download.internal$"), + re.compile(r"^project:releng:services/tooltool/api/download/internal$"), + # private toolchain artifacts from tasks + re.compile(r"^queue:get-artifact:project/gecko/.*$"), + # level-appropriate secrets are generally necessary to run a task; these + # also are "not that secret" - most of them are built into the resulting + # binary and could be extracted by someone with `strings`. + re.compile(r"^secrets:get:project/releng/gecko/build/level-[0-9]/\*"), + # ptracing is generally useful for interactive tasks, too! + re.compile(r"^docker-worker:feature:allowPtrace$"), + # docker-worker capabilities include loopback devices + re.compile(r"^docker-worker:capability:device:.*$"), + re.compile(r"^docker-worker:capability:privileged$"), + re.compile(r"^docker-worker:cache:gecko-level-1-checkouts.*$"), + re.compile(r"^docker-worker:cache:gecko-level-1-tooltool-cache.*$"), +] + + +def context(params): + # available for any docker-worker tasks at levels 1, 2; and for + # test tasks on level 3 (level-3 builders are firewalled off) + if int(params["level"]) < 3: + return [{"worker-implementation": "docker-worker"}] + return [{"worker-implementation": "docker-worker", "kind": "test"}] + # Windows is not supported by one-click loaners yet. See + # https://wiki.mozilla.org/ReleaseEngineering/How_To/Self_Provision_a_TaskCluster_Windows_Instance + # for instructions for using them. + + +@register_callback_action( + title="Create Interactive Task", + name="create-interactive", + symbol="create-inter", + description=("Create a a copy of the task that you can interact with"), + order=50, + context=context, + schema={ + "type": "object", + "properties": { + "notify": { + "type": "string", + "format": "email", + "title": "Who to notify of the pending interactive task", + "description": ( + "Enter your email here to get an email containing a link " + "to interact with the task" + ), + # include a default for ease of users' editing + "default": "noreply@noreply.mozilla.org", + }, + }, + "additionalProperties": False, + }, +) +def create_interactive_action(parameters, graph_config, input, task_group_id, task_id): + # fetch the original task definition from the taskgraph, to avoid + # creating interactive copies of unexpected tasks. Note that this only applies + # to docker-worker tasks, so we can assume the docker-worker payload format. + decision_task_id, full_task_graph, label_to_taskid = fetch_graph_and_labels( + parameters, graph_config + ) + task = get_task_definition(task_id) + label = task["metadata"]["name"] + + def edit(task): + if task.label != label: + return task + task_def = task.task + + # drop task routes (don't index this!) + task_def["routes"] = [] + + # only try this once + task_def["retries"] = 0 + + # short expirations, at least 3 hour maxRunTime + task_def["deadline"] = {"relative-datestamp": "12 hours"} + task_def["created"] = {"relative-datestamp": "0 hours"} + task_def["expires"] = {"relative-datestamp": "1 day"} + + # filter scopes with the SCOPE_WHITELIST + task.task["scopes"] = [ + s + for s in task.task.get("scopes", []) + if any(p.match(s) for p in SCOPE_WHITELIST) + ] + + payload = task_def["payload"] + + # make sure the task runs for long enough.. + payload["maxRunTime"] = max(3600 * 3, payload.get("maxRunTime", 0)) + + # no caches or artifacts + payload["cache"] = {} + payload["artifacts"] = {} + + # enable interactive mode + payload.setdefault("features", {})["interactive"] = True + payload.setdefault("env", {})["TASKCLUSTER_INTERACTIVE"] = "true" + + for key in task_def["payload"]["env"].keys(): + payload["env"][key] = task_def["payload"]["env"].get(key, "") + + return task + + # Create the task and any of its dependencies. This uses a new taskGroupId to avoid + # polluting the existing taskGroup with interactive tasks. + action_task_id = os.environ.get("TASK_ID") + label_to_taskid = create_tasks( + graph_config, + [label], + full_task_graph, + label_to_taskid, + parameters, + decision_task_id=action_task_id, + modifier=edit, + ) + + taskId = label_to_taskid[label] + logger.info(f"Created interactive task {taskId}; sending notification") + + if input and "notify" in input: + email = input["notify"] + # no point sending to a noreply address! + if email == "noreply@noreply.mozilla.org": + return + + info = { + "url": taskcluster_urls.ui(get_root_url(False), f"tasks/{taskId}/connect"), + "label": label, + "revision": parameters["head_rev"], + "repo": parameters["head_repository"], + } + send_email( + email, + subject=EMAIL_SUBJECT.format(**info), + content=EMAIL_CONTENT.format(**info), + link={ + "text": "Connect", + "href": info["url"], + }, + use_proxy=True, + ) |