brizental pushed to branch tor-browser-153.5.0esr-16.0-1 at The Tor Project / Applications / Tor Browser
Commits:
-
230b2e7d
by Beatriz Rizental at 2026-10-09T06:15:16-03:00
-
6b009dd1
by Beatriz Rizental at 2026-10-09T06:15:17-03:00
-
fcf7cd1e
by Beatriz Rizental at 2026-10-09T06:15:17-03:00
-
014bb2ac
by Beatriz Rizental at 2026-10-09T06:15:18-03:00
-
7cd8a546
by Beatriz Rizental at 2026-10-09T06:15:19-03:00
-
0cece149
by Beatriz Rizental at 2026-10-09T06:16:59-03:00
15 changed files:
- .gitignore
- python/mach/mach/command_util.py
- tools/moz.build
- + tools/yeet/__init__.py
- + tools/yeet/auth.py
- + tools/yeet/common.py
- + tools/yeet/mach_commands.py
- + tools/yeet/remote.py
- + tools/yeet/tests/python/conftest.py
- + tools/yeet/tests/python/python.toml
- + tools/yeet/tests/python/test_auth.py
- + tools/yeet/tests/python/test_common.py
- + tools/yeet/tests/python/test_remote.py
- + tools/yeet/tests/python/test_upload.py
- + tools/yeet/upload.py
Changes:
| ... | ... | @@ -426,8 +426,10 @@ CLAUDE.local.md |
| 426 | 426 | # Other files modified at build time
|
| 427 | 427 | mobile/android/.experimenter.json
|
| 428 | 428 | |
| 429 | +# GitLab project and token saved by `mach yeet auth`
|
|
| 430 | +/.yeet.json
|
|
| 431 | + |
|
| 429 | 432 | # Tor libraries for local builds
|
| 430 | 433 | mobile/android/fenix/tools/nimbus-fml
|
| 431 | 434 | mobile/android/fenix/app/tor-expert-bundle.aar
|
| 432 | 435 | mobile/android/fenix/app/src/main/assets/extensions/{73a6fe31-595d-460b-a920-fcc0f8843232}.xpi |
| 433 | - |
| ... | ... | @@ -291,6 +291,7 @@ MACH_COMMANDS = { |
| 291 | 291 | "xpcshell-test": MachCommandReference(
|
| 292 | 292 | "testing/xpcshell/mach_commands.py", ["test"]
|
| 293 | 293 | ),
|
| 294 | + "yeet": MachCommandReference("tools/yeet/mach_commands.py"),
|
|
| 294 | 295 | }
|
| 295 | 296 | |
| 296 | 297 |
| ... | ... | @@ -75,4 +75,5 @@ PYTHON_UNITTEST_MANIFESTS += [ |
| 75 | 75 | "lint/test/python.toml",
|
| 76 | 76 | "tryselect/test/python.toml",
|
| 77 | 77 | "update-packaging/test/python.toml",
|
| 78 | + "yeet/tests/python/python.toml",
|
|
| 78 | 79 | ] |
| 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/. |
| 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 json
|
|
| 6 | +import os
|
|
| 7 | +import subprocess
|
|
| 8 | +import urllib.error
|
|
| 9 | +import urllib.parse
|
|
| 10 | +import urllib.request
|
|
| 11 | +from pathlib import Path
|
|
| 12 | + |
|
| 13 | +from yeet.common import (
|
|
| 14 | + CANONICAL_GITLAB_PROJECT,
|
|
| 15 | + CANONICAL_GITLAB_REPO_NAME,
|
|
| 16 | + DEFAULT_SSH_HOST,
|
|
| 17 | + GITLAB_API_BASE,
|
|
| 18 | + TERM,
|
|
| 19 | + YEET_CONFIG_FILENAME,
|
|
| 20 | + prompt,
|
|
| 21 | +)
|
|
| 22 | + |
|
| 23 | + |
|
| 24 | +def _resolve_project(raw_username):
|
|
| 25 | + """Build a fork project path out of a GitLab username. Raises ValueError
|
|
| 26 | + if it's empty or the canonical repo's own namespace.
|
|
| 27 | + """
|
|
| 28 | + username = raw_username.strip()
|
|
| 29 | + if not username:
|
|
| 30 | + raise ValueError("A GitLab username is required.")
|
|
| 31 | + |
|
| 32 | + project = f"{username}/{CANONICAL_GITLAB_REPO_NAME}"
|
|
| 33 | + if project.lower() == CANONICAL_GITLAB_PROJECT.lower():
|
|
| 34 | + raise ValueError(
|
|
| 35 | + "That's the canonical repo's namespace, not a fork. Use your "
|
|
| 36 | + "own GitLab username instead."
|
|
| 37 | + )
|
|
| 38 | + |
|
| 39 | + return project
|
|
| 40 | + |
|
| 41 | + |
|
| 42 | +def _prompt_fork():
|
|
| 43 | + print()
|
|
| 44 | + print(
|
|
| 45 | + TERM.yellow(
|
|
| 46 | + f"""`mach yeet remote` runs against your own fork of
|
|
| 47 | +{CANONICAL_GITLAB_PROJECT}, never against the canonical repo
|
|
| 48 | +directly -- that keeps CI runs off the shared project.
|
|
| 49 | + |
|
| 50 | +If you haven't forked it yet, fork it first at:
|
|
| 51 | + https://gitlab.torproject.org/{CANONICAL_GITLAB_PROJECT}/-/forks/new
|
|
| 52 | + |
|
| 53 | +Keep your fork up to date too. An outdated fork's .gitlab-ci.yml may be missing
|
|
| 54 | +pieces this command relies on, so pipelines could fail or come back empty."""
|
|
| 55 | + )
|
|
| 56 | + )
|
|
| 57 | + print()
|
|
| 58 | + |
|
| 59 | + while True:
|
|
| 60 | + try:
|
|
| 61 | + return _resolve_project(prompt("Your GitLab username: "))
|
|
| 62 | + except ValueError as e:
|
|
| 63 | + print(TERM.red(f"ERROR! {e}"))
|
|
| 64 | + |
|
| 65 | + |
|
| 66 | +def _gitlab_token_instructions(project):
|
|
| 67 | + return f"""
|
|
| 68 | +Now you need a pipeline trigger token for your fork:
|
|
| 69 | + {project}
|
|
| 70 | + |
|
| 71 | +This is NOT a personal access token -- it's specific to this one project, and
|
|
| 72 | +it's the only kind that can actually run CI jobs here (their rules require it):
|
|
| 73 | + |
|
| 74 | + 1. Go to https://gitlab.torproject.org/{project}/-/settings/ci_cd#js-pipeline-triggers
|
|
| 75 | + 2. Expand "Pipeline trigger tokens" and add a new one.
|
|
| 76 | + 3. Paste the token below.
|
|
| 77 | + |
|
| 78 | +The project and token will be saved to {YEET_CONFIG_FILENAME} at the root of the repository
|
|
| 79 | +and are not committed to it.
|
|
| 80 | +""".strip()
|
|
| 81 | + |
|
| 82 | + |
|
| 83 | +def _print_ssh_instructions():
|
|
| 84 | + print()
|
|
| 85 | + print(
|
|
| 86 | + TERM.yellow(
|
|
| 87 | + f"""`mach yeet` also uploads to and runs commands on a build server over SSH
|
|
| 88 | +({DEFAULT_SSH_HOST} by default). Uploaded files are served from your
|
|
| 89 | +account's public_html there, at:
|
|
| 90 | + https://<host>/~<username>
|
|
| 91 | + |
|
| 92 | +Anything special about the connection -- port, jump host, a hardware key's
|
|
| 93 | +PKCS11 provider, agent forwarding -- belongs in your ~/.ssh/config, not here:
|
|
| 94 | +we just shell out to `ssh`. Whatever already works on your command line works
|
|
| 95 | +for `mach yeet` too."""
|
|
| 96 | + )
|
|
| 97 | + )
|
|
| 98 | + print()
|
|
| 99 | + |
|
| 100 | + |
|
| 101 | +def _prompt_ssh_host():
|
|
| 102 | + return prompt(f"SSH host [{DEFAULT_SSH_HOST}]: ").strip() or DEFAULT_SSH_HOST
|
|
| 103 | + |
|
| 104 | + |
|
| 105 | +def _prompt_ssh_username(ssh_host):
|
|
| 106 | + while True:
|
|
| 107 | + username = prompt(f"Your username on {ssh_host}: ").strip()
|
|
| 108 | + if username:
|
|
| 109 | + return username
|
|
| 110 | + print(TERM.red("ERROR! Username cannot be empty."))
|
|
| 111 | + |
|
| 112 | + |
|
| 113 | +def _prompt_token():
|
|
| 114 | + while True:
|
|
| 115 | + token = prompt("GitLab token: ", secret=True).strip()
|
|
| 116 | + if token:
|
|
| 117 | + return token
|
|
| 118 | + print(TERM.red("ERROR! Token cannot be empty."))
|
|
| 119 | + |
|
| 120 | + |
|
| 121 | +def _verify_ssh(target):
|
|
| 122 | + """Try an actual SSH connection. stdin is /dev/null so ssh can't swallow
|
|
| 123 | + input buffered for us; its touch/PIN/passphrase prompts go through
|
|
| 124 | + /dev/tty and still reach the user.
|
|
| 125 | + """
|
|
| 126 | + try:
|
|
| 127 | + result = subprocess.run(
|
|
| 128 | + ["ssh", target, "true"],
|
|
| 129 | + stdin=subprocess.DEVNULL,
|
|
| 130 | + check=False,
|
|
| 131 | + )
|
|
| 132 | + except FileNotFoundError:
|
|
| 133 | + print()
|
|
| 134 | + print(TERM.red("ERROR! No `ssh` binary found on PATH."))
|
|
| 135 | + return False
|
|
| 136 | + return result.returncode == 0
|
|
| 137 | + |
|
| 138 | + |
|
| 139 | +def _verify_auth(project, token):
|
|
| 140 | + """Check that `project`/`token` are accepted by GitLab, without
|
|
| 141 | + actually triggering a pipeline.
|
|
| 142 | + |
|
| 143 | + GitLab authenticates the trigger token before validating the rest of
|
|
| 144 | + the request, so posting to the trigger endpoint without the required
|
|
| 145 | + `ref` param never queues a pipeline: a valid token/project combination
|
|
| 146 | + gets rejected with 400 (missing ref) instead, while an invalid token
|
|
| 147 | + or unknown project is rejected with 401/404 before `ref` is even
|
|
| 148 | + looked at.
|
|
| 149 | + """
|
|
| 150 | + url = f"{GITLAB_API_BASE}/projects/{urllib.parse.quote(project, safe='')}/trigger/pipeline"
|
|
| 151 | + data = urllib.parse.urlencode({"token": token}).encode()
|
|
| 152 | + request = urllib.request.Request(url, data=data, method="POST")
|
|
| 153 | + try:
|
|
| 154 | + with urllib.request.urlopen(request):
|
|
| 155 | + pass
|
|
| 156 | + except urllib.error.HTTPError as e:
|
|
| 157 | + return e.code == 400
|
|
| 158 | + else:
|
|
| 159 | + return True
|
|
| 160 | + |
|
| 161 | + |
|
| 162 | +def run(command_context, **kwargs):
|
|
| 163 | + gitlab_username = kwargs.get("gitlab_username")
|
|
| 164 | + if gitlab_username is not None:
|
|
| 165 | + try:
|
|
| 166 | + project = _resolve_project(gitlab_username)
|
|
| 167 | + except ValueError as e:
|
|
| 168 | + print(TERM.red(f"ERROR! --gitlab-username: {e}"))
|
|
| 169 | + return 1
|
|
| 170 | + else:
|
|
| 171 | + project = _prompt_fork()
|
|
| 172 | + |
|
| 173 | + gitlab_token = kwargs.get("gitlab_token")
|
|
| 174 | + if gitlab_token is not None:
|
|
| 175 | + token = gitlab_token.strip()
|
|
| 176 | + if not token:
|
|
| 177 | + print()
|
|
| 178 | + print(TERM.red("ERROR! --gitlab-token was empty."))
|
|
| 179 | + return 1
|
|
| 180 | + else:
|
|
| 181 | + print()
|
|
| 182 | + print(_gitlab_token_instructions(project))
|
|
| 183 | + print()
|
|
| 184 | + token = _prompt_token()
|
|
| 185 | + |
|
| 186 | + verify = kwargs.get("verify", True)
|
|
| 187 | + if verify:
|
|
| 188 | + print()
|
|
| 189 | + print("Verifying credentials with GitLab...")
|
|
| 190 | + try:
|
|
| 191 | + verified = _verify_auth(project, token)
|
|
| 192 | + except urllib.error.URLError as e:
|
|
| 193 | + print()
|
|
| 194 | + print(TERM.red(f"ERROR! Could not reach GitLab: {e}"))
|
|
| 195 | + return 1
|
|
| 196 | + |
|
| 197 | + if not verified:
|
|
| 198 | + print()
|
|
| 199 | + print(
|
|
| 200 | + TERM.red(
|
|
| 201 | + "ERROR! GitLab rejected that project/token combination. "
|
|
| 202 | + "Double-check the fork name and the trigger token."
|
|
| 203 | + )
|
|
| 204 | + )
|
|
| 205 | + return 1
|
|
| 206 | + print(TERM.green("Credentials verified."))
|
|
| 207 | + |
|
| 208 | + ssh_host = kwargs.get("ssh_host")
|
|
| 209 | + ssh_username = kwargs.get("ssh_username")
|
|
| 210 | + if ssh_host is None or ssh_username is None:
|
|
| 211 | + _print_ssh_instructions()
|
|
| 212 | + |
|
| 213 | + if ssh_host is not None:
|
|
| 214 | + ssh_host = ssh_host.strip()
|
|
| 215 | + if not ssh_host:
|
|
| 216 | + print()
|
|
| 217 | + print(TERM.red("ERROR! --ssh-host was empty."))
|
|
| 218 | + return 1
|
|
| 219 | + else:
|
|
| 220 | + ssh_host = _prompt_ssh_host()
|
|
| 221 | + |
|
| 222 | + if ssh_username is not None:
|
|
| 223 | + ssh_username = ssh_username.strip()
|
|
| 224 | + if not ssh_username:
|
|
| 225 | + print()
|
|
| 226 | + print(TERM.red("ERROR! --ssh-user was empty."))
|
|
| 227 | + return 1
|
|
| 228 | + else:
|
|
| 229 | + ssh_username = _prompt_ssh_username(ssh_host)
|
|
| 230 | + |
|
| 231 | + if verify:
|
|
| 232 | + ssh_target = f"{ssh_username}@{ssh_host}"
|
|
| 233 | + print()
|
|
| 234 | + print(f"Verifying SSH access to {ssh_target}...")
|
|
| 235 | + if not _verify_ssh(ssh_target):
|
|
| 236 | + print()
|
|
| 237 | + print(
|
|
| 238 | + TERM.red(
|
|
| 239 | + f"ERROR! Could not SSH into {ssh_target}. Make sure you "
|
|
| 240 | + "can `ssh` there yourself first."
|
|
| 241 | + )
|
|
| 242 | + )
|
|
| 243 | + return 1
|
|
| 244 | + print(TERM.green("SSH access verified."))
|
|
| 245 | + |
|
| 246 | + tokens_path = Path(command_context.topsrcdir) / YEET_CONFIG_FILENAME
|
|
| 247 | + file_desc = os.open(
|
|
| 248 | + tokens_path, flags=os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode=0o600
|
|
| 249 | + )
|
|
| 250 | + with open(file_desc, "w") as f:
|
|
| 251 | + json.dump(
|
|
| 252 | + {
|
|
| 253 | + "project": project,
|
|
| 254 | + "token": token,
|
|
| 255 | + "ssh_host": ssh_host,
|
|
| 256 | + "ssh_user": ssh_username,
|
|
| 257 | + },
|
|
| 258 | + f,
|
|
| 259 | + )
|
|
| 260 | + os.chmod(tokens_path, 0o600)
|
|
| 261 | + |
|
| 262 | + print()
|
|
| 263 | + print(TERM.green(f"Saved to {tokens_path}"))
|
|
| 264 | + return 0 |
| 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 getpass
|
|
| 6 | +import json
|
|
| 7 | +import sys
|
|
| 8 | +from pathlib import Path
|
|
| 9 | + |
|
| 10 | +from mozterm import Terminal
|
|
| 11 | + |
|
| 12 | +TERM = Terminal()
|
|
| 13 | + |
|
| 14 | +GITLAB_API_BASE = "https://gitlab.torproject.org/api/v4"
|
|
| 15 | + |
|
| 16 | +# The canonical repo. `mach yeet remote` must never target this directly --
|
|
| 17 | +# only a fork of it, so CI runs don't pile up on the shared project.
|
|
| 18 | +CANONICAL_GITLAB_NAMESPACE = "tpo/applications"
|
|
| 19 | +CANONICAL_GITLAB_REPO_NAME = "tor-browser-bundle-testsuite"
|
|
| 20 | +CANONICAL_GITLAB_PROJECT = f"{CANONICAL_GITLAB_NAMESPACE}/{CANONICAL_GITLAB_REPO_NAME}"
|
|
| 21 | + |
|
| 22 | +# Project and token saved by `mach yeet auth`.
|
|
| 23 | +YEET_CONFIG_FILENAME = ".yeet.json"
|
|
| 24 | + |
|
| 25 | +# The default server `mach yeet` uploads to and runs commands on over SSH.
|
|
| 26 | +# Uploaded files are served from the user's public_html on it.
|
|
| 27 | +DEFAULT_SSH_HOST = "tb-build-03.torproject.org"
|
|
| 28 | + |
|
| 29 | + |
|
| 30 | +def prompt(message, secret=False):
|
|
| 31 | + """Read a line of user input, first discarding anything typed before the
|
|
| 32 | + prompt appeared so stray keystrokes aren't taken as the answer.
|
|
| 33 | + """
|
|
| 34 | + if sys.stdin.isatty():
|
|
| 35 | + try:
|
|
| 36 | + import termios
|
|
| 37 | + |
|
| 38 | + termios.tcflush(sys.stdin, termios.TCIFLUSH)
|
|
| 39 | + except ImportError:
|
|
| 40 | + pass
|
|
| 41 | + if secret:
|
|
| 42 | + return getpass.getpass(message)
|
|
| 43 | + return input(message)
|
|
| 44 | + |
|
| 45 | + |
|
| 46 | +def confirm(message):
|
|
| 47 | + """Ask a yes/no question, defaulting to no, until a valid answer is given."""
|
|
| 48 | + while True:
|
|
| 49 | + answer = prompt(f"{message} [y/N]: ").strip().lower()
|
|
| 50 | + if answer in ("y", "yes"):
|
|
| 51 | + return True
|
|
| 52 | + if answer in ("", "n", "no"):
|
|
| 53 | + return False
|
|
| 54 | + print(TERM.red("ERROR! Please answer 'y' or 'n'."))
|
|
| 55 | + |
|
| 56 | + |
|
| 57 | +def read_config(topsrcdir):
|
|
| 58 | + """Read the project/token/ssh_* config saved by `mach yeet auth`, or
|
|
| 59 | + None if it hasn't been run yet.
|
|
| 60 | + """
|
|
| 61 | + config_path = Path(topsrcdir) / YEET_CONFIG_FILENAME
|
|
| 62 | + if not config_path.is_file():
|
|
| 63 | + return None
|
|
| 64 | + |
|
| 65 | + with open(config_path) as f:
|
|
| 66 | + return json.load(f) |
| 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 | +from mach.decorators import Command, CommandArgument, SubCommand # noqa: I001
|
|
| 6 | + |
|
| 7 | +from yeet import remote
|
|
| 8 | + |
|
| 9 | + |
|
| 10 | +@Command(
|
|
| 11 | + "yeet",
|
|
| 12 | + category="ci",
|
|
| 13 | + description="TODO: build / test remotely from the current state of the repo.",
|
|
| 14 | +)
|
|
| 15 | +def yeet(command_context):
|
|
| 16 | + # TODO: this will be the main "yeet" command -- push, build and (maybe)
|
|
| 17 | + # test remotely from the current state of the local repo, rather than
|
|
| 18 | + # requiring an already-published build like `yeet remote` does.
|
|
| 19 | + pass
|
|
| 20 | + |
|
| 21 | + |
|
| 22 | +@SubCommand(
|
|
| 23 | + "yeet",
|
|
| 24 | + "remote",
|
|
| 25 | + description="Trigger a tor-browser-bundle-testsuite CI job against an "
|
|
| 26 | + "already-published build, for a single platform.",
|
|
| 27 | +)
|
|
| 28 | +@CommandArgument(
|
|
| 29 | + "--platform",
|
|
| 30 | + default=None,
|
|
| 31 | + choices=remote.PLATFORMS,
|
|
| 32 | + help="Platform to test. Prompted for if not provided.",
|
|
| 33 | +)
|
|
| 34 | +@CommandArgument(
|
|
| 35 | + "--channel",
|
|
| 36 | + default=None,
|
|
| 37 | + choices=remote.ANDROID_CHANNELS,
|
|
| 38 | + help="Android build channel/flavor to install (only used with "
|
|
| 39 | + "--platform android_x86_64). Prompted for if not provided.",
|
|
| 40 | +)
|
|
| 41 | +@CommandArgument(
|
|
| 42 | + "installer_url",
|
|
| 43 | + help="URL of the installer to test, or a local file path to upload first.",
|
|
| 44 | +)
|
|
| 45 | +@CommandArgument(
|
|
| 46 | + "--sha256sums-url", default=None, help="URL of the build's sha256sums file."
|
|
| 47 | +)
|
|
| 48 | +@CommandArgument(
|
|
| 49 | + "--artifacts-url",
|
|
| 50 | + default=None,
|
|
| 51 | + help="URL of the build's artifacts directory. Inferred from the "
|
|
| 52 | + "in-tree browser version and --platform if not provided.",
|
|
| 53 | +)
|
|
| 54 | +@CommandArgument(
|
|
| 55 | + "--mozharness-url",
|
|
| 56 | + default=None,
|
|
| 57 | + help="URL of the mozharness.zip to use. Inferred from the in-tree "
|
|
| 58 | + "browser version and --platform if not provided.",
|
|
| 59 | +)
|
|
| 60 | +@CommandArgument(
|
|
| 61 | + "--version",
|
|
| 62 | + default=None,
|
|
| 63 | + help="Browser version to use when inferring --artifacts-url and "
|
|
| 64 | + "--mozharness-url. Defaults to the in-tree version "
|
|
| 65 | + "(browser/config/version.txt).",
|
|
| 66 | +)
|
|
| 67 | +@CommandArgument(
|
|
| 68 | + "--ref",
|
|
| 69 | + default="main",
|
|
| 70 | + help="tor-browser-bundle-testsuite ref to run on. Must have a "
|
|
| 71 | + ".gitlab-ci.yml, which the default branch may not.",
|
|
| 72 | +)
|
|
| 73 | +@CommandArgument(
|
|
| 74 | + "--tag",
|
|
| 75 | + dest="tags",
|
|
| 76 | + action="append",
|
|
| 77 | + default=None,
|
|
| 78 | + help="Test tag to filter tests by (can be passed multiple times, e.g. "
|
|
| 79 | + "--tag tor --tag base-browser). Defaults to '--tag tor --tag base-browser'.",
|
|
| 80 | +)
|
|
| 81 | +@CommandArgument(
|
|
| 82 | + "--dry-run",
|
|
| 83 | + action="store_true",
|
|
| 84 | + default=False,
|
|
| 85 | + help="Print the curl command instead of triggering the pipeline.",
|
|
| 86 | +)
|
|
| 87 | +@CommandArgument(
|
|
| 88 | + "--yes",
|
|
| 89 | + "-y",
|
|
| 90 | + dest="assume_yes",
|
|
| 91 | + action="store_true",
|
|
| 92 | + default=False,
|
|
| 93 | + help="Upload a local installer without asking for confirmation.",
|
|
| 94 | +)
|
|
| 95 | +def yeet_remote(command_context, **kwargs):
|
|
| 96 | + # With action="append", argparse appends to a non-None default instead of
|
|
| 97 | + # replacing it, so the default tags are applied here only if none were given.
|
|
| 98 | + if kwargs["tags"] is None:
|
|
| 99 | + kwargs["tags"] = ["tor", "base-browser"]
|
|
| 100 | + return remote.run(command_context, **kwargs)
|
|
| 101 | + |
|
| 102 | + |
|
| 103 | +@SubCommand(
|
|
| 104 | + "yeet",
|
|
| 105 | + "auth",
|
|
| 106 | + description="Save a GitLab project and access token for running CI jobs.",
|
|
| 107 | +)
|
|
| 108 | +@CommandArgument(
|
|
| 109 | + "--no-verify",
|
|
| 110 | + dest="verify",
|
|
| 111 | + action="store_false",
|
|
| 112 | + help="Skip checking the GitLab and SSH credentials before saving them.",
|
|
| 113 | +)
|
|
| 114 | +@CommandArgument(
|
|
| 115 | + "--gitlab-username",
|
|
| 116 | + default=None,
|
|
| 117 | + help="GitLab username to use, skipping that prompt.",
|
|
| 118 | +)
|
|
| 119 | +@CommandArgument(
|
|
| 120 | + "--gitlab-token",
|
|
| 121 | + default=None,
|
|
| 122 | + help=(
|
|
| 123 | + "GitLab pipeline trigger token to use, skipping that prompt. Note "
|
|
| 124 | + "this may end up in your shell history."
|
|
| 125 | + ),
|
|
| 126 | +)
|
|
| 127 | +@CommandArgument(
|
|
| 128 | + "--ssh-user",
|
|
| 129 | + dest="ssh_username",
|
|
| 130 | + default=None,
|
|
| 131 | + help="Username on the build server, skipping that prompt.",
|
|
| 132 | +)
|
|
| 133 | +@CommandArgument(
|
|
| 134 | + "--ssh-host",
|
|
| 135 | + default=None,
|
|
| 136 | + help="Build server to upload to and run commands on over SSH, skipping that prompt.",
|
|
| 137 | +)
|
|
| 138 | +def yeet_auth(command_context, **kwargs):
|
|
| 139 | + from yeet import auth
|
|
| 140 | + |
|
| 141 | + return auth.run(command_context, **kwargs) |
| 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 json
|
|
| 6 | +import os
|
|
| 7 | +import shlex
|
|
| 8 | +import urllib.error
|
|
| 9 | +import urllib.parse
|
|
| 10 | +import urllib.request
|
|
| 11 | + |
|
| 12 | +from yeet import upload
|
|
| 13 | +from yeet.common import (
|
|
| 14 | + GITLAB_API_BASE,
|
|
| 15 | + TERM,
|
|
| 16 | + prompt,
|
|
| 17 | + read_config,
|
|
| 18 | +)
|
|
| 19 | + |
|
| 20 | +PLATFORM_TOOLCHAIN_DIR = {
|
|
| 21 | + "debian_x86_64": "linux-x86_64",
|
|
| 22 | + "windows_x86_64": "windows-x86_64",
|
|
| 23 | + "macos_x86_64": "macos-x86_64",
|
|
| 24 | + "android_x86_64": "android-x86_64",
|
|
| 25 | +}
|
|
| 26 | + |
|
| 27 | +PLATFORMS = list(PLATFORM_TOOLCHAIN_DIR.keys())
|
|
| 28 | + |
|
| 29 | +GITLAB_REQUEST_TIMEOUT_SECONDS = 30
|
|
| 30 | + |
|
| 31 | +TOOLCHAINS_URL_BASE = "https://nightlies.tbb.torproject.org/nightly-builds/toolchains"
|
|
| 32 | + |
|
| 33 | +# Each Android build channel/flavor ships under its own package name, so the
|
|
| 34 | +# pipeline needs to be told which one to install.
|
|
| 35 | +ANDROID_CHANNEL_PACKAGE_NAMES = {
|
|
| 36 | + "debug": "org.torproject.torbrowser_debug",
|
|
| 37 | + "nightly": "org.torproject.torbrowser_nightly",
|
|
| 38 | + "alpha": "org.torproject.torbrowser_alpha",
|
|
| 39 | + "release": "org.torproject.torbrowser",
|
|
| 40 | +}
|
|
| 41 | +ANDROID_CHANNELS = list(ANDROID_CHANNEL_PACKAGE_NAMES)
|
|
| 42 | + |
|
| 43 | + |
|
| 44 | +def _read_browser_version(topsrcdir):
|
|
| 45 | + version_path = os.path.join(topsrcdir, "browser", "config", "version.txt")
|
|
| 46 | + with open(version_path) as f:
|
|
| 47 | + return f.read().strip()
|
|
| 48 | + |
|
| 49 | + |
|
| 50 | +def _resolve_version(topsrcdir, version):
|
|
| 51 | + if version:
|
|
| 52 | + return version
|
|
| 53 | + |
|
| 54 | + version = _read_browser_version(topsrcdir)
|
|
| 55 | + print(
|
|
| 56 | + TERM.yellow(
|
|
| 57 | + f"""Inferred platform version {version} from browser/config/version.txt.
|
|
| 58 | +Pass --version if this isn't the version you want to test."""
|
|
| 59 | + )
|
|
| 60 | + )
|
|
| 61 | + return version
|
|
| 62 | + |
|
| 63 | + |
|
| 64 | +def _artifacts_url(version, platform):
|
|
| 65 | + return f"{TOOLCHAINS_URL_BASE}/{version}/{PLATFORM_TOOLCHAIN_DIR[platform]}/"
|
|
| 66 | + |
|
| 67 | + |
|
| 68 | +def _mozharness_url(version, platform):
|
|
| 69 | + return f"{_artifacts_url(version, platform)}mozharness.zip"
|
|
| 70 | + |
|
| 71 | + |
|
| 72 | +def _curl_command(url, headers, data):
|
|
| 73 | + args = ["curl", "--request", "POST"]
|
|
| 74 | + for name, value in headers.items():
|
|
| 75 | + args += ["--header", f"{name}: {value}"]
|
|
| 76 | + args += ["--data", data.decode("utf-8"), url]
|
|
| 77 | + return shlex.join(args)
|
|
| 78 | + |
|
| 79 | + |
|
| 80 | +def _resolve_installer_url(topsrcdir, installer, platform, assume_yes, dry_run):
|
|
| 81 | + """Accept either an installer URL directly, or a local file path to
|
|
| 82 | + upload first. On a dry run a local file is not uploaded, and a
|
|
| 83 | + placeholder URL is returned instead. Raises whatever
|
|
| 84 | + `upload.run` raises on failure.
|
|
| 85 | + """
|
|
| 86 | + if urllib.parse.urlparse(installer).scheme in ("http", "https"):
|
|
| 87 | + return installer
|
|
| 88 | + if dry_run:
|
|
| 89 | + file_path = upload.resolve_file(installer)
|
|
| 90 | + print(TERM.yellow(f"Dry run: not uploading {file_path}."))
|
|
| 91 | + return f"<URL of uploaded {file_path.name}>"
|
|
| 92 | + return upload.run(topsrcdir, installer, platform, assume_yes=assume_yes)
|
|
| 93 | + |
|
| 94 | + |
|
| 95 | +def _prompt_platform():
|
|
| 96 | + print()
|
|
| 97 | + print(
|
|
| 98 | + TERM.bold(
|
|
| 99 | + "Select the platform you are testing for (if not listed, tests are not supported yet):"
|
|
| 100 | + )
|
|
| 101 | + )
|
|
| 102 | + for i, platform in enumerate(PLATFORMS, 1):
|
|
| 103 | + print(f" {TERM.cyan(str(i))}) {platform}")
|
|
| 104 | + print()
|
|
| 105 | + |
|
| 106 | + while True:
|
|
| 107 | + choice = prompt(f"Platform [1-{len(PLATFORMS)}]: ").strip()
|
|
| 108 | + if choice.isdigit() and 1 <= int(choice) <= len(PLATFORMS):
|
|
| 109 | + print()
|
|
| 110 | + return PLATFORMS[int(choice) - 1]
|
|
| 111 | + print(TERM.red(f"ERROR! Please enter a number between 1 and {len(PLATFORMS)}."))
|
|
| 112 | + |
|
| 113 | + |
|
| 114 | +def _prompt_channel():
|
|
| 115 | + print()
|
|
| 116 | + print(TERM.bold("Select the Android build channel/flavor you are testing:"))
|
|
| 117 | + for i, channel in enumerate(ANDROID_CHANNELS, 1):
|
|
| 118 | + print(f" {TERM.cyan(str(i))}) {channel}")
|
|
| 119 | + print()
|
|
| 120 | + |
|
| 121 | + while True:
|
|
| 122 | + choice = prompt(f"Channel [1-{len(ANDROID_CHANNELS)}]: ").strip()
|
|
| 123 | + if choice.isdigit() and 1 <= int(choice) <= len(ANDROID_CHANNELS):
|
|
| 124 | + print()
|
|
| 125 | + return ANDROID_CHANNELS[int(choice) - 1]
|
|
| 126 | + print(
|
|
| 127 | + TERM.red(
|
|
| 128 | + f"ERROR! Please enter a number between 1 and {len(ANDROID_CHANNELS)}."
|
|
| 129 | + )
|
|
| 130 | + )
|
|
| 131 | + |
|
| 132 | + |
|
| 133 | +def run(command_context, **kwargs):
|
|
| 134 | + # TODO(tor-browser-bundle-testsuite#40120): this runs whatever the pipeline
|
|
| 135 | + # runs, indiscriminately -- fine for now since marionette is the only suite
|
|
| 136 | + # it has, but once others (xpcshell, cppunittest, mochitest, ...) exist, add
|
|
| 137 | + # a --suite argument here and thread it into `inputs` so a run can be scoped
|
|
| 138 | + # to just one.
|
|
| 139 | + config = read_config(command_context.topsrcdir)
|
|
| 140 | + if config is None:
|
|
| 141 | + print(TERM.red("No GitLab fork/token found, run `mach yeet auth` first."))
|
|
| 142 | + return 1
|
|
| 143 | + |
|
| 144 | + installer_url = kwargs["installer_url"].strip()
|
|
| 145 | + if not installer_url:
|
|
| 146 | + print(TERM.red("ERROR! The installer URL or path was empty."))
|
|
| 147 | + return 1
|
|
| 148 | + |
|
| 149 | + platform = kwargs.get("platform") or _prompt_platform()
|
|
| 150 | + dry_run = kwargs.get("dry_run", False)
|
|
| 151 | + |
|
| 152 | + try:
|
|
| 153 | + installer_url = _resolve_installer_url(
|
|
| 154 | + command_context.topsrcdir,
|
|
| 155 | + installer_url,
|
|
| 156 | + platform,
|
|
| 157 | + kwargs.get("assume_yes", False),
|
|
| 158 | + dry_run,
|
|
| 159 | + )
|
|
| 160 | + except (FileNotFoundError, RuntimeError) as e:
|
|
| 161 | + print()
|
|
| 162 | + print(TERM.red(f"ERROR! {e}"))
|
|
| 163 | + return 1
|
|
| 164 | + |
|
| 165 | + package_name = None
|
|
| 166 | + if platform == "android_x86_64":
|
|
| 167 | + channel = kwargs.get("channel") or _prompt_channel()
|
|
| 168 | + if channel not in ANDROID_CHANNEL_PACKAGE_NAMES:
|
|
| 169 | + print(TERM.red(f"ERROR! Unknown channel {channel!r}."))
|
|
| 170 | + return 1
|
|
| 171 | + package_name = ANDROID_CHANNEL_PACKAGE_NAMES[channel]
|
|
| 172 | + |
|
| 173 | + artifacts_url = kwargs.get("artifacts_url")
|
|
| 174 | + mozharness_url = kwargs.get("mozharness_url")
|
|
| 175 | + if not artifacts_url or not mozharness_url:
|
|
| 176 | + version = _resolve_version(command_context.topsrcdir, kwargs.get("version"))
|
|
| 177 | + if not artifacts_url:
|
|
| 178 | + artifacts_url = _artifacts_url(version, platform)
|
|
| 179 | + if not mozharness_url:
|
|
| 180 | + mozharness_url = _mozharness_url(version, platform)
|
|
| 181 | + |
|
| 182 | + inputs = {
|
|
| 183 | + "mozharness_url": mozharness_url,
|
|
| 184 | + f"{platform}_installer_url": installer_url,
|
|
| 185 | + f"{platform}_artifacts_url": artifacts_url,
|
|
| 186 | + }
|
|
| 187 | + if package_name:
|
|
| 188 | + inputs[f"{platform}_package_name"] = package_name
|
|
| 189 | + if sha256sums_url := kwargs.get("sha256sums_url"):
|
|
| 190 | + inputs[f"{platform}_sha256sums_url"] = sha256sums_url
|
|
| 191 | + if tags := kwargs.get("tags"):
|
|
| 192 | + inputs["tags"] = shlex.join(arg for tag in tags for arg in ("--tag", tag))
|
|
| 193 | + |
|
| 194 | + project_id = urllib.parse.quote(config["project"], safe="")
|
|
| 195 | + url = f"{GITLAB_API_BASE}/projects/{project_id}/trigger/pipeline"
|
|
| 196 | + headers = {"Content-Type": "application/json"}
|
|
| 197 | + data = json.dumps({
|
|
| 198 | + "token": config["token"],
|
|
| 199 | + "ref": kwargs.get("ref", "main"),
|
|
| 200 | + "inputs": inputs,
|
|
| 201 | + }).encode("utf-8")
|
|
| 202 | + |
|
| 203 | + if dry_run:
|
|
| 204 | + print()
|
|
| 205 | + print(TERM.cyan(_curl_command(url, headers, data)))
|
|
| 206 | + print()
|
|
| 207 | + return 0
|
|
| 208 | + |
|
| 209 | + request = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
| 210 | + |
|
| 211 | + try:
|
|
| 212 | + with urllib.request.urlopen(
|
|
| 213 | + request, timeout=GITLAB_REQUEST_TIMEOUT_SECONDS
|
|
| 214 | + ) as response:
|
|
| 215 | + result = json.load(response)
|
|
| 216 | + except urllib.error.HTTPError as e:
|
|
| 217 | + body = e.read().decode("utf-8", "replace")
|
|
| 218 | + print(
|
|
| 219 | + TERM.red(f"ERROR! Failed to trigger pipeline: {e.code} {e.reason}\n{body}")
|
|
| 220 | + )
|
|
| 221 | + return 1
|
|
| 222 | + except urllib.error.URLError as e:
|
|
| 223 | + print(TERM.red(f"ERROR! Could not reach GitLab: {e}"))
|
|
| 224 | + return 1
|
|
| 225 | + |
|
| 226 | + print()
|
|
| 227 | + print(TERM.green(f"Pipeline triggered: {result.get('web_url')}"))
|
|
| 228 | + return 0 |
| 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 json
|
|
| 6 | + |
|
| 7 | +from yeet import common # noqa: E402
|
|
| 8 | + |
|
| 9 | + |
|
| 10 | +def write_config(tmp_path, **overrides):
|
|
| 11 | + """Write a `.yeet.json` under `tmp_path`, as `mach yeet auth` would,
|
|
| 12 | + for tests that need one already in place.
|
|
| 13 | + """
|
|
| 14 | + config = {
|
|
| 15 | + "project": "bea/tor-browser-bundle-testsuite",
|
|
| 16 | + "token": "s3cr3t",
|
|
| 17 | + "ssh_host": common.DEFAULT_SSH_HOST,
|
|
| 18 | + "ssh_user": "bea",
|
|
| 19 | + }
|
|
| 20 | + config.update(overrides)
|
|
| 21 | + with open(tmp_path / common.YEET_CONFIG_FILENAME, "w") as f:
|
|
| 22 | + json.dump(config, f)
|
|
| 23 | + return config |
| 1 | +[DEFAULT]
|
|
| 2 | +subsuite = "base-browser"
|
|
| 3 | + |
|
| 4 | +["test_auth.py"]
|
|
| 5 | + |
|
| 6 | +["test_upload.py"]
|
|
| 7 | + |
|
| 8 | +["test_remote.py"]
|
|
| 9 | + |
|
| 10 | +["test_common.py"] |
| 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 json
|
|
| 6 | +import urllib.error
|
|
| 7 | +from types import SimpleNamespace
|
|
| 8 | + |
|
| 9 | +import mozunit
|
|
| 10 | +import pytest
|
|
| 11 | +from yeet import auth, common
|
|
| 12 | + |
|
| 13 | + |
|
| 14 | +def _set_inputs(monkeypatch, *values):
|
|
| 15 | + values = iter(values)
|
|
| 16 | + monkeypatch.setattr("builtins.input", lambda prompt="": next(values))
|
|
| 17 | + |
|
| 18 | + |
|
| 19 | +def _forbid_prompts(monkeypatch):
|
|
| 20 | + """Make input()/getpass() blow up instead of hanging, so a test can
|
|
| 21 | + assert that a fully non-interactive call never prompts."""
|
|
| 22 | + |
|
| 23 | + def _fail(prompt=""):
|
|
| 24 | + raise AssertionError(f"unexpected prompt: {prompt!r}")
|
|
| 25 | + |
|
| 26 | + monkeypatch.setattr("builtins.input", _fail)
|
|
| 27 | + monkeypatch.setattr(common.getpass, "getpass", _fail)
|
|
| 28 | + |
|
| 29 | + |
|
| 30 | +@pytest.mark.parametrize("value", ["bea", " bea ", "bea.b", "bea_b-1"])
|
|
| 31 | +def test_resolve_project_accepts_username(value):
|
|
| 32 | + project = auth._resolve_project(value)
|
|
| 33 | + |
|
| 34 | + assert project == f"{value.strip()}/{common.CANONICAL_GITLAB_REPO_NAME}"
|
|
| 35 | + |
|
| 36 | + |
|
| 37 | +@pytest.mark.parametrize(
|
|
| 38 | + "value",
|
|
| 39 | + [
|
|
| 40 | + common.CANONICAL_GITLAB_NAMESPACE,
|
|
| 41 | + common.CANONICAL_GITLAB_NAMESPACE.upper(),
|
|
| 42 | + f" {common.CANONICAL_GITLAB_NAMESPACE} ",
|
|
| 43 | + ],
|
|
| 44 | +)
|
|
| 45 | +def test_resolve_project_rejects_canonical_namespace(value):
|
|
| 46 | + with pytest.raises(ValueError, match="canonical"):
|
|
| 47 | + auth._resolve_project(value)
|
|
| 48 | + |
|
| 49 | + |
|
| 50 | +def test_resolve_project_accepts_top_level_group_of_canonical_namespace():
|
|
| 51 | + group = common.CANONICAL_GITLAB_NAMESPACE.split("/", 1)[0]
|
|
| 52 | + |
|
| 53 | + assert auth._resolve_project(group) == (
|
|
| 54 | + f"{group}/{common.CANONICAL_GITLAB_REPO_NAME}"
|
|
| 55 | + )
|
|
| 56 | + |
|
| 57 | + |
|
| 58 | +def test_prompt_fork_rejects_canonical_namespace(monkeypatch, capsys):
|
|
| 59 | + canonical_namespace = common.CANONICAL_GITLAB_NAMESPACE
|
|
| 60 | + _set_inputs(monkeypatch, canonical_namespace, "bea")
|
|
| 61 | + |
|
| 62 | + project = auth._prompt_fork()
|
|
| 63 | + |
|
| 64 | + assert project == f"bea/{common.CANONICAL_GITLAB_REPO_NAME}"
|
|
| 65 | + assert "not a" in capsys.readouterr().out.lower()
|
|
| 66 | + |
|
| 67 | + |
|
| 68 | +@pytest.mark.parametrize("value", ["", " ", "\t\n"])
|
|
| 69 | +def test_resolve_project_rejects_blank_username(value):
|
|
| 70 | + with pytest.raises(ValueError):
|
|
| 71 | + auth._resolve_project(value)
|
|
| 72 | + |
|
| 73 | + |
|
| 74 | +def test_prompt_fork_rejects_empty_username(monkeypatch):
|
|
| 75 | + _set_inputs(monkeypatch, "", " \t ", "bea")
|
|
| 76 | + |
|
| 77 | + project = auth._prompt_fork()
|
|
| 78 | + |
|
| 79 | + assert project == f"bea/{common.CANONICAL_GITLAB_REPO_NAME}"
|
|
| 80 | + |
|
| 81 | + |
|
| 82 | +@pytest.mark.parametrize("status", [400])
|
|
| 83 | +def test_verify_auth_accepts_valid_token(monkeypatch, status):
|
|
| 84 | + def fake_urlopen(request, *args, **kwargs):
|
|
| 85 | + raise urllib.error.HTTPError("url", status, "msg", None, None)
|
|
| 86 | + |
|
| 87 | + monkeypatch.setattr(auth.urllib.request, "urlopen", fake_urlopen)
|
|
| 88 | + |
|
| 89 | + assert auth._verify_auth("bea/tor-browser-bundle-testsuite", "tok") is True
|
|
| 90 | + |
|
| 91 | + |
|
| 92 | +@pytest.mark.parametrize("status", [401, 404])
|
|
| 93 | +def test_verify_auth_rejects_bad_token_or_project(monkeypatch, status):
|
|
| 94 | + def fake_urlopen(request, *args, **kwargs):
|
|
| 95 | + raise urllib.error.HTTPError("url", status, "msg", None, None)
|
|
| 96 | + |
|
| 97 | + monkeypatch.setattr(auth.urllib.request, "urlopen", fake_urlopen)
|
|
| 98 | + |
|
| 99 | + assert auth._verify_auth("bea/tor-browser-bundle-testsuite", "tok") is False
|
|
| 100 | + |
|
| 101 | + |
|
| 102 | +def test_verify_auth_propagates_network_errors(monkeypatch):
|
|
| 103 | + def fake_urlopen(request, *args, **kwargs):
|
|
| 104 | + raise urllib.error.URLError("unreachable")
|
|
| 105 | + |
|
| 106 | + monkeypatch.setattr(auth.urllib.request, "urlopen", fake_urlopen)
|
|
| 107 | + |
|
| 108 | + with pytest.raises(urllib.error.URLError):
|
|
| 109 | + auth._verify_auth("bea/tor-browser-bundle-testsuite", "tok")
|
|
| 110 | + |
|
| 111 | + |
|
| 112 | +def test_verify_ssh_success(monkeypatch):
|
|
| 113 | + calls = []
|
|
| 114 | + |
|
| 115 | + def fake_run(cmd, **kwargs):
|
|
| 116 | + calls.append((cmd, kwargs))
|
|
| 117 | + return SimpleNamespace(returncode=0)
|
|
| 118 | + |
|
| 119 | + monkeypatch.setattr(auth.subprocess, "run", fake_run)
|
|
| 120 | + |
|
| 121 | + assert auth._verify_ssh("bea@tb-build-03.torproject.org") is True
|
|
| 122 | + cmd, kwargs = calls[0]
|
|
| 123 | + assert cmd[-2:] == ["bea@tb-build-03.torproject.org", "true"]
|
|
| 124 | + assert kwargs["stdin"] is auth.subprocess.DEVNULL
|
|
| 125 | + |
|
| 126 | + |
|
| 127 | +def test_verify_ssh_failure(monkeypatch):
|
|
| 128 | + monkeypatch.setattr(
|
|
| 129 | + auth.subprocess,
|
|
| 130 | + "run",
|
|
| 131 | + lambda *args, **kwargs: SimpleNamespace(returncode=255),
|
|
| 132 | + )
|
|
| 133 | + |
|
| 134 | + assert auth._verify_ssh("bea@tb-build-03.torproject.org") is False
|
|
| 135 | + |
|
| 136 | + |
|
| 137 | +def test_verify_ssh_missing_binary(monkeypatch, capsys):
|
|
| 138 | + def fake_run(*args, **kwargs):
|
|
| 139 | + raise FileNotFoundError()
|
|
| 140 | + |
|
| 141 | + monkeypatch.setattr(auth.subprocess, "run", fake_run)
|
|
| 142 | + |
|
| 143 | + assert auth._verify_ssh("bea@tb-build-03.torproject.org") is False
|
|
| 144 | + assert "no `ssh` binary" in capsys.readouterr().out.lower()
|
|
| 145 | + |
|
| 146 | + |
|
| 147 | +def test_run_saves_config_and_skips_verification(tmp_path, monkeypatch):
|
|
| 148 | + _set_inputs(monkeypatch, "bea", "", "bea")
|
|
| 149 | + monkeypatch.setattr(common.getpass, "getpass", lambda prompt="": "s3cr3t")
|
|
| 150 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 151 | + |
|
| 152 | + result = auth.run(command_context, verify=False)
|
|
| 153 | + |
|
| 154 | + assert result == 0
|
|
| 155 | + config_path = tmp_path / common.YEET_CONFIG_FILENAME
|
|
| 156 | + with open(config_path) as f:
|
|
| 157 | + saved = json.load(f)
|
|
| 158 | + assert saved == {
|
|
| 159 | + "project": f"bea/{common.CANONICAL_GITLAB_REPO_NAME}",
|
|
| 160 | + "token": "s3cr3t",
|
|
| 161 | + "ssh_host": common.DEFAULT_SSH_HOST,
|
|
| 162 | + "ssh_user": "bea",
|
|
| 163 | + }
|
|
| 164 | + assert (config_path.stat().st_mode & 0o777) == 0o600
|
|
| 165 | + |
|
| 166 | + |
|
| 167 | +def test_prompt_ssh_username_reprompts_until_non_empty(monkeypatch, capsys):
|
|
| 168 | + _set_inputs(monkeypatch, "", " ", "bea")
|
|
| 169 | + |
|
| 170 | + assert auth._prompt_ssh_username(common.DEFAULT_SSH_HOST) == "bea"
|
|
| 171 | + assert capsys.readouterr().out.count("ERROR!") == 2
|
|
| 172 | + |
|
| 173 | + |
|
| 174 | +def test_prompt_ssh_host_defaults_when_empty(monkeypatch):
|
|
| 175 | + _set_inputs(monkeypatch, " ")
|
|
| 176 | + |
|
| 177 | + assert auth._prompt_ssh_host() == common.DEFAULT_SSH_HOST
|
|
| 178 | + |
|
| 179 | + |
|
| 180 | +def test_run_prompts_for_ssh_host(tmp_path, monkeypatch):
|
|
| 181 | + _set_inputs(monkeypatch, "build.example.com", "bea")
|
|
| 182 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 183 | + |
|
| 184 | + result = auth.run(
|
|
| 185 | + command_context, verify=False, gitlab_username="bea", gitlab_token="s3cr3t"
|
|
| 186 | + )
|
|
| 187 | + |
|
| 188 | + assert result == 0
|
|
| 189 | + with open(tmp_path / common.YEET_CONFIG_FILENAME) as f:
|
|
| 190 | + saved = json.load(f)
|
|
| 191 | + assert saved["ssh_host"] == "build.example.com"
|
|
| 192 | + assert saved["ssh_user"] == "bea"
|
|
| 193 | + |
|
| 194 | + |
|
| 195 | +def test_prompt_token_reprompts_until_non_empty(monkeypatch, capsys):
|
|
| 196 | + values = iter(["", " ", "s3cr3t"])
|
|
| 197 | + monkeypatch.setattr(common.getpass, "getpass", lambda prompt="": next(values))
|
|
| 198 | + |
|
| 199 | + assert auth._prompt_token() == "s3cr3t"
|
|
| 200 | + assert capsys.readouterr().out.count("ERROR!") == 2
|
|
| 201 | + |
|
| 202 | + |
|
| 203 | +def test_run_aborts_when_gitlab_verification_fails(tmp_path, monkeypatch, capsys):
|
|
| 204 | + _set_inputs(monkeypatch, "bea", "", "bea")
|
|
| 205 | + monkeypatch.setattr(common.getpass, "getpass", lambda prompt="": "s3cr3t")
|
|
| 206 | + monkeypatch.setattr(auth, "_verify_auth", lambda project, token: False)
|
|
| 207 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 208 | + |
|
| 209 | + result = auth.run(command_context, verify=True)
|
|
| 210 | + |
|
| 211 | + assert result == 1
|
|
| 212 | + assert not (tmp_path / common.YEET_CONFIG_FILENAME).exists()
|
|
| 213 | + assert (
|
|
| 214 | + "ERROR! GitLab rejected that project/token combination."
|
|
| 215 | + in capsys.readouterr().out
|
|
| 216 | + )
|
|
| 217 | + |
|
| 218 | + |
|
| 219 | +def test_run_aborts_when_gitlab_unreachable(tmp_path, monkeypatch, capsys):
|
|
| 220 | + _set_inputs(monkeypatch, "bea", "", "bea")
|
|
| 221 | + monkeypatch.setattr(common.getpass, "getpass", lambda prompt="": "s3cr3t")
|
|
| 222 | + |
|
| 223 | + def raise_url_error(project, token):
|
|
| 224 | + raise urllib.error.URLError("unreachable")
|
|
| 225 | + |
|
| 226 | + monkeypatch.setattr(auth, "_verify_auth", raise_url_error)
|
|
| 227 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 228 | + |
|
| 229 | + result = auth.run(command_context, verify=True)
|
|
| 230 | + |
|
| 231 | + assert result == 1
|
|
| 232 | + assert not (tmp_path / common.YEET_CONFIG_FILENAME).exists()
|
|
| 233 | + assert (
|
|
| 234 | + "ERROR! Could not reach GitLab: <urlopen error unreachable>"
|
|
| 235 | + in capsys.readouterr().out
|
|
| 236 | + )
|
|
| 237 | + |
|
| 238 | + |
|
| 239 | +def test_run_aborts_when_ssh_verification_fails(tmp_path, monkeypatch, capsys):
|
|
| 240 | + _set_inputs(monkeypatch, "bea", "", "bea")
|
|
| 241 | + monkeypatch.setattr(common.getpass, "getpass", lambda prompt="": "s3cr3t")
|
|
| 242 | + monkeypatch.setattr(auth, "_verify_auth", lambda project, token: True)
|
|
| 243 | + monkeypatch.setattr(auth, "_verify_ssh", lambda target: False)
|
|
| 244 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 245 | + |
|
| 246 | + result = auth.run(command_context, verify=True)
|
|
| 247 | + |
|
| 248 | + assert result == 1
|
|
| 249 | + assert not (tmp_path / common.YEET_CONFIG_FILENAME).exists()
|
|
| 250 | + assert (
|
|
| 251 | + f"ERROR! Could not SSH into bea@{common.DEFAULT_SSH_HOST}."
|
|
| 252 | + in capsys.readouterr().out
|
|
| 253 | + )
|
|
| 254 | + |
|
| 255 | + |
|
| 256 | +def test_run_saves_config_when_verification_succeeds(tmp_path, monkeypatch):
|
|
| 257 | + _set_inputs(monkeypatch, "bea", "", "bea")
|
|
| 258 | + monkeypatch.setattr(common.getpass, "getpass", lambda prompt="": "s3cr3t")
|
|
| 259 | + monkeypatch.setattr(auth, "_verify_auth", lambda project, token: True)
|
|
| 260 | + monkeypatch.setattr(auth, "_verify_ssh", lambda target: True)
|
|
| 261 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 262 | + |
|
| 263 | + result = auth.run(command_context, verify=True)
|
|
| 264 | + |
|
| 265 | + assert result == 0
|
|
| 266 | + with open(tmp_path / common.YEET_CONFIG_FILENAME) as f:
|
|
| 267 | + assert json.load(f) == {
|
|
| 268 | + "project": f"bea/{common.CANONICAL_GITLAB_REPO_NAME}",
|
|
| 269 | + "token": "s3cr3t",
|
|
| 270 | + "ssh_host": common.DEFAULT_SSH_HOST,
|
|
| 271 | + "ssh_user": "bea",
|
|
| 272 | + }
|
|
| 273 | + |
|
| 274 | + |
|
| 275 | +def test_run_uses_cli_gitlab_username_and_still_prompts_for_rest(tmp_path, monkeypatch):
|
|
| 276 | + # ssh host and username aren't given via CLI, so they're the only things
|
|
| 277 | + # prompted for.
|
|
| 278 | + _set_inputs(monkeypatch, "", "bea")
|
|
| 279 | + monkeypatch.setattr(common.getpass, "getpass", lambda prompt="": "s3cr3t")
|
|
| 280 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 281 | + |
|
| 282 | + result = auth.run(command_context, verify=False, gitlab_username="bea")
|
|
| 283 | + |
|
| 284 | + assert result == 0
|
|
| 285 | + with open(tmp_path / common.YEET_CONFIG_FILENAME) as f:
|
|
| 286 | + saved = json.load(f)
|
|
| 287 | + assert saved["project"] == f"bea/{common.CANONICAL_GITLAB_REPO_NAME}"
|
|
| 288 | + assert saved["ssh_user"] == "bea"
|
|
| 289 | + |
|
| 290 | + |
|
| 291 | +def test_run_rejects_invalid_cli_gitlab_username(tmp_path, monkeypatch, capsys):
|
|
| 292 | + _forbid_prompts(monkeypatch)
|
|
| 293 | + canonical_namespace = common.CANONICAL_GITLAB_NAMESPACE
|
|
| 294 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 295 | + |
|
| 296 | + result = auth.run(
|
|
| 297 | + command_context, verify=False, gitlab_username=canonical_namespace
|
|
| 298 | + )
|
|
| 299 | + |
|
| 300 | + assert result == 1
|
|
| 301 | + assert not (tmp_path / common.YEET_CONFIG_FILENAME).exists()
|
|
| 302 | + assert "not a" in capsys.readouterr().out.lower()
|
|
| 303 | + |
|
| 304 | + |
|
| 305 | +def test_run_fully_noninteractive_with_all_cli_args(tmp_path, monkeypatch):
|
|
| 306 | + _forbid_prompts(monkeypatch)
|
|
| 307 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 308 | + |
|
| 309 | + result = auth.run(
|
|
| 310 | + command_context,
|
|
| 311 | + verify=False,
|
|
| 312 | + gitlab_username="bea",
|
|
| 313 | + gitlab_token="s3cr3t",
|
|
| 314 | + ssh_username="bea",
|
|
| 315 | + ssh_host=common.DEFAULT_SSH_HOST,
|
|
| 316 | + )
|
|
| 317 | + |
|
| 318 | + assert result == 0
|
|
| 319 | + with open(tmp_path / common.YEET_CONFIG_FILENAME) as f:
|
|
| 320 | + saved = json.load(f)
|
|
| 321 | + assert saved == {
|
|
| 322 | + "project": f"bea/{common.CANONICAL_GITLAB_REPO_NAME}",
|
|
| 323 | + "token": "s3cr3t",
|
|
| 324 | + "ssh_host": common.DEFAULT_SSH_HOST,
|
|
| 325 | + "ssh_user": "bea",
|
|
| 326 | + }
|
|
| 327 | + |
|
| 328 | + |
|
| 329 | +def test_run_saves_cli_ssh_host(tmp_path, monkeypatch):
|
|
| 330 | + _forbid_prompts(monkeypatch)
|
|
| 331 | + targets = []
|
|
| 332 | + monkeypatch.setattr(auth, "_verify_auth", lambda project, token: True)
|
|
| 333 | + monkeypatch.setattr(
|
|
| 334 | + auth, "_verify_ssh", lambda target: targets.append(target) or True
|
|
| 335 | + )
|
|
| 336 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 337 | + |
|
| 338 | + result = auth.run(
|
|
| 339 | + command_context,
|
|
| 340 | + verify=True,
|
|
| 341 | + gitlab_username="bea",
|
|
| 342 | + gitlab_token="s3cr3t",
|
|
| 343 | + ssh_username="bea",
|
|
| 344 | + ssh_host="build.example.com",
|
|
| 345 | + )
|
|
| 346 | + |
|
| 347 | + assert result == 0
|
|
| 348 | + assert targets == ["bea@build.example.com"]
|
|
| 349 | + with open(tmp_path / common.YEET_CONFIG_FILENAME) as f:
|
|
| 350 | + assert json.load(f)["ssh_host"] == "build.example.com"
|
|
| 351 | + |
|
| 352 | + |
|
| 353 | +def test_run_fully_noninteractive_with_verification(tmp_path, monkeypatch):
|
|
| 354 | + _forbid_prompts(monkeypatch)
|
|
| 355 | + monkeypatch.setattr(auth, "_verify_auth", lambda project, token: True)
|
|
| 356 | + monkeypatch.setattr(auth, "_verify_ssh", lambda target: True)
|
|
| 357 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 358 | + |
|
| 359 | + result = auth.run(
|
|
| 360 | + command_context,
|
|
| 361 | + verify=True,
|
|
| 362 | + gitlab_username="bea",
|
|
| 363 | + gitlab_token="s3cr3t",
|
|
| 364 | + ssh_username="bea",
|
|
| 365 | + ssh_host=common.DEFAULT_SSH_HOST,
|
|
| 366 | + )
|
|
| 367 | + |
|
| 368 | + assert result == 0
|
|
| 369 | + with open(tmp_path / common.YEET_CONFIG_FILENAME) as f:
|
|
| 370 | + assert json.load(f) == {
|
|
| 371 | + "project": f"bea/{common.CANONICAL_GITLAB_REPO_NAME}",
|
|
| 372 | + "token": "s3cr3t",
|
|
| 373 | + "ssh_host": common.DEFAULT_SSH_HOST,
|
|
| 374 | + "ssh_user": "bea",
|
|
| 375 | + }
|
|
| 376 | + |
|
| 377 | + |
|
| 378 | +_CLI_ARGS = {
|
|
| 379 | + "gitlab_username": "bea",
|
|
| 380 | + "gitlab_token": "s3cr3t",
|
|
| 381 | + "ssh_username": "bea",
|
|
| 382 | + "ssh_host": "build.example.com",
|
|
| 383 | +}
|
|
| 384 | + |
|
| 385 | +_SAVED_CONFIG = {
|
|
| 386 | + "project": f"bea/{common.CANONICAL_GITLAB_REPO_NAME}",
|
|
| 387 | + "token": "s3cr3t",
|
|
| 388 | + "ssh_host": "build.example.com",
|
|
| 389 | + "ssh_user": "bea",
|
|
| 390 | +}
|
|
| 391 | + |
|
| 392 | + |
|
| 393 | +@pytest.mark.parametrize("padding", [" ", "\t", "\n", " \t\n "])
|
|
| 394 | +def test_run_trims_cli_args(tmp_path, monkeypatch, padding):
|
|
| 395 | + _forbid_prompts(monkeypatch)
|
|
| 396 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 397 | + |
|
| 398 | + result = auth.run(
|
|
| 399 | + command_context,
|
|
| 400 | + verify=False,
|
|
| 401 | + **{name: f"{padding}{value}{padding}" for name, value in _CLI_ARGS.items()},
|
|
| 402 | + )
|
|
| 403 | + |
|
| 404 | + assert result == 0
|
|
| 405 | + with open(tmp_path / common.YEET_CONFIG_FILENAME) as f:
|
|
| 406 | + assert json.load(f) == _SAVED_CONFIG
|
|
| 407 | + |
|
| 408 | + |
|
| 409 | +def test_run_trims_prompted_values(tmp_path, monkeypatch):
|
|
| 410 | + _set_inputs(monkeypatch, " bea\t", " build.example.com\n", "\tbea ")
|
|
| 411 | + monkeypatch.setattr(common.getpass, "getpass", lambda prompt="": " s3cr3t\t")
|
|
| 412 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 413 | + |
|
| 414 | + result = auth.run(command_context, verify=False)
|
|
| 415 | + |
|
| 416 | + assert result == 0
|
|
| 417 | + with open(tmp_path / common.YEET_CONFIG_FILENAME) as f:
|
|
| 418 | + assert json.load(f) == _SAVED_CONFIG
|
|
| 419 | + |
|
| 420 | + |
|
| 421 | +@pytest.mark.parametrize("arg", list(_CLI_ARGS))
|
|
| 422 | +@pytest.mark.parametrize("blank", ["", " ", "\t\n"])
|
|
| 423 | +def test_run_rejects_blank_cli_arg(tmp_path, monkeypatch, capsys, arg, blank):
|
|
| 424 | + _forbid_prompts(monkeypatch)
|
|
| 425 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 426 | + |
|
| 427 | + result = auth.run(command_context, verify=False, **{**_CLI_ARGS, arg: blank})
|
|
| 428 | + |
|
| 429 | + assert result == 1
|
|
| 430 | + assert not (tmp_path / common.YEET_CONFIG_FILENAME).exists()
|
|
| 431 | + assert "ERROR!" in capsys.readouterr().out
|
|
| 432 | + |
|
| 433 | + |
|
| 434 | +if __name__ == "__main__":
|
|
| 435 | + mozunit.main() |
| 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 sys
|
|
| 6 | +from types import SimpleNamespace
|
|
| 7 | + |
|
| 8 | +import mozunit
|
|
| 9 | +import pytest
|
|
| 10 | +from yeet import common
|
|
| 11 | + |
|
| 12 | + |
|
| 13 | +@pytest.mark.parametrize("secret", [False, True])
|
|
| 14 | +def test_prompt_flushes_pending_tty_input(monkeypatch, secret):
|
|
| 15 | + termios = pytest.importorskip("termios")
|
|
| 16 | + events = []
|
|
| 17 | + monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: True))
|
|
| 18 | + monkeypatch.setattr(
|
|
| 19 | + termios, "tcflush", lambda fd, queue: events.append(("flush", queue))
|
|
| 20 | + )
|
|
| 21 | + monkeypatch.setattr(
|
|
| 22 | + "builtins.input", lambda message="": events.append("input") or "plain"
|
|
| 23 | + )
|
|
| 24 | + monkeypatch.setattr(
|
|
| 25 | + common.getpass,
|
|
| 26 | + "getpass",
|
|
| 27 | + lambda message="": events.append("getpass") or "hidden",
|
|
| 28 | + )
|
|
| 29 | + |
|
| 30 | + answer = common.prompt("Q: ", secret=secret)
|
|
| 31 | + |
|
| 32 | + assert answer == ("hidden" if secret else "plain")
|
|
| 33 | + assert events == [
|
|
| 34 | + ("flush", termios.TCIFLUSH),
|
|
| 35 | + "getpass" if secret else "input",
|
|
| 36 | + ]
|
|
| 37 | + |
|
| 38 | + |
|
| 39 | +def test_prompt_skips_flush_when_not_a_tty(monkeypatch):
|
|
| 40 | + termios = pytest.importorskip("termios")
|
|
| 41 | + monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: False))
|
|
| 42 | + monkeypatch.setattr(
|
|
| 43 | + termios,
|
|
| 44 | + "tcflush",
|
|
| 45 | + lambda *args: pytest.fail("tcflush called on non-tty stdin"),
|
|
| 46 | + )
|
|
| 47 | + monkeypatch.setattr("builtins.input", lambda message="": "piped")
|
|
| 48 | + |
|
| 49 | + assert common.prompt("Q: ") == "piped"
|
|
| 50 | + |
|
| 51 | + |
|
| 52 | +@pytest.mark.parametrize(
|
|
| 53 | + "answers,expected",
|
|
| 54 | + [
|
|
| 55 | + (["y"], True),
|
|
| 56 | + (["YES"], True),
|
|
| 57 | + ([" yes "], True),
|
|
| 58 | + ([""], False),
|
|
| 59 | + (["n"], False),
|
|
| 60 | + (["No"], False),
|
|
| 61 | + (["maybe", "y"], True),
|
|
| 62 | + ],
|
|
| 63 | +)
|
|
| 64 | +def test_confirm(monkeypatch, answers, expected):
|
|
| 65 | + answers = iter(answers)
|
|
| 66 | + monkeypatch.setattr("builtins.input", lambda message="": next(answers))
|
|
| 67 | + |
|
| 68 | + assert common.confirm("Go?") is expected
|
|
| 69 | + |
|
| 70 | + |
|
| 71 | +def test_confirm_reprompts_on_invalid_answer(monkeypatch, capsys):
|
|
| 72 | + answers = iter(["maybe", "sure", "n"])
|
|
| 73 | + monkeypatch.setattr("builtins.input", lambda message="": next(answers))
|
|
| 74 | + |
|
| 75 | + assert common.confirm("Go?") is False
|
|
| 76 | + assert capsys.readouterr().out.count("ERROR!") == 2
|
|
| 77 | + |
|
| 78 | + |
|
| 79 | +if __name__ == "__main__":
|
|
| 80 | + mozunit.main() |
| 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 json
|
|
| 6 | +import shlex
|
|
| 7 | +import urllib.error
|
|
| 8 | +from types import SimpleNamespace
|
|
| 9 | + |
|
| 10 | +import mozunit
|
|
| 11 | +import pytest
|
|
| 12 | +from conftest import write_config
|
|
| 13 | +from yeet import remote
|
|
| 14 | + |
|
| 15 | + |
|
| 16 | +class _FakeResponse:
|
|
| 17 | + def __init__(self, body):
|
|
| 18 | + self._body = body
|
|
| 19 | + |
|
| 20 | + def __enter__(self):
|
|
| 21 | + return self
|
|
| 22 | + |
|
| 23 | + def __exit__(self, *args):
|
|
| 24 | + return False
|
|
| 25 | + |
|
| 26 | + def read(self):
|
|
| 27 | + return self._body
|
|
| 28 | + |
|
| 29 | + |
|
| 30 | +def _write_version(tmp_path, version="153.2.0"):
|
|
| 31 | + version_dir = tmp_path / "browser" / "config"
|
|
| 32 | + version_dir.mkdir(parents=True)
|
|
| 33 | + (version_dir / "version.txt").write_text(f"{version}\n")
|
|
| 34 | + |
|
| 35 | + |
|
| 36 | +def test_read_browser_version(tmp_path):
|
|
| 37 | + _write_version(tmp_path, "1.2.3")
|
|
| 38 | + |
|
| 39 | + assert remote._read_browser_version(tmp_path) == "1.2.3"
|
|
| 40 | + |
|
| 41 | + |
|
| 42 | +def test_resolve_version_prefers_given_value(tmp_path):
|
|
| 43 | + |
|
| 44 | + assert remote._resolve_version(tmp_path, "9.9.9") == "9.9.9"
|
|
| 45 | + |
|
| 46 | + |
|
| 47 | +def test_resolve_version_falls_back_to_in_tree_version(tmp_path, capsys):
|
|
| 48 | + _write_version(tmp_path, "1.2.3")
|
|
| 49 | + |
|
| 50 | + assert remote._resolve_version(tmp_path, None) == "1.2.3"
|
|
| 51 | + assert "1.2.3" in capsys.readouterr().out
|
|
| 52 | + |
|
| 53 | + |
|
| 54 | +def test_artifacts_and_mozharness_urls():
|
|
| 55 | + artifacts = remote._artifacts_url("1.2.3", "android_x86_64")
|
|
| 56 | + |
|
| 57 | + assert artifacts == f"{remote.TOOLCHAINS_URL_BASE}/1.2.3/android-x86_64/"
|
|
| 58 | + assert remote._mozharness_url("1.2.3", "android_x86_64") == (
|
|
| 59 | + artifacts + "mozharness.zip"
|
|
| 60 | + )
|
|
| 61 | + |
|
| 62 | + |
|
| 63 | +def test_curl_command_includes_header_data_and_url():
|
|
| 64 | + command = remote._curl_command(
|
|
| 65 | + "https://example.org/trigger",
|
|
| 66 | + {"Content-Type": "application/json"},
|
|
| 67 | + b'{"a": 1}',
|
|
| 68 | + )
|
|
| 69 | + |
|
| 70 | + assert shlex.split(command) == [
|
|
| 71 | + "curl",
|
|
| 72 | + "--request",
|
|
| 73 | + "POST",
|
|
| 74 | + "--header",
|
|
| 75 | + "Content-Type: application/json",
|
|
| 76 | + "--data",
|
|
| 77 | + '{"a": 1}',
|
|
| 78 | + "https://example.org/trigger",
|
|
| 79 | + ]
|
|
| 80 | + |
|
| 81 | + |
|
| 82 | +def _failing_upload_run(topsrcdir, file, platform, assume_yes):
|
|
| 83 | + raise RuntimeError("boom")
|
|
| 84 | + |
|
| 85 | + |
|
| 86 | +def test_resolve_installer_url_passes_through_a_url(tmp_path):
|
|
| 87 | + |
|
| 88 | + url = remote._resolve_installer_url(
|
|
| 89 | + tmp_path,
|
|
| 90 | + "https://example.org/app.apk",
|
|
| 91 | + "android_x86_64",
|
|
| 92 | + assume_yes=False,
|
|
| 93 | + dry_run=False,
|
|
| 94 | + )
|
|
| 95 | + |
|
| 96 | + assert url == "https://example.org/app.apk"
|
|
| 97 | + |
|
| 98 | + |
|
| 99 | +def test_resolve_installer_url_uploads_a_local_path(tmp_path, monkeypatch):
|
|
| 100 | + calls = []
|
|
| 101 | + |
|
| 102 | + def fake_upload_run(topsrcdir, file, platform, assume_yes):
|
|
| 103 | + calls.append((topsrcdir, file, platform, assume_yes))
|
|
| 104 | + return "https://tb-build-03.torproject.org/~bea/yeet/ts.apk"
|
|
| 105 | + |
|
| 106 | + monkeypatch.setattr(remote.upload, "run", fake_upload_run)
|
|
| 107 | + |
|
| 108 | + url = remote._resolve_installer_url(
|
|
| 109 | + tmp_path,
|
|
| 110 | + "/tmp/app.apk",
|
|
| 111 | + "android_x86_64",
|
|
| 112 | + assume_yes=True,
|
|
| 113 | + dry_run=False,
|
|
| 114 | + )
|
|
| 115 | + |
|
| 116 | + assert url == "https://tb-build-03.torproject.org/~bea/yeet/ts.apk"
|
|
| 117 | + assert calls == [(tmp_path, "/tmp/app.apk", "android_x86_64", True)]
|
|
| 118 | + |
|
| 119 | + |
|
| 120 | +def test_resolve_installer_url_propagates_upload_error(tmp_path, monkeypatch):
|
|
| 121 | + monkeypatch.setattr(remote.upload, "run", _failing_upload_run)
|
|
| 122 | + |
|
| 123 | + with pytest.raises(RuntimeError, match="boom"):
|
|
| 124 | + remote._resolve_installer_url(
|
|
| 125 | + tmp_path,
|
|
| 126 | + "/tmp/app.apk",
|
|
| 127 | + "android_x86_64",
|
|
| 128 | + assume_yes=False,
|
|
| 129 | + dry_run=False,
|
|
| 130 | + )
|
|
| 131 | + |
|
| 132 | + |
|
| 133 | +def test_prompt_platform_rejects_invalid_choice_then_accepts(monkeypatch):
|
|
| 134 | + values = iter(["0", "99", "not-a-number", "2"])
|
|
| 135 | + monkeypatch.setattr("builtins.input", lambda prompt="": next(values))
|
|
| 136 | + |
|
| 137 | + assert remote._prompt_platform() == remote.PLATFORMS[1]
|
|
| 138 | + |
|
| 139 | + |
|
| 140 | +def test_prompt_channel_rejects_invalid_choice_then_accepts(monkeypatch):
|
|
| 141 | + values = iter(["0", "99", "not-a-number", "2"])
|
|
| 142 | + monkeypatch.setattr("builtins.input", lambda prompt="": next(values))
|
|
| 143 | + |
|
| 144 | + assert remote._prompt_channel() == remote.ANDROID_CHANNELS[1]
|
|
| 145 | + |
|
| 146 | + |
|
| 147 | +def _run_kwargs(**overrides):
|
|
| 148 | + kwargs = dict(
|
|
| 149 | + platform="android_x86_64",
|
|
| 150 | + channel="debug",
|
|
| 151 | + installer_url="https://example.org/app.apk",
|
|
| 152 | + sha256sums_url=None,
|
|
| 153 | + artifacts_url="https://example.org/artifacts/",
|
|
| 154 | + mozharness_url="https://example.org/mozharness.zip",
|
|
| 155 | + version=None,
|
|
| 156 | + ref="main",
|
|
| 157 | + tags=["tor"],
|
|
| 158 | + dry_run=False,
|
|
| 159 | + )
|
|
| 160 | + kwargs.update(overrides)
|
|
| 161 | + return kwargs
|
|
| 162 | + |
|
| 163 | + |
|
| 164 | +def test_run_aborts_without_config(tmp_path, capsys):
|
|
| 165 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 166 | + |
|
| 167 | + result = remote.run(command_context, **_run_kwargs())
|
|
| 168 | + |
|
| 169 | + assert result == 1
|
|
| 170 | + assert "mach yeet auth" in capsys.readouterr().out
|
|
| 171 | + |
|
| 172 | + |
|
| 173 | +def test_run_dry_run_prints_curl_and_skips_network(tmp_path, monkeypatch, capsys):
|
|
| 174 | + write_config(tmp_path)
|
|
| 175 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 176 | + |
|
| 177 | + def fail_urlopen(*args, **kwargs):
|
|
| 178 | + raise AssertionError("urlopen should not be called in dry-run mode")
|
|
| 179 | + |
|
| 180 | + monkeypatch.setattr(remote.urllib.request, "urlopen", fail_urlopen)
|
|
| 181 | + |
|
| 182 | + result = remote.run(command_context, **_run_kwargs(dry_run=True))
|
|
| 183 | + |
|
| 184 | + assert result == 0
|
|
| 185 | + out = capsys.readouterr().out
|
|
| 186 | + assert "curl --request POST" in out
|
|
| 187 | + assert "app.apk" in out
|
|
| 188 | + |
|
| 189 | + |
|
| 190 | +def test_run_prompts_for_platform_when_missing(tmp_path, monkeypatch, capsys):
|
|
| 191 | + write_config(tmp_path)
|
|
| 192 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 193 | + calls = []
|
|
| 194 | + |
|
| 195 | + def fake_prompt_platform():
|
|
| 196 | + calls.append(True)
|
|
| 197 | + return "debian_x86_64"
|
|
| 198 | + |
|
| 199 | + monkeypatch.setattr(remote, "_prompt_platform", fake_prompt_platform)
|
|
| 200 | + |
|
| 201 | + result = remote.run(command_context, **_run_kwargs(platform=None, dry_run=True))
|
|
| 202 | + |
|
| 203 | + assert result == 0
|
|
| 204 | + assert len(calls) == 1
|
|
| 205 | + assert "debian_x86_64_installer_url" in capsys.readouterr().out
|
|
| 206 | + |
|
| 207 | + |
|
| 208 | +def test_run_prompts_for_channel_when_android_and_missing(
|
|
| 209 | + tmp_path, monkeypatch, capsys
|
|
| 210 | +):
|
|
| 211 | + write_config(tmp_path)
|
|
| 212 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 213 | + calls = []
|
|
| 214 | + |
|
| 215 | + def fake_prompt_channel():
|
|
| 216 | + calls.append(True)
|
|
| 217 | + return "nightly"
|
|
| 218 | + |
|
| 219 | + monkeypatch.setattr(remote, "_prompt_channel", fake_prompt_channel)
|
|
| 220 | + |
|
| 221 | + result = remote.run(command_context, **_run_kwargs(channel=None, dry_run=True))
|
|
| 222 | + |
|
| 223 | + assert result == 0
|
|
| 224 | + assert len(calls) == 1
|
|
| 225 | + assert remote.ANDROID_CHANNEL_PACKAGE_NAMES["nightly"] in capsys.readouterr().out
|
|
| 226 | + |
|
| 227 | + |
|
| 228 | +def test_run_adds_package_name_input_for_android(tmp_path, capsys):
|
|
| 229 | + write_config(tmp_path)
|
|
| 230 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 231 | + |
|
| 232 | + result = remote.run(command_context, **_run_kwargs(channel="nightly", dry_run=True))
|
|
| 233 | + |
|
| 234 | + assert result == 0
|
|
| 235 | + out = capsys.readouterr().out
|
|
| 236 | + assert "android_x86_64_package_name" in out
|
|
| 237 | + assert remote.ANDROID_CHANNEL_PACKAGE_NAMES["nightly"] in out
|
|
| 238 | + |
|
| 239 | + |
|
| 240 | +def test_run_skips_channel_for_non_android_platforms(tmp_path, monkeypatch, capsys):
|
|
| 241 | + write_config(tmp_path)
|
|
| 242 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 243 | + monkeypatch.setattr(
|
|
| 244 | + remote,
|
|
| 245 | + "_prompt_channel",
|
|
| 246 | + lambda: (_ for _ in ()).throw(AssertionError("should not prompt")),
|
|
| 247 | + )
|
|
| 248 | + |
|
| 249 | + result = remote.run(
|
|
| 250 | + command_context,
|
|
| 251 | + **_run_kwargs(
|
|
| 252 | + platform="debian_x86_64",
|
|
| 253 | + channel=None,
|
|
| 254 | + artifacts_url="https://example.org/artifacts/",
|
|
| 255 | + mozharness_url="https://example.org/mozharness.zip",
|
|
| 256 | + dry_run=True,
|
|
| 257 | + ),
|
|
| 258 | + )
|
|
| 259 | + |
|
| 260 | + assert result == 0
|
|
| 261 | + out = capsys.readouterr().out
|
|
| 262 | + assert "package_name" not in out
|
|
| 263 | + |
|
| 264 | + |
|
| 265 | +def test_run_rejects_unknown_channel(tmp_path, capsys):
|
|
| 266 | + write_config(tmp_path)
|
|
| 267 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 268 | + |
|
| 269 | + result = remote.run(command_context, **_run_kwargs(channel="not-a-channel"))
|
|
| 270 | + |
|
| 271 | + assert result == 1
|
|
| 272 | + assert "ERROR! Unknown channel 'not-a-channel'." in capsys.readouterr().out
|
|
| 273 | + |
|
| 274 | + |
|
| 275 | +@pytest.mark.parametrize("dry_run", [False, True])
|
|
| 276 | +@pytest.mark.parametrize("installer_url", ["", " ", "\t\n"])
|
|
| 277 | +def test_run_rejects_empty_installer(
|
|
| 278 | + tmp_path, monkeypatch, capsys, installer_url, dry_run
|
|
| 279 | +):
|
|
| 280 | + write_config(tmp_path)
|
|
| 281 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 282 | + monkeypatch.setattr(
|
|
| 283 | + remote, "_prompt_platform", lambda: pytest.fail("prompted for platform")
|
|
| 284 | + )
|
|
| 285 | + monkeypatch.setattr(
|
|
| 286 | + remote.upload, "run", lambda *args, **kwargs: pytest.fail("uploaded")
|
|
| 287 | + )
|
|
| 288 | + monkeypatch.setattr(
|
|
| 289 | + remote.urllib.request,
|
|
| 290 | + "urlopen",
|
|
| 291 | + lambda *args, **kwargs: pytest.fail("pipeline triggered"),
|
|
| 292 | + )
|
|
| 293 | + |
|
| 294 | + result = remote.run(
|
|
| 295 | + command_context,
|
|
| 296 | + **_run_kwargs(platform=None, installer_url=installer_url, dry_run=dry_run),
|
|
| 297 | + )
|
|
| 298 | + |
|
| 299 | + assert result == 1
|
|
| 300 | + out = capsys.readouterr().out
|
|
| 301 | + assert "ERROR! The installer URL or path was empty." in out
|
|
| 302 | + assert "curl" not in out
|
|
| 303 | + |
|
| 304 | + |
|
| 305 | +def test_run_trims_installer_url(tmp_path, capsys):
|
|
| 306 | + write_config(tmp_path)
|
|
| 307 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 308 | + |
|
| 309 | + result = remote.run(
|
|
| 310 | + command_context,
|
|
| 311 | + **_run_kwargs(installer_url=" https://example.org/app.apk\n", dry_run=True),
|
|
| 312 | + )
|
|
| 313 | + |
|
| 314 | + assert result == 0
|
|
| 315 | + assert '"android_x86_64_installer_url": "https://example.org/app.apk"' in (
|
|
| 316 | + capsys.readouterr().out
|
|
| 317 | + )
|
|
| 318 | + |
|
| 319 | + |
|
| 320 | +def test_run_ignores_channel_for_non_android_platforms(tmp_path, capsys):
|
|
| 321 | + write_config(tmp_path)
|
|
| 322 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 323 | + |
|
| 324 | + result = remote.run(
|
|
| 325 | + command_context,
|
|
| 326 | + **_run_kwargs(platform="debian_x86_64", channel="nightly", dry_run=True),
|
|
| 327 | + )
|
|
| 328 | + |
|
| 329 | + assert result == 0
|
|
| 330 | + out = capsys.readouterr().out
|
|
| 331 | + assert "package_name" not in out
|
|
| 332 | + assert remote.ANDROID_CHANNEL_PACKAGE_NAMES["nightly"] not in out
|
|
| 333 | + |
|
| 334 | + |
|
| 335 | +@pytest.mark.parametrize(
|
|
| 336 | + "platform,installer",
|
|
| 337 | + [
|
|
| 338 | + ("debian_x86_64", "tor-browser-linux-x86_64.tar.xz"),
|
|
| 339 | + ("windows_x86_64", "tor-browser-windows-x86_64-portable.exe"),
|
|
| 340 | + ("macos_x86_64", "tor-browser-macos.dmg"),
|
|
| 341 | + ("android_x86_64", "tor-browser-android-x86_64.apk"),
|
|
| 342 | + ],
|
|
| 343 | +)
|
|
| 344 | +def test_run_uploads_a_local_path_before_triggering(
|
|
| 345 | + tmp_path, monkeypatch, platform, installer
|
|
| 346 | +):
|
|
| 347 | + write_config(tmp_path)
|
|
| 348 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 349 | + installer_path = f"/tmp/{installer}"
|
|
| 350 | + uploaded_url = (
|
|
| 351 | + f"https://tb-build-03.torproject.org/~bea/yeet/ts-{platform}/{installer}"
|
|
| 352 | + )
|
|
| 353 | + uploads = []
|
|
| 354 | + |
|
| 355 | + def fake_upload_run(topsrcdir, file, platform, assume_yes):
|
|
| 356 | + uploads.append((file, platform))
|
|
| 357 | + return uploaded_url
|
|
| 358 | + |
|
| 359 | + monkeypatch.setattr(remote.upload, "run", fake_upload_run)
|
|
| 360 | + requests = []
|
|
| 361 | + |
|
| 362 | + def fake_urlopen(request, timeout=None):
|
|
| 363 | + requests.append(request)
|
|
| 364 | + return _FakeResponse(json.dumps({"web_url": "https://x"}).encode())
|
|
| 365 | + |
|
| 366 | + monkeypatch.setattr(remote.urllib.request, "urlopen", fake_urlopen)
|
|
| 367 | + |
|
| 368 | + result = remote.run(
|
|
| 369 | + command_context,
|
|
| 370 | + **_run_kwargs(platform=platform, installer_url=installer_path),
|
|
| 371 | + )
|
|
| 372 | + |
|
| 373 | + assert result == 0
|
|
| 374 | + assert uploads == [(installer_path, platform)]
|
|
| 375 | + inputs = json.loads(requests[0].data)["inputs"]
|
|
| 376 | + assert inputs[f"{platform}_installer_url"] == uploaded_url
|
|
| 377 | + assert (f"{platform}_package_name" in inputs) == (platform == "android_x86_64")
|
|
| 378 | + |
|
| 379 | + |
|
| 380 | +def test_run_prompts_for_platform_before_uploading(tmp_path, monkeypatch):
|
|
| 381 | + write_config(tmp_path)
|
|
| 382 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 383 | + monkeypatch.setattr(remote, "_prompt_platform", lambda: "debian_x86_64")
|
|
| 384 | + seen = []
|
|
| 385 | + |
|
| 386 | + def fake_upload_run(topsrcdir, file, platform, assume_yes):
|
|
| 387 | + seen.append(platform)
|
|
| 388 | + raise RuntimeError("stop here")
|
|
| 389 | + |
|
| 390 | + monkeypatch.setattr(remote.upload, "run", fake_upload_run)
|
|
| 391 | + |
|
| 392 | + remote.run(
|
|
| 393 | + command_context, **_run_kwargs(platform=None, installer_url="/tmp/app.tar.xz")
|
|
| 394 | + )
|
|
| 395 | + |
|
| 396 | + assert seen == ["debian_x86_64"]
|
|
| 397 | + |
|
| 398 | + |
|
| 399 | +@pytest.mark.parametrize("assume_yes", [False, True])
|
|
| 400 | +def test_run_forwards_assume_yes_to_upload(tmp_path, monkeypatch, assume_yes):
|
|
| 401 | + write_config(tmp_path)
|
|
| 402 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 403 | + seen = []
|
|
| 404 | + |
|
| 405 | + def fake_upload_run(topsrcdir, file, platform, assume_yes):
|
|
| 406 | + seen.append(assume_yes)
|
|
| 407 | + raise RuntimeError("stop here")
|
|
| 408 | + |
|
| 409 | + monkeypatch.setattr(remote.upload, "run", fake_upload_run)
|
|
| 410 | + |
|
| 411 | + remote.run(
|
|
| 412 | + command_context,
|
|
| 413 | + **_run_kwargs(installer_url="/tmp/app.apk", assume_yes=assume_yes),
|
|
| 414 | + )
|
|
| 415 | + |
|
| 416 | + assert seen == [assume_yes]
|
|
| 417 | + |
|
| 418 | + |
|
| 419 | +@pytest.mark.parametrize(
|
|
| 420 | + "tags,expected",
|
|
| 421 | + [
|
|
| 422 | + (["tor", "base-browser"], "--tag tor --tag base-browser"),
|
|
| 423 | + (["a b"], "--tag 'a b'"),
|
|
| 424 | + (["a b", '"c"'], """--tag 'a b' --tag '"c"'"""),
|
|
| 425 | + ],
|
|
| 426 | +)
|
|
| 427 | +def test_run_formats_tags_as_flags(tmp_path, monkeypatch, tags, expected):
|
|
| 428 | + write_config(tmp_path)
|
|
| 429 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 430 | + requests = []
|
|
| 431 | + |
|
| 432 | + def fake_urlopen(request, timeout=None):
|
|
| 433 | + requests.append(request)
|
|
| 434 | + return _FakeResponse(json.dumps({"web_url": "https://x"}).encode())
|
|
| 435 | + |
|
| 436 | + monkeypatch.setattr(remote.urllib.request, "urlopen", fake_urlopen)
|
|
| 437 | + |
|
| 438 | + remote.run(command_context, **_run_kwargs(tags=tags))
|
|
| 439 | + |
|
| 440 | + assert json.loads(requests[0].data)["inputs"]["tags"] == expected
|
|
| 441 | + |
|
| 442 | + |
|
| 443 | +def test_run_omits_tags_input_when_empty(tmp_path, monkeypatch):
|
|
| 444 | + write_config(tmp_path)
|
|
| 445 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 446 | + requests = []
|
|
| 447 | + |
|
| 448 | + def fake_urlopen(request, timeout=None):
|
|
| 449 | + requests.append(request)
|
|
| 450 | + return _FakeResponse(json.dumps({"web_url": "https://x"}).encode())
|
|
| 451 | + |
|
| 452 | + monkeypatch.setattr(remote.urllib.request, "urlopen", fake_urlopen)
|
|
| 453 | + |
|
| 454 | + remote.run(command_context, **_run_kwargs(tags=[]))
|
|
| 455 | + |
|
| 456 | + assert "tags" not in json.loads(requests[0].data)["inputs"]
|
|
| 457 | + |
|
| 458 | + |
|
| 459 | +def _triggered_inputs(tmp_path, monkeypatch, **overrides):
|
|
| 460 | + write_config(tmp_path)
|
|
| 461 | + _write_version(tmp_path, "1.2.3")
|
|
| 462 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 463 | + requests = []
|
|
| 464 | + |
|
| 465 | + def fake_urlopen(request, timeout=None):
|
|
| 466 | + requests.append(request)
|
|
| 467 | + return _FakeResponse(json.dumps({"web_url": "https://x"}).encode())
|
|
| 468 | + |
|
| 469 | + monkeypatch.setattr(remote.urllib.request, "urlopen", fake_urlopen)
|
|
| 470 | + |
|
| 471 | + assert remote.run(command_context, **_run_kwargs(**overrides)) == 0
|
|
| 472 | + return json.loads(requests[0].data)["inputs"]
|
|
| 473 | + |
|
| 474 | + |
|
| 475 | +@pytest.mark.parametrize("sha256sums_url", [None, "https://example.org/sha256sums.txt"])
|
|
| 476 | +def test_run_includes_sha256sums_input_only_when_given(
|
|
| 477 | + tmp_path, monkeypatch, sha256sums_url
|
|
| 478 | +):
|
|
| 479 | + inputs = _triggered_inputs(tmp_path, monkeypatch, sha256sums_url=sha256sums_url)
|
|
| 480 | + |
|
| 481 | + assert inputs.get("android_x86_64_sha256sums_url") == sha256sums_url
|
|
| 482 | + |
|
| 483 | + |
|
| 484 | +@pytest.mark.parametrize("artifacts_url", [None, "https://example.org/artifacts/"])
|
|
| 485 | +@pytest.mark.parametrize("mozharness_url", [None, "https://example.org/mozharness.zip"])
|
|
| 486 | +def test_run_uses_given_artifacts_and_mozharness_urls_or_infers_them(
|
|
| 487 | + tmp_path, monkeypatch, artifacts_url, mozharness_url
|
|
| 488 | +):
|
|
| 489 | + inputs = _triggered_inputs(
|
|
| 490 | + tmp_path,
|
|
| 491 | + monkeypatch,
|
|
| 492 | + artifacts_url=artifacts_url,
|
|
| 493 | + mozharness_url=mozharness_url,
|
|
| 494 | + )
|
|
| 495 | + |
|
| 496 | + assert inputs["android_x86_64_artifacts_url"] == (
|
|
| 497 | + artifacts_url or remote._artifacts_url("1.2.3", "android_x86_64")
|
|
| 498 | + )
|
|
| 499 | + assert inputs["mozharness_url"] == (
|
|
| 500 | + mozharness_url or remote._mozharness_url("1.2.3", "android_x86_64")
|
|
| 501 | + )
|
|
| 502 | + |
|
| 503 | + |
|
| 504 | +def test_run_aborts_when_local_upload_fails(tmp_path, monkeypatch, capsys):
|
|
| 505 | + write_config(tmp_path)
|
|
| 506 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 507 | + monkeypatch.setattr(remote.upload, "run", _failing_upload_run)
|
|
| 508 | + monkeypatch.setattr(
|
|
| 509 | + remote.urllib.request,
|
|
| 510 | + "urlopen",
|
|
| 511 | + lambda *args, **kwargs: pytest.fail("pipeline triggered after failed upload"),
|
|
| 512 | + )
|
|
| 513 | + |
|
| 514 | + result = remote.run(command_context, **_run_kwargs(installer_url="/tmp/app.apk"))
|
|
| 515 | + |
|
| 516 | + assert result == 1
|
|
| 517 | + assert "ERROR! boom" in capsys.readouterr().out
|
|
| 518 | + |
|
| 519 | + |
|
| 520 | +def test_run_dry_run_does_not_upload_a_local_path(tmp_path, monkeypatch, capsys):
|
|
| 521 | + write_config(tmp_path)
|
|
| 522 | + apk = tmp_path / "app.apk"
|
|
| 523 | + apk.write_bytes(b"not really an apk")
|
|
| 524 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 525 | + monkeypatch.setattr(
|
|
| 526 | + remote.upload,
|
|
| 527 | + "run",
|
|
| 528 | + lambda *args, **kwargs: pytest.fail("uploaded during a dry run"),
|
|
| 529 | + )
|
|
| 530 | + |
|
| 531 | + result = remote.run(
|
|
| 532 | + command_context, **_run_kwargs(installer_url=str(apk), dry_run=True)
|
|
| 533 | + )
|
|
| 534 | + |
|
| 535 | + assert result == 0
|
|
| 536 | + assert "<URL of uploaded app.apk>" in capsys.readouterr().out
|
|
| 537 | + |
|
| 538 | + |
|
| 539 | +def test_run_dry_run_rejects_missing_local_path(tmp_path, capsys):
|
|
| 540 | + write_config(tmp_path)
|
|
| 541 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 542 | + missing = tmp_path / "missing.apk"
|
|
| 543 | + |
|
| 544 | + result = remote.run(
|
|
| 545 | + command_context, **_run_kwargs(installer_url=str(missing), dry_run=True)
|
|
| 546 | + )
|
|
| 547 | + |
|
| 548 | + assert result == 1
|
|
| 549 | + out = capsys.readouterr().out
|
|
| 550 | + assert f"ERROR! No such file: {missing}" in out
|
|
| 551 | + assert "curl" not in out
|
|
| 552 | + |
|
| 553 | + |
|
| 554 | +def test_run_infers_version_artifacts_and_mozharness(tmp_path, capsys):
|
|
| 555 | + write_config(tmp_path)
|
|
| 556 | + _write_version(tmp_path, "1.2.3")
|
|
| 557 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 558 | + |
|
| 559 | + result = remote.run(
|
|
| 560 | + command_context,
|
|
| 561 | + **_run_kwargs(artifacts_url=None, mozharness_url=None, dry_run=True),
|
|
| 562 | + )
|
|
| 563 | + |
|
| 564 | + assert result == 0
|
|
| 565 | + out = capsys.readouterr().out
|
|
| 566 | + assert "1.2.3" in out
|
|
| 567 | + assert "android-x86_64" in out
|
|
| 568 | + |
|
| 569 | + |
|
| 570 | +def test_run_triggers_pipeline_and_reports_url(tmp_path, monkeypatch, capsys):
|
|
| 571 | + config = write_config(tmp_path)
|
|
| 572 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 573 | + |
|
| 574 | + requests = []
|
|
| 575 | + |
|
| 576 | + def fake_urlopen(request, timeout=None):
|
|
| 577 | + requests.append(request)
|
|
| 578 | + body = json.dumps({"web_url": "https://gitlab.example/pipelines/1"}).encode()
|
|
| 579 | + return _FakeResponse(body)
|
|
| 580 | + |
|
| 581 | + monkeypatch.setattr(remote.urllib.request, "urlopen", fake_urlopen)
|
|
| 582 | + |
|
| 583 | + result = remote.run(command_context, **_run_kwargs())
|
|
| 584 | + |
|
| 585 | + assert result == 0
|
|
| 586 | + assert "https://gitlab.example/pipelines/1" in capsys.readouterr().out
|
|
| 587 | + assert requests[0].full_url == (
|
|
| 588 | + f"{remote.GITLAB_API_BASE}/projects/"
|
|
| 589 | + f"{config['project'].replace('/', '%2F')}/trigger/pipeline"
|
|
| 590 | + )
|
|
| 591 | + payload = json.loads(requests[0].data)
|
|
| 592 | + assert payload["token"] == config["token"]
|
|
| 593 | + assert payload["ref"] == "main"
|
|
| 594 | + assert payload["inputs"]["tags"] == "--tag tor"
|
|
| 595 | + |
|
| 596 | + |
|
| 597 | +def test_run_reports_http_error(tmp_path, monkeypatch, capsys):
|
|
| 598 | + write_config(tmp_path)
|
|
| 599 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 600 | + |
|
| 601 | + def fake_urlopen(request, timeout=None):
|
|
| 602 | + raise urllib.error.HTTPError(
|
|
| 603 | + request.full_url,
|
|
| 604 | + 401,
|
|
| 605 | + "Unauthorized",
|
|
| 606 | + None,
|
|
| 607 | + SimpleNamespace(read=lambda: b"bad token", close=lambda: None),
|
|
| 608 | + )
|
|
| 609 | + |
|
| 610 | + monkeypatch.setattr(remote.urllib.request, "urlopen", fake_urlopen)
|
|
| 611 | + |
|
| 612 | + result = remote.run(command_context, **_run_kwargs())
|
|
| 613 | + |
|
| 614 | + assert result == 1
|
|
| 615 | + out = capsys.readouterr().out
|
|
| 616 | + assert "401" in out
|
|
| 617 | + assert "bad token" in out
|
|
| 618 | + |
|
| 619 | + |
|
| 620 | +def test_run_reports_url_error(tmp_path, monkeypatch, capsys):
|
|
| 621 | + write_config(tmp_path)
|
|
| 622 | + command_context = SimpleNamespace(topsrcdir=str(tmp_path))
|
|
| 623 | + |
|
| 624 | + def fake_urlopen(request, timeout=None):
|
|
| 625 | + raise urllib.error.URLError("unreachable")
|
|
| 626 | + |
|
| 627 | + monkeypatch.setattr(remote.urllib.request, "urlopen", fake_urlopen)
|
|
| 628 | + |
|
| 629 | + result = remote.run(command_context, **_run_kwargs())
|
|
| 630 | + |
|
| 631 | + assert result == 1
|
|
| 632 | + assert "unreachable" in capsys.readouterr().out
|
|
| 633 | + |
|
| 634 | + |
|
| 635 | +if __name__ == "__main__":
|
|
| 636 | + mozunit.main() |
| 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 | +from datetime import datetime, timedelta, timezone
|
|
| 6 | +from types import SimpleNamespace
|
|
| 7 | + |
|
| 8 | +import mozunit
|
|
| 9 | +import pytest
|
|
| 10 | +from conftest import write_config
|
|
| 11 | +from yeet import upload
|
|
| 12 | + |
|
| 13 | + |
|
| 14 | +def _freeze_time(monkeypatch, fixed):
|
|
| 15 | + class _FrozenDatetime(datetime):
|
|
| 16 | + @classmethod
|
|
| 17 | + def now(cls, tz=None):
|
|
| 18 | + return fixed.astimezone(tz)
|
|
| 19 | + |
|
| 20 | + monkeypatch.setattr(upload, "datetime", _FrozenDatetime)
|
|
| 21 | + |
|
| 22 | + |
|
| 23 | +def test_resolve_file_missing():
|
|
| 24 | + with pytest.raises(FileNotFoundError, match="No such file"):
|
|
| 25 | + upload.resolve_file("/no/such/file/here")
|
|
| 26 | + |
|
| 27 | + |
|
| 28 | +def test_resolve_file_existing(tmp_path):
|
|
| 29 | + apk = tmp_path / "fenix-x86_64-debug.apk"
|
|
| 30 | + apk.write_bytes(b"not really an apk")
|
|
| 31 | + |
|
| 32 | + assert upload.resolve_file(str(apk)) == apk.resolve()
|
|
| 33 | + |
|
| 34 | + |
|
| 35 | +@pytest.mark.parametrize(
|
|
| 36 | + "fixed,expected",
|
|
| 37 | + [
|
|
| 38 | + (datetime(2026, 9, 14, 12, 34, 56, tzinfo=timezone.utc), "20260914-123456"),
|
|
| 39 | + (
|
|
| 40 | + datetime(
|
|
| 41 | + 2026, 9, 14, 18, 4, 56, tzinfo=timezone(timedelta(hours=5, minutes=30))
|
|
| 42 | + ),
|
|
| 43 | + "20260914-123456",
|
|
| 44 | + ),
|
|
| 45 | + (
|
|
| 46 | + datetime(2026, 9, 14, 21, 0, 0, tzinfo=timezone(timedelta(hours=-5))),
|
|
| 47 | + "20260915-020000",
|
|
| 48 | + ),
|
|
| 49 | + ],
|
|
| 50 | +)
|
|
| 51 | +def test_remote_dir_name_is_current_utc_time_and_platform(monkeypatch, fixed, expected):
|
|
| 52 | + _freeze_time(monkeypatch, fixed)
|
|
| 53 | + |
|
| 54 | + assert upload._remote_dir_name("android_x86_64") == f"{expected}-android_x86_64"
|
|
| 55 | + |
|
| 56 | + |
|
| 57 | +def test_upload_success(monkeypatch):
|
|
| 58 | + calls = []
|
|
| 59 | + |
|
| 60 | + def fake_run(cmd, **kwargs):
|
|
| 61 | + calls.append(cmd)
|
|
| 62 | + assert kwargs["stdin"] is upload.subprocess.DEVNULL
|
|
| 63 | + return SimpleNamespace(returncode=0)
|
|
| 64 | + |
|
| 65 | + monkeypatch.setattr(upload.subprocess, "run", fake_run)
|
|
| 66 | + |
|
| 67 | + url = upload._upload(upload.Path("/tmp/app.apk"), "bea", "example.org", "ts-os")
|
|
| 68 | + |
|
| 69 | + assert url == "https://example.org/~bea/yeet/ts-os/app.apk"
|
|
| 70 | + assert calls[0][0] == "ssh"
|
|
| 71 | + assert calls[0][-3:] == ["mkdir", "-p", "~/public_html/yeet/ts-os"]
|
|
| 72 | + assert calls[1][0] == "scp"
|
|
| 73 | + assert calls[1][-2:] == [
|
|
| 74 | + "/tmp/app.apk",
|
|
| 75 | + "bea@example.org:~/public_html/yeet/ts-os/app.apk",
|
|
| 76 | + ]
|
|
| 77 | + |
|
| 78 | + |
|
| 79 | +def test_upload_replaces_unsafe_filename_characters(monkeypatch):
|
|
| 80 | + calls = []
|
|
| 81 | + |
|
| 82 | + def fake_run(cmd, **kwargs):
|
|
| 83 | + calls.append(cmd)
|
|
| 84 | + return SimpleNamespace(returncode=0)
|
|
| 85 | + |
|
| 86 | + monkeypatch.setattr(upload.subprocess, "run", fake_run)
|
|
| 87 | + |
|
| 88 | + url = upload._upload(
|
|
| 89 | + upload.Path("/tmp/app (1)#%?.apk"), "bea", "example.org", "ts-os"
|
|
| 90 | + )
|
|
| 91 | + |
|
| 92 | + assert url == "https://example.org/~bea/yeet/ts-os/app__1____.apk"
|
|
| 93 | + assert calls[1][-2:] == [
|
|
| 94 | + "/tmp/app (1)#%?.apk",
|
|
| 95 | + "bea@example.org:~/public_html/yeet/ts-os/app__1____.apk",
|
|
| 96 | + ]
|
|
| 97 | + |
|
| 98 | + |
|
| 99 | +def test_upload_stops_if_mkdir_fails(monkeypatch):
|
|
| 100 | + calls = []
|
|
| 101 | + |
|
| 102 | + def fake_run(cmd, **kwargs):
|
|
| 103 | + calls.append(cmd)
|
|
| 104 | + return SimpleNamespace(returncode=1)
|
|
| 105 | + |
|
| 106 | + monkeypatch.setattr(upload.subprocess, "run", fake_run)
|
|
| 107 | + |
|
| 108 | + with pytest.raises(RuntimeError):
|
|
| 109 | + upload._upload(upload.Path("/tmp/app.apk"), "bea", "example.org", "ts-os")
|
|
| 110 | + |
|
| 111 | + assert len(calls) == 1
|
|
| 112 | + |
|
| 113 | + |
|
| 114 | +def test_upload_fails_if_scp_fails(monkeypatch):
|
|
| 115 | + def fake_run(cmd, **kwargs):
|
|
| 116 | + return SimpleNamespace(returncode=0 if cmd[0] == "ssh" else 1)
|
|
| 117 | + |
|
| 118 | + monkeypatch.setattr(upload.subprocess, "run", fake_run)
|
|
| 119 | + |
|
| 120 | + with pytest.raises(RuntimeError):
|
|
| 121 | + upload._upload(upload.Path("/tmp/app.apk"), "bea", "example.org", "ts-os")
|
|
| 122 | + |
|
| 123 | + |
|
| 124 | +def test_run_aborts_without_config(tmp_path):
|
|
| 125 | + apk = tmp_path / "app.apk"
|
|
| 126 | + apk.touch()
|
|
| 127 | + |
|
| 128 | + with pytest.raises(RuntimeError, match="mach yeet auth"):
|
|
| 129 | + upload.run(tmp_path, str(apk), "android_x86_64")
|
|
| 130 | + |
|
| 131 | + |
|
| 132 | +def test_run_aborts_when_file_missing(tmp_path):
|
|
| 133 | + write_config(tmp_path)
|
|
| 134 | + |
|
| 135 | + with pytest.raises(FileNotFoundError):
|
|
| 136 | + upload.run(tmp_path, str(tmp_path / "missing.apk"), "android_x86_64")
|
|
| 137 | + |
|
| 138 | + |
|
| 139 | +def _write_apk(tmp_path):
|
|
| 140 | + apk = tmp_path / "app.apk"
|
|
| 141 | + apk.write_bytes(b"not really an apk")
|
|
| 142 | + return apk
|
|
| 143 | + |
|
| 144 | + |
|
| 145 | +def _set_confirm_answers(monkeypatch, *answers):
|
|
| 146 | + answers = iter(answers)
|
|
| 147 | + monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
|
|
| 148 | + |
|
| 149 | + |
|
| 150 | +def test_run_uploads_into_timestamped_dir_and_prints_url(tmp_path, monkeypatch, capsys):
|
|
| 151 | + config = write_config(tmp_path)
|
|
| 152 | + apk = tmp_path / "app.apk"
|
|
| 153 | + apk.write_bytes(b"not really an apk")
|
|
| 154 | + _freeze_time(monkeypatch, datetime(2026, 9, 14, 12, 34, 56, tzinfo=timezone.utc))
|
|
| 155 | + |
|
| 156 | + calls = []
|
|
| 157 | + |
|
| 158 | + def fake_upload(file_path, ssh_user, ssh_host, remote_dir):
|
|
| 159 | + calls.append((file_path, ssh_user, ssh_host, remote_dir))
|
|
| 160 | + return f"https://{ssh_host}/~{ssh_user}/yeet/{remote_dir}/{file_path.name}"
|
|
| 161 | + |
|
| 162 | + monkeypatch.setattr(upload, "_upload", fake_upload)
|
|
| 163 | + |
|
| 164 | + url = upload.run(tmp_path, str(apk), "android_x86_64", assume_yes=True)
|
|
| 165 | + |
|
| 166 | + assert url == (
|
|
| 167 | + f"https://{config['ssh_host']}/~{config['ssh_user']}/yeet/20260914-123456-android_x86_64/app.apk"
|
|
| 168 | + )
|
|
| 169 | + assert calls == [
|
|
| 170 | + (
|
|
| 171 | + apk.resolve(),
|
|
| 172 | + config["ssh_user"],
|
|
| 173 | + config["ssh_host"],
|
|
| 174 | + "20260914-123456-android_x86_64",
|
|
| 175 | + )
|
|
| 176 | + ]
|
|
| 177 | + assert url in capsys.readouterr().out
|
|
| 178 | + |
|
| 179 | + |
|
| 180 | +def test_run_reports_upload_failure(tmp_path, monkeypatch):
|
|
| 181 | + write_config(tmp_path)
|
|
| 182 | + apk = tmp_path / "app.apk"
|
|
| 183 | + apk.write_bytes(b"not really an apk")
|
|
| 184 | + |
|
| 185 | + def fake_upload(file_path, ssh_user, ssh_host, remote_dir):
|
|
| 186 | + raise RuntimeError("boom")
|
|
| 187 | + |
|
| 188 | + monkeypatch.setattr(upload, "_upload", fake_upload)
|
|
| 189 | + |
|
| 190 | + with pytest.raises(RuntimeError, match="boom"):
|
|
| 191 | + upload.run(tmp_path, str(apk), "android_x86_64", assume_yes=True)
|
|
| 192 | + |
|
| 193 | + |
|
| 194 | +@pytest.mark.parametrize("answer", ["y", "yes", "Y"])
|
|
| 195 | +def test_run_uploads_after_confirmation(tmp_path, monkeypatch, answer):
|
|
| 196 | + write_config(tmp_path)
|
|
| 197 | + apk = _write_apk(tmp_path)
|
|
| 198 | + _set_confirm_answers(monkeypatch, answer)
|
|
| 199 | + monkeypatch.setattr(
|
|
| 200 | + upload,
|
|
| 201 | + "_upload",
|
|
| 202 | + lambda file_path, ssh_user, ssh_host, remote_dir: "https://x/y",
|
|
| 203 | + )
|
|
| 204 | + |
|
| 205 | + assert upload.run(tmp_path, str(apk), "android_x86_64") == "https://x/y"
|
|
| 206 | + |
|
| 207 | + |
|
| 208 | +@pytest.mark.parametrize("answers", [("",), ("n",), ("no",), ("maybe", "n")])
|
|
| 209 | +def test_run_does_not_upload_when_declined(tmp_path, monkeypatch, answers):
|
|
| 210 | + write_config(tmp_path)
|
|
| 211 | + apk = _write_apk(tmp_path)
|
|
| 212 | + _set_confirm_answers(monkeypatch, *answers)
|
|
| 213 | + monkeypatch.setattr(
|
|
| 214 | + upload,
|
|
| 215 | + "_upload",
|
|
| 216 | + lambda *args: pytest.fail("_upload called after the user declined"),
|
|
| 217 | + )
|
|
| 218 | + |
|
| 219 | + with pytest.raises(RuntimeError, match="cancelled"):
|
|
| 220 | + upload.run(tmp_path, str(apk), "android_x86_64")
|
|
| 221 | + |
|
| 222 | + |
|
| 223 | +def test_run_skips_confirmation_with_assume_yes(tmp_path, monkeypatch):
|
|
| 224 | + write_config(tmp_path)
|
|
| 225 | + apk = _write_apk(tmp_path)
|
|
| 226 | + monkeypatch.setattr(
|
|
| 227 | + "builtins.input", lambda prompt="": pytest.fail("unexpected prompt")
|
|
| 228 | + )
|
|
| 229 | + monkeypatch.setattr(
|
|
| 230 | + upload,
|
|
| 231 | + "_upload",
|
|
| 232 | + lambda file_path, ssh_user, ssh_host, remote_dir: "https://x/y",
|
|
| 233 | + )
|
|
| 234 | + |
|
| 235 | + assert (
|
|
| 236 | + upload.run(tmp_path, str(apk), "android_x86_64", assume_yes=True)
|
|
| 237 | + == "https://x/y"
|
|
| 238 | + )
|
|
| 239 | + |
|
| 240 | + |
|
| 241 | +if __name__ == "__main__":
|
|
| 242 | + mozunit.main() |
| 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 re
|
|
| 6 | +import subprocess
|
|
| 7 | +from datetime import datetime, timezone
|
|
| 8 | +from pathlib import Path
|
|
| 9 | + |
|
| 10 | +from yeet.common import TERM, YEET_CONFIG_FILENAME, confirm, read_config
|
|
| 11 | + |
|
| 12 | + |
|
| 13 | +def resolve_file(file_arg):
|
|
| 14 | + path = Path(file_arg).resolve()
|
|
| 15 | + if not path.is_file():
|
|
| 16 | + raise FileNotFoundError(f"No such file: {path}")
|
|
| 17 | + return path
|
|
| 18 | + |
|
| 19 | + |
|
| 20 | +def _remote_dir_name(platform):
|
|
| 21 | + """Name the upload's directory after the current time and `platform`,
|
|
| 22 | + so repeated uploads never collide or overwrite each other.
|
|
| 23 | + """
|
|
| 24 | + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
| 25 | + return f"{timestamp}-{platform}"
|
|
| 26 | + |
|
| 27 | + |
|
| 28 | +def _upload(file_path, ssh_user, ssh_host, remote_dir):
|
|
| 29 | + """scp `file_path` into ~/public_html/yeet/`remote_dir` on
|
|
| 30 | + ssh_user@ssh_host, keeping its name (with characters unsafe in a URL or
|
|
| 31 | + shell replaced by `_`) and creating that directory first if needed. Both
|
|
| 32 | + commands get /dev/null as stdin so they can't swallow input buffered for
|
|
| 33 | + us; their touch/PIN/passphrase prompts go through /dev/tty and still reach
|
|
| 34 | + the user.
|
|
| 35 | + |
|
| 36 | + Returns the file's final https:// URL. Raises RuntimeError on failure.
|
|
| 37 | + """
|
|
| 38 | + ssh_target = f"{ssh_user}@{ssh_host}"
|
|
| 39 | + remote_name = re.sub(r"[^A-Za-z0-9._-]", "_", file_path.name)
|
|
| 40 | + dest_dir = f"~/public_html/yeet/{remote_dir}"
|
|
| 41 | + dest_path = f"{dest_dir}/{remote_name}"
|
|
| 42 | + html_path = f"~{ssh_user}/yeet/{remote_dir}/{remote_name}"
|
|
| 43 | + |
|
| 44 | + mkdir = subprocess.run(
|
|
| 45 | + ["ssh", ssh_target, "mkdir", "-p", dest_dir],
|
|
| 46 | + stdin=subprocess.DEVNULL,
|
|
| 47 | + check=False,
|
|
| 48 | + )
|
|
| 49 | + if mkdir.returncode != 0:
|
|
| 50 | + raise RuntimeError(f"Could not create {dest_dir} on {ssh_target}.")
|
|
| 51 | + |
|
| 52 | + scp = subprocess.run(
|
|
| 53 | + ["scp", str(file_path), f"{ssh_target}:{dest_path}"],
|
|
| 54 | + stdin=subprocess.DEVNULL,
|
|
| 55 | + check=False,
|
|
| 56 | + )
|
|
| 57 | + if scp.returncode != 0:
|
|
| 58 | + raise RuntimeError(f"Upload to {ssh_target} failed.")
|
|
| 59 | + |
|
| 60 | + return f"https://{ssh_host}/{html_path}"
|
|
| 61 | + |
|
| 62 | + |
|
| 63 | +def run(topsrcdir, file, platform, assume_yes=False):
|
|
| 64 | + """Upload `file` for `platform` to the configured server, asking for
|
|
| 65 | + confirmation first unless `assume_yes`. Returns the file's final https://
|
|
| 66 | + URL. Raises FileNotFoundError if `file` or the config is missing, and
|
|
| 67 | + RuntimeError if the upload fails or is cancelled.
|
|
| 68 | + """
|
|
| 69 | + config = read_config(topsrcdir)
|
|
| 70 | + if config is None:
|
|
| 71 | + raise RuntimeError(
|
|
| 72 | + f"No {YEET_CONFIG_FILENAME} found. Run `mach yeet auth` first."
|
|
| 73 | + )
|
|
| 74 | + |
|
| 75 | + file_path = resolve_file(file)
|
|
| 76 | + |
|
| 77 | + remote_dir = _remote_dir_name(platform)
|
|
| 78 | + |
|
| 79 | + if not assume_yes:
|
|
| 80 | + print()
|
|
| 81 | + print(
|
|
| 82 | + TERM.yellow(
|
|
| 83 | + f"{file_path} will be uploaded to {config['ssh_host']} and "
|
|
| 84 | + "be publicly accessible."
|
|
| 85 | + )
|
|
| 86 | + )
|
|
| 87 | + if not confirm("Upload it?"):
|
|
| 88 | + raise RuntimeError("Upload cancelled.")
|
|
| 89 | + |
|
| 90 | + print()
|
|
| 91 | + print(f"Uploading {file_path.name} to {remote_dir}/...")
|
|
| 92 | + url = _upload(file_path, config["ssh_user"], config["ssh_host"], remote_dir)
|
|
| 93 | + |
|
| 94 | + print()
|
|
| 95 | + print(TERM.green(f"Uploaded: {url}"))
|
|
| 96 | + return url |