create_interactive.py (7479B)
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 6 import logging 7 import os 8 import re 9 10 import taskcluster_urls 11 from taskgraph.util.taskcluster import get_root_url, get_task_definition 12 13 from gecko_taskgraph.actions.registry import register_callback_action 14 from gecko_taskgraph.actions.util import create_tasks, fetch_graph_and_labels 15 from gecko_taskgraph.util.constants import TEST_KINDS 16 17 logger = logging.getLogger(__name__) 18 19 EMAIL_SUBJECT = "Your Interactive Task for {label}" 20 EMAIL_CONTENT = """\ 21 As you requested, Firefox CI has created an interactive task to run {label} 22 on revision {revision} in {repo}. Click the button below to connect to the 23 task. You may need to wait for it to begin running. 24 """ 25 26 ### 27 # Security Concerns 28 # 29 # An "interactive task" is, quite literally, shell access to a worker. That 30 # is limited by being in a Docker container, but we assume that Docker has 31 # bugs so we do not want to rely on container isolation exclusively. 32 # 33 # Interactive tasks should never be allowed on hosts that build binaries 34 # leading to a release -- level 3 builders. 35 # 36 # Users must not be allowed to create interactive tasks for tasks above 37 # their own level. 38 # 39 # Interactive tasks must not have any routes that might make them appear 40 # in the index to be used by other production tasks. 41 # 42 # Interactive tasks should not be able to write to any docker-worker caches. 43 44 SCOPE_WHITELIST = [ 45 # these are not actually secrets, and just about everything needs them 46 re.compile(r"^secrets:get:project/taskcluster/gecko/(hgfingerprint|hgmointernal)$"), 47 # public downloads are OK 48 re.compile(r"^docker-worker:relengapi-proxy:tooltool.download.public$"), 49 re.compile(r"^project:releng:services/tooltool/api/download/public$"), 50 # internal downloads are OK 51 re.compile(r"^docker-worker:relengapi-proxy:tooltool.download.internal$"), 52 re.compile(r"^project:releng:services/tooltool/api/download/internal$"), 53 # private toolchain artifacts from tasks 54 re.compile(r"^queue:get-artifact:project/gecko/.*$"), 55 # level-appropriate secrets are generally necessary to run a task; these 56 # also are "not that secret" - most of them are built into the resulting 57 # binary and could be extracted by someone with `strings`. 58 re.compile(r"^secrets:get:project/releng/gecko/build/level-[0-9]/\*"), 59 # ptracing is generally useful for interactive tasks, too! 60 re.compile(r"^docker-worker:feature:allowPtrace$"), 61 # docker-worker capabilities include loopback devices 62 re.compile(r"^docker-worker:capability:device:.*$"), 63 re.compile(r"^docker-worker:capability:privileged$"), 64 re.compile(r"^docker-worker:cache:gecko-level-1-checkouts.*$"), 65 re.compile(r"^docker-worker:cache:gecko-level-1-tooltool-cache.*$"), 66 ] 67 68 69 def context(params): 70 # available for any docker-worker tasks at levels 1, 2; and for 71 # test tasks on level 3 (level-3 builders are firewalled off) 72 if int(params["level"]) < 3: 73 return [{"worker-implementation": "docker-worker"}] 74 return [ 75 {"worker-implementation": "docker-worker", "kind": kind} for kind in TEST_KINDS 76 ] 77 # Windows is not supported by one-click loaners yet. See 78 # https://wiki.mozilla.org/ReleaseEngineering/How_To/Self_Provision_a_TaskCluster_Windows_Instance 79 # for instructions for using them. 80 81 82 @register_callback_action( 83 title="Create Interactive Task", 84 name="create-interactive", 85 symbol="create-inter", 86 description=("Create a a copy of the task that you can interact with"), 87 order=50, 88 context=context, 89 schema={ 90 "type": "object", 91 "properties": { 92 "notify": { 93 "type": "string", 94 "format": "email", 95 "title": "Who to notify of the pending interactive task", 96 "description": ( 97 "Enter your email here to get an email containing a link " 98 "to interact with the task" 99 ), 100 # include a default for ease of users' editing 101 "default": "noreply@noreply.mozilla.org", 102 }, 103 }, 104 "additionalProperties": False, 105 }, 106 ) 107 def create_interactive_action(parameters, graph_config, input, task_group_id, task_id): 108 # fetch the original task definition from the taskgraph, to avoid 109 # creating interactive copies of unexpected tasks. Note that this only applies 110 # to docker-worker tasks, so we can assume the docker-worker payload format. 111 decision_task_id, full_task_graph, label_to_taskid, _ = fetch_graph_and_labels( 112 parameters, graph_config 113 ) 114 task = get_task_definition(task_id) 115 label = task["metadata"]["name"] 116 117 def edit(task): 118 if task.label != label: 119 return task 120 task_def = task.task 121 122 # drop task routes (don't index this!) 123 task_def["routes"] = [] 124 125 # only try this once 126 task_def["retries"] = 0 127 128 # short expirations, at least 3 hour maxRunTime 129 task_def["deadline"] = {"relative-datestamp": "12 hours"} 130 task_def["created"] = {"relative-datestamp": "0 hours"} 131 task_def["expires"] = {"relative-datestamp": "1 day"} 132 133 # filter scopes with the SCOPE_WHITELIST 134 task.task["scopes"] = [ 135 s 136 for s in task.task.get("scopes", []) 137 if any(p.match(s) for p in SCOPE_WHITELIST) 138 ] 139 140 payload = task_def["payload"] 141 142 # make sure the task runs for long enough.. 143 payload["maxRunTime"] = max(3600 * 3, payload.get("maxRunTime", 0)) 144 145 # no caches or artifacts 146 payload["cache"] = {} 147 payload["artifacts"] = {} 148 149 # enable interactive mode 150 payload.setdefault("features", {})["interactive"] = True 151 payload.setdefault("env", {})["TASKCLUSTER_INTERACTIVE"] = "true" 152 153 for key in task_def["payload"]["env"].keys(): 154 payload["env"][key] = task_def["payload"]["env"].get(key, "") 155 156 # add notification 157 email = input.get("notify") 158 # no point sending to a noreply address! 159 if email and email != "noreply@noreply.mozilla.org": 160 info = { 161 "url": taskcluster_urls.ui( 162 get_root_url(block_proxy=True), "tasks/${status.taskId}/connect" 163 ), 164 "label": label, 165 "revision": parameters["head_rev"], 166 "repo": parameters["head_repository"], 167 } 168 task_def.setdefault("extra", {}).setdefault("notify", {})["email"] = { 169 "subject": EMAIL_SUBJECT.format(**info), 170 "content": EMAIL_CONTENT.format(**info), 171 "link": {"text": "Connect", "href": info["url"]}, 172 } 173 task_def["routes"].append(f"notify.email.{email}.on-pending") 174 175 return task 176 177 # Create the task and any of its dependencies. This uses a new taskGroupId to avoid 178 # polluting the existing taskGroup with interactive tasks. 179 action_task_id = os.environ.get("TASK_ID") 180 label_to_taskid = create_tasks( 181 graph_config, 182 [label], 183 full_task_graph, 184 label_to_taskid, 185 parameters, 186 decision_task_id=action_task_id, 187 modifier=edit, 188 ) 189 190 taskId = label_to_taskid[label] 191 logger.info(f"Created interactive task {taskId}")