[Author Prev][Author Next][Thread Prev][Thread Next][Author Index][Thread Index]

[tor-commits] [Git][tpo/applications/tor-browser][tor-browser-153.5.0esr-16.0-1] 6 commits: BB 45308: Implement ./mach yeet command



Title: GitLab

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
    BB 45308: Implement ./mach yeet command
    
    ./mach yeet is the Tor Project version of try -- but much simpler.
    
    
  • 6b009dd1
    by Beatriz Rizental at 2026-10-09T06:15:17-03:00
    fixup! BB 45308: Implement ./mach yeet command
    
    Bug 45308: Implement ./mach yeet auth
    
    
  • fcf7cd1e
    by Beatriz Rizental at 2026-10-09T06:15:17-03:00
    fixup! BB 45308: Implement ./mach yeet command
    
    Bug 45308: Implement ./mach yeet's upload step
    
    Uploads a local file to the configured build server's public_html
    over scp.
    
    
  • 014bb2ac
    by Beatriz Rizental at 2026-10-09T06:15:18-03:00
    fixup! BB 45308: Implement ./mach yeet command
    
    Bug 45308: Implement ./mach yeet remote's CI trigger
    
    
  • 7cd8a546
    by Beatriz Rizental at 2026-10-09T06:15:19-03:00
    fixup! BB 45308: Implement ./mach yeet command
    
    Address review comments
    
  • 0cece149
    by Beatriz Rizental at 2026-10-09T06:16:59-03:00
    fixup! BB 45308: Implement ./mach yeet command
    
    Attend more review comments.
    

15 changed files:

Changes:

  • .gitignore
    ... ... @@ -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
    -

  • python/mach/mach/command_util.py
    ... ... @@ -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
     
    

  • tools/moz.build
    ... ... @@ -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
     ]

  • tools/yeet/__init__.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/.

  • tools/yeet/auth.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 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

  • tools/yeet/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 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)

  • tools/yeet/mach_commands.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
    +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="", 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)

  • tools/yeet/remote.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 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

  • tools/yeet/tests/python/conftest.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
    +
    
    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

  • tools/yeet/tests/python/python.toml
    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"]

  • tools/yeet/tests/python/test_auth.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@xxxxxxxxxxxxxxxxxxxxxxxxxx") is True
    
    122
    +    cmd, kwargs = calls[0]
    
    123
    +    assert cmd[-2:] == ["bea@xxxxxxxxxxxxxxxxxxxxxxxxxx", "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@xxxxxxxxxxxxxxxxxxxxxxxxxx") 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@xxxxxxxxxxxxxxxxxxxxxxxxxx") 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@xxxxxxxxxxxxxxxxx"]
    
    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()

  • tools/yeet/tests/python/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 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()

  • tools/yeet/tests/python/test_remote.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 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()

  • tools/yeet/tests/python/test_upload.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
    +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@xxxxxxxxxxx:~/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@xxxxxxxxxxx:~/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()

  • tools/yeet/upload.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 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

  • _______________________________________________
    tor-commits mailing list -- tor-commits@xxxxxxxxxxxxxxxxxxxx
    To unsubscribe send an email to tor-commits-leave@xxxxxxxxxxxxxxxxxxxx