# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/.
"""Implements Auth0 Device Code flow and Lando try submission.
import base64 import configparser import json import os import time import webbrowser from dataclasses import (
dataclass,
field,
) from pathlib import Path from typing import Union
import requests from mach.util import get_state_dir from mozbuild.base import MozbuildObject from mozversioncontrol import (
GitRepository,
HgRepository,
JujutsuRepository,
)
# The supported variants of `Repository` for this workflow.
SupportedVcsRepository = Union[GitRepository, HgRepository, JujutsuRepository]
here = os.path.abspath(os.path.dirname(__file__))
build = MozbuildObject.from_environment(cwd=here)
def convert_bytes_patch_to_base64(patch_bytes: bytes) -> str: """Return a base64 encoded `str` representing the passed `bytes` patch.""" return base64.b64encode(patch_bytes).decode("ascii")
def load_token_from_disk() -> dict | None: """Load and validate an existing Auth0 token from disk.
Return the token as a `dict` if it can be validated, orreturn `None` if any error was encountered. """ ifnot TOKEN_FILE.exists():
print("No existing Auth0 token found.") returnNone
try:
user_token = json.loads(TOKEN_FILE.read_bytes()) except json.JSONDecodeError:
print("Existing Auth0 token could not be decoded as JSON.") returnNone
return user_token
def get_stack_info(
vcs: SupportedVcsRepository, head: str | None
) -> tuple[str, str, list[str]]: """Retrieve information about the current stack for submission via Lando.
Returns a tuple of the current public base commit as a Mercurial SHA, and a list of ordered base64 encoded patches. """ # Get the appropriate base commit hash format. # Use `git` for Git-native checkouts of Firefox. # Use `hg` for Mercurial repos and `git-cinnabar` clones.
base_commit_vcs = ( "git"if vcs.name in ("git", "jj") andnot vcs.is_cinnabar_repo() else"hg"
)
base_commit = (
vcs.base_ref_as_hg() if base_commit_vcs == "hg"else vcs.base_ref_as_commit()
) ifnot base_commit: raise ValueError("Could not determine base commit hash for submission.")
print("Using", base_commit, f"as the {base_commit_vcs} base commit.")
# Reuse the base revision when on Mercurial to avoid multiple calls to `hg log`.
get_commits_kwargs = {} if isinstance(vcs, HgRepository):
get_commits_kwargs["base_ref"] = base_commit
nodes = vcs.get_commits(head, **get_commits_kwargs) ifnot nodes: raise ValueError("Could not find any commit hashes for submission.") elif len(nodes) == 1:
print("Submitting a single try config commit.") elif len(nodes) == 2:
print("Submitting 1 node and the try commit.") else:
print("Submitting stack of", len(nodes) - 1, "nodes and the try commit.")
patches = vcs.get_commit_patches(nodes)
base64_patches = [
convert_bytes_patch_to_base64(patch_bytes) for patch_bytes in patches
]
@property def jwks_url(self) -> str: """URL of the JWKS file.""" return f"{self.base_url}/.well-known/jwks.json"
@property def oauth_token_url(self) -> str: """URL of the OAuth Token endpoint.""" return f"{self.base_url}/oauth/token"
def request_device_code(self) -> dict: """Request authorization from Auth0 using the Device Code Flow.
See https://auth0.com/docs/api/authentication#get-device-code for more. """
response = requests.post(
self.device_code_url,
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={ "audience": self.audience, "client_id": self.client_id, "scope": self.scope,
},
)
response.raise_for_status()
return response.json()
def validate_token(self, user_token: dict) -> dict | None: """Verify the given user token is valid.
Validate the ID token, and validate the access token's expiration claim. """ # Import `auth0-python` here to avoid `ImportError` in tests, since # the `python-test` site won't have `auth0-python` installed. import jwt from auth0.authentication.token_verifier import (
AsymmetricSignatureVerifier,
TokenVerifier,
) from auth0.exceptions import (
TokenValidationError,
)
try:
token_verifier.verify(user_token["id_token"]) except TokenValidationError as e: if"Expiration Time (exp) claim error"in str(e): # This is the most common error, and the default one is very technical # and verbose: # Could not validate existing Auth0 ID token: Expiration Time (exp) # claim error in the ID token; current time (1750343040.7194722) is # after expiration time (1699120554) # Instead of that mess, print something clear and concise.
print("Your Auth0 token has expired.") else:
print("Could not validate existing Auth0 ID token:", str(e)) returnNone
# Assert that the access token isn't expired or expiring within a minute. if time.time() > access_token_expiration + 60:
print("Access token is expired.") returnNone
device_code_data = self.request_device_code()
print( "1. On your computer or mobile device navigate to:",
device_code_data["verification_uri_complete"],
)
print("2. Enter the following code:", device_code_data["user_code"])
if LAUNCH_BROWSER: try:
webbrowser.open(device_code_data["verification_uri_complete"]) except webbrowser.Error:
print("Could not automatically open the web browser.")
# Print successive periods on the same line to avoid moving the link # while the user is trying to click it.
print("Waiting...", end="", flush=True) while time.perf_counter() - start < device_code_lifetime_s:
response = requests.post(
self.oauth_token_url,
data={ "client_id": self.client_id, "device_code": device_code_data["device_code"], "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "scope": self.scope,
},
)
response_data = response.json()
if response.status_code == 200: # Terminate the in-progress "Waiting......" line.
print() return response_data
if response_data["error"] notin ("authorization_pending", "slow_down"): raise RuntimeError(response_data["error_description"])
raise ValueError("Timed out waiting for Auth0 device code authentication!")
def get_token(self) -> dict: """Retrieve an access token for authentication.
If a cached token is found and can be confirmed to be valid, return it.
Otherwise, perform the Device Code Flow authorization to request a new
token, validate it and save it to disk. """ # Load a cached token and validate it if one is available.
cached_token = load_token_from_disk()
user_token = self.validate_token(cached_token) if cached_token elseNone
# Login with the Device Authorization Flow if an existing token isn't found. ifnot user_token:
new_token = self.device_authorization_flow()
user_token = self.validate_token(new_token)
ifnot user_token: raise ValueError("Could not get an Auth0 token.")
# Save token to disk. with TOKEN_FILE.open("w") as f:
json.dump(user_token, f, indent=2, sort_keys=True)
return user_token
class LandoAPIException(Exception): """Raised when Lando throws an exception."""
def lando_try_status_url(self, job_id: int) -> str: """URL of the Lando Try Job Status HTML endpoint in new Lando.""" return f"https://{self.api_url}/landings/{job_id}"
@property def api_headers(self) -> dict[str, str]: """Headers for use accessing and authenticating against the API.""" return { "Authorization": f"Bearer {self.access_token}", "Content-Type": "application/json",
}
@classmethod def from_lando_config_file(cls, config_path: Path, section: str) -> LandoAPI: """Build a `LandoConfig` from `section` in the file at `config_path`.""" ifnot config_path.exists(): raise ValueError(f"Could not find a Lando config file at `{config_path}`.")
def post(self, url: str, body: dict) -> dict: """Make a POST request to Lando."""
response = requests.post(
url, headers=self.api_headers, json=body, verify=self.verify_tls
)
try:
response_json = response.json() except json.JSONDecodeError: # If the server didn't send back a valid JSON object, raise a stack # trace to the terminal which includes error details.
response.raise_for_status()
# Raise `ValueError` if the response wasn't JSON and we didn't raise # from an invalid status. raise LandoAPIException(
detail="Response was not valid JSON yet status was valid."
)
if response.status_code >= 400: raise LandoAPIException(detail=response_json["detail"])
Send the list of base64-encoded `patches` in `patch_format` to Lando, to be applied to
the Mercurial `base_commit`, using the Auth0 `access_token` for authorization. """
request_json_body = { "base_commit": base_commit, "base_commit_vcs": base_commit_vcs, "patch_format": patch_format, "patches": patches,
}
def push_to_lando_try(
vcs: SupportedVcsRepository,
commit_message: str,
changed_files: dict,
metrics,
*,
force_old_lando: bool = False,
): """Push a set of patches to Lando's try endpoint."""
metrics.mach_try.vcs_prep.start() # Map `Repository` subclasses to the `patch_format` value Lando expects.
PATCH_FORMAT_STRING_MAPPING = {
GitRepository: "git-format-patch",
HgRepository: "hgexport",
JujutsuRepository: "git-format-patch",
}
patch_format = PATCH_FORMAT_STRING_MAPPING.get(type(vcs)) ifnot patch_format: # Other VCS types (namely `src`) are unsupported. raise ValueError(f"Try push via Lando is not supported for `{vcs.name}`.")
# Load Auth0 config from `.lando.ini`.
lando_api = get_lando_api_config(vcs.path)
# Get the time when the push was initiated, not including Auth0 login time.
push_start_time = time.perf_counter()
def get_lando_instance_id(vcs_path: str, section_name: str | None = None) -> str: """Return the lando instance ID from the given config section, with default."""
lando_api = get_lando_api_config(vcs_path, section_name) return lando_api.instance_id
def get_lando_api_config(vcs_path: str, section_name: str | None = None) -> LandoAPI: """Initialise a LandoAPI object from the .lando.ini for the given section_name"""
lando_ini_path = Path(vcs_path) / ".lando.ini"
section_name = section_name or get_lando_config_section_name()
def get_lando_config_section_name() -> str: """Determine which lando config section to use.
This is based on defaults and overrides such as the LANDO_TRY_CONFIG env variable. """
default_lando_config_section = "lando-prod-new" return os.getenv("LANDO_TRY_CONFIG", default_lando_config_section)
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.