[brockit] Kick out Rust/WASM CI jobs (#36903)
This PR adds a new command to brockit, `brockit gen-rust-wasm-toolchain`, which will be used to kick out a new toolchain run across all platforms. This is the first use of CI in `brockit`, but we potentially want this type of thing for all toolchains, so further work will follow to make this code more generic to other CI tasks. This is a very experimental implementation. Bug: https://github.com/brave/brave-browser/issues/55812
This commit is contained in:
+563
-33
@@ -190,6 +190,26 @@ Pass `--culprit=<hash>` to provide a specific culprit for the toolchain update.
|
||||
If none is provided, `brockit` will trying to determine the culprit by looking
|
||||
for the last commit updating the toolchain in Chromium.
|
||||
|
||||
### `brockit.py gen-rust-toolchain`
|
||||
This command triggers the Rust/WASM toolchain Jenkins pipelines (one per
|
||||
platform) for a given Chromium tag. It reads credentials from `~/.jenkins.json`
|
||||
and kicks off a parameterized build for each pipeline.
|
||||
|
||||
```sh
|
||||
tools/cr/brockit.py gen-rust-toolchain 150.0.7850.1
|
||||
```
|
||||
|
||||
The `tag` argument also accepts the same `@latest-*` labels (e.g. `@latest-tag`,
|
||||
etc).
|
||||
|
||||
Pass `--watch` to show a live-updating table of each pipeline's stage and
|
||||
status until all finish. Pressing Ctrl+C stops watching, but the builds keep
|
||||
running.
|
||||
|
||||
```sh
|
||||
tools/cr/brockit.py gen-rust-toolchain @latest-canary --watch
|
||||
```
|
||||
|
||||
### `brockit.py reassign`
|
||||
This command is used to change the authorship of a given commit in the branch.
|
||||
It generates an empty commit with the message `reassign! {original_subject}`
|
||||
@@ -226,10 +246,16 @@ import pickle
|
||||
import platform
|
||||
import re
|
||||
import requests
|
||||
from rich.box import Box
|
||||
from rich.live import Live
|
||||
from rich.markdown import Markdown
|
||||
from rich.padding import Padding
|
||||
from rich.spinner import Spinner
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from git_status import GitStatus
|
||||
from patchfile import Patchfile
|
||||
@@ -239,7 +265,8 @@ import rebase_v2
|
||||
from rebase_v2 import REASSIGN_COMMIT_MSG_PREFIX
|
||||
import repository
|
||||
from repository import Repository
|
||||
from terminal import IncendiaryErrorHandler, console, is_verbose, terminal
|
||||
from terminal import (IncendiaryErrorHandler, Task as BaseTask, console,
|
||||
is_verbose, terminal)
|
||||
import versioning
|
||||
from versioning import Version
|
||||
from vpython_utils import VPYTHON3_PATH
|
||||
@@ -279,6 +306,65 @@ CHROMIUM_MAC_SDK_GNI = 'build/config/mac/mac_sdk.gni'
|
||||
# Google dash link used to check the latest version for a given channel
|
||||
CHROMIUMDASH_LATEST_RELEASE = 'https://chromiumdash.appspot.com/fetch_releases?channel={channel}&platform={platform}&num=1'
|
||||
|
||||
# Configuration file holding the Jenkins credentials used to trigger toolchain
|
||||
# pipelines. See `GenRustToolchain` for the expected schema.
|
||||
JENKINS_CONFIG_FILE = Path.home() / '.jenkins.json'
|
||||
|
||||
# The Jenkins jobs that build and publish the Rust/WASM toolchain, one per
|
||||
# platform. Each accepts a single `CHROMIUM_TAG` build parameter.
|
||||
RUST_TOOLCHAIN_JOBS = (
|
||||
'brave-browser-rust-toolchain-aux-build-linux-x64',
|
||||
'brave-browser-rust-toolchain-aux-build-macos-arm64',
|
||||
'brave-browser-rust-toolchain-aux-build-macos-x64',
|
||||
'brave-browser-rust-toolchain-aux-build-windows-x64',
|
||||
)
|
||||
|
||||
# How often `gen-rust-toolchain --watch` polls Jenkins for pipeline progress.
|
||||
WATCH_POLL_INTERVAL_SECONDS = 8
|
||||
|
||||
# Maps Pipeline Stage View (`wfapi`) statuses onto the canonical states the
|
||||
# watch table tracks. Statuses not listed (e.g. NOT_EXECUTED) leave the state
|
||||
# unchanged.
|
||||
_WFAPI_STATUS_TO_STATE = {
|
||||
'IN_PROGRESS': 'RUNNING',
|
||||
'PAUSED_PENDING_INPUT': 'RUNNING',
|
||||
'SUCCESS': 'SUCCESS',
|
||||
'FAILED': 'FAILURE',
|
||||
'ABORTED': 'ABORTED',
|
||||
'UNSTABLE': 'UNSTABLE',
|
||||
}
|
||||
|
||||
# States meaning a pipeline has finished and no longer needs polling.
|
||||
_TERMINAL_WATCH_STATES = frozenset(
|
||||
{'SUCCESS', 'FAILURE', 'ABORTED', 'UNSTABLE', 'CANCELLED'})
|
||||
|
||||
# Minimalist box for the `--watch` table: column dividers plus a single
|
||||
# header rule, no outer frame (paired with `show_edge=False`). Each of the
|
||||
# eight lines is four chars -- left edge, cell fill, column divider, right
|
||||
# edge -- in the order Rich's `Box` expects (top, head, head-rule, mid, row,
|
||||
# foot-rule, foot, bottom). Edge/foot lines are placeholders never drawn once
|
||||
# the edge is hidden and the table has no footer.
|
||||
_WATCH_TABLE_BOX = Box(' \n' # top
|
||||
' │ \n' # head
|
||||
' ── \n' # head rule
|
||||
' │ \n' # mid
|
||||
' │ \n' # row
|
||||
' ── \n' # foot rule
|
||||
' │ \n' # foot
|
||||
' \n') # bottom
|
||||
|
||||
# Icon + rich style for each watch state, used to render the State column.
|
||||
_WATCH_STATE_STYLE = {
|
||||
'QUEUED': ('⏳', 'dim'),
|
||||
'RUNNING': ('🔧', 'cyan'),
|
||||
'SUCCESS': ('✔️', 'green'),
|
||||
'FAILURE': ('🚨', 'bold red'),
|
||||
'ABORTED': ('🛑', 'yellow'),
|
||||
'CANCELLED': ('🚫', 'yellow'),
|
||||
'UNSTABLE': ('⚠️', 'yellow'),
|
||||
'UNKNOWN': ('🤔', 'dim'),
|
||||
}
|
||||
|
||||
# A decorator to be shown for messages that the user should address before
|
||||
# continuing.
|
||||
ACTION_NEEDED_DECORATOR = '[bold yellow](action needed)[/]'
|
||||
@@ -575,42 +661,24 @@ class ActionNeededException(Exception):
|
||||
console.log(message)
|
||||
|
||||
|
||||
class Task:
|
||||
""" Base class for all tasks in brockit.
|
||||
# Banners framing every Brockit task run, shared with `run_watching`.
|
||||
_BROCKIT_START_BANNER = '[italic]🚀 Brockit!'
|
||||
_BROCKIT_END_BANNER = '[bold]💥 Done!'
|
||||
|
||||
This class provides a common interface for other tasks to build upon. It
|
||||
provides a run method that will execute the task, and a status_message
|
||||
method that will return a string to be displayed while the task is running.
|
||||
|
||||
class Task(BaseTask):
|
||||
"""Base class for all Brockit tasks.
|
||||
|
||||
Adds the Brockit banners around the generic `terminal.Task` run; the actual
|
||||
behaviour (status spinner, `execute`/`status_message` contract) lives in the
|
||||
base class. Subclasses provide `status_message`.
|
||||
"""
|
||||
|
||||
def run(self, **kwargs) -> bool:
|
||||
"""Runs the task with a status message.
|
||||
# Abstract base: concrete subclasses implement `status_message`.
|
||||
# pylint: disable=abstract-method
|
||||
|
||||
This function will run the task inside the scope of a status message.
|
||||
|
||||
Args:
|
||||
an open set of argument to be passed along to the derived class's
|
||||
execute method.
|
||||
|
||||
Returns:
|
||||
Return 1 if the task failed, 0 if the task succeeded. This is used
|
||||
as the process' exit code.
|
||||
"""
|
||||
console.log('[italic]🚀 Brockit!')
|
||||
with terminal.with_status(self.status_message()):
|
||||
# Calling `self.execute` triggers the linter, as there's no
|
||||
# `execute` method in this class. The derived classes are expected
|
||||
# to provide this method.
|
||||
# pylint: disable=no-member
|
||||
self.execute(**kwargs)
|
||||
console.log('[bold]💥 Done!')
|
||||
|
||||
def status_message(self) -> str:
|
||||
"""Returns a status message for the task.
|
||||
|
||||
This function has to be implemented by the derived class.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
start_banner = _BROCKIT_START_BANNER
|
||||
end_banner = _BROCKIT_END_BANNER
|
||||
|
||||
|
||||
class Versioned(Task):
|
||||
@@ -2396,6 +2464,441 @@ class UpdateXcodeToolchain(Task):
|
||||
})
|
||||
|
||||
|
||||
@dataclass
|
||||
class _WatchedJob:
|
||||
"""Mutable per-pipeline state tracked while `--watch` polls Jenkins."""
|
||||
|
||||
# The Jenkins job name.
|
||||
job: str
|
||||
|
||||
# The transient queue-item URL from the trigger's `Location` header.
|
||||
queue_url: str
|
||||
|
||||
# The build URL, resolved once an executor dequeues the job.
|
||||
build_url: str | None = None
|
||||
|
||||
# Canonical state: QUEUED / RUNNING / SUCCESS / FAILURE / ABORTED /
|
||||
# UNSTABLE / CANCELLED / UNKNOWN.
|
||||
state: str = 'QUEUED'
|
||||
|
||||
# Human-readable current stage (or the queue reason while QUEUED).
|
||||
stage: str = ''
|
||||
|
||||
# Pre-formatted elapsed build time (e.g. "12m04s"), as of the last poll.
|
||||
# Used as-is for non-running rows; RUNNING rows tick forward from the two
|
||||
# fields below instead (see `_ElapsedClock`).
|
||||
elapsed: str = ''
|
||||
|
||||
# The raw server-reported build duration (ms) at the last poll, and the
|
||||
# monotonic clock read at that same instant. Together they anchor a
|
||||
# RUNNING row's locally-ticking elapsed counter. Both None until a build
|
||||
# is resolved.
|
||||
duration_millis: int | None = None
|
||||
elapsed_anchor: float | None = None
|
||||
|
||||
# The pipeline's configured `display-name`, resolved once when watching
|
||||
# starts. None until resolved, or when the pipeline sets no display name.
|
||||
display_name: str | None = None
|
||||
|
||||
@property
|
||||
def bot(self) -> str:
|
||||
"""Label for the table's Bot column.
|
||||
|
||||
The pipeline's configured `display-name` when it has one, otherwise
|
||||
its Jenkins job name.
|
||||
"""
|
||||
return self.display_name or self.job
|
||||
|
||||
@property
|
||||
def is_terminal(self) -> bool:
|
||||
"""Whether the pipeline has finished and no longer needs polling."""
|
||||
return self.state in _TERMINAL_WATCH_STATES
|
||||
|
||||
def link(self, base_url: str) -> str:
|
||||
"""The best link available: the build URL, else the job page."""
|
||||
return self.build_url or f'{base_url}/job/{self.job}/'
|
||||
|
||||
|
||||
class _ElapsedClock:
|
||||
"""Renderable that advances a RUNNING job's elapsed time between polls.
|
||||
|
||||
Rich re-renders the `Live` tree on every refresh -- the same mechanism
|
||||
that animates the RUNNING spinner -- so recomputing the value here lets
|
||||
the counter tick smoothly at the Live refresh rate instead of jumping once
|
||||
per poll. `base_millis` is the duration the server reported at the last
|
||||
poll and `anchor` is the monotonic clock read at that same instant; the
|
||||
rendered value is `base + (now - anchor)`. Every poll re-anchors both, so
|
||||
the local ticking stays corrected by the authoritative server value and
|
||||
settles on it once the build leaves RUNNING.
|
||||
"""
|
||||
|
||||
def __init__(self, base_millis: int, anchor: float) -> None:
|
||||
self._base_millis = base_millis
|
||||
self._anchor = anchor
|
||||
|
||||
def __rich__(self) -> Text:
|
||||
elapsed_millis = (self._base_millis +
|
||||
(time.monotonic() - self._anchor) * 1000)
|
||||
return Text(GenRustToolchain._format_duration(elapsed_millis),
|
||||
justify='right')
|
||||
|
||||
|
||||
class GenRustToolchain(Task):
|
||||
"""Triggers the Rust/WASM toolchain Jenkins pipelines for a Chromium tag.
|
||||
|
||||
There is one pipeline per platform (see `RUST_TOOLCHAIN_JOBS`), and each
|
||||
takes a single `CHROMIUM_TAG` build parameter. This task resolves the
|
||||
requested version (accepting the same labels as `lift --to`, e.g.
|
||||
`@latest-canary`), reads the Jenkins credentials from `~/.jenkins.json`,
|
||||
and kicks off a parameterized build for every pipeline.
|
||||
|
||||
The expected `~/.jenkins.json` schema is:
|
||||
|
||||
{
|
||||
"url": "https://ci.brave.com",
|
||||
"username": "<ldap-user>",
|
||||
"token": "<jenkins-api-token>"
|
||||
}
|
||||
"""
|
||||
|
||||
def status_message(self):
|
||||
return "Triggering Rust toolchain builds..."
|
||||
|
||||
def run_watching(self, tag: str) -> None:
|
||||
"""Entry point for `--watch` that bypasses `Task.run`.
|
||||
|
||||
The watch table is a `rich.live.Live` display, and rich permits only
|
||||
one live display at a time. `Task.run` wraps `execute` in the shared
|
||||
status spinner -- itself a live display -- so the watch flow cannot go
|
||||
through it. This mirrors `run`'s banner framing but drives `execute`
|
||||
directly, without the spinner.
|
||||
"""
|
||||
console.log(self.start_banner)
|
||||
self.execute(tag=tag, watch=True)
|
||||
console.log(self.end_banner)
|
||||
|
||||
@staticmethod
|
||||
def _load_jenkins_config() -> tuple[str, str, str]:
|
||||
"""Reads the Jenkins base URL and credentials from `~/.jenkins.json`.
|
||||
|
||||
Returns:
|
||||
A `(base_url, username, token)` tuple, with any trailing slash
|
||||
stripped from the base URL.
|
||||
"""
|
||||
if not JENKINS_CONFIG_FILE.is_file():
|
||||
raise InvalidInputException(
|
||||
f'Jenkins config not found at {JENKINS_CONFIG_FILE}. Create it '
|
||||
'with [bold cyan]url[/], [bold cyan]username[/], and '
|
||||
'[bold cyan]token[/] fields.')
|
||||
|
||||
try:
|
||||
config = json.loads(
|
||||
JENKINS_CONFIG_FILE.read_bytes().decode('utf-8'))
|
||||
except json.JSONDecodeError as e:
|
||||
raise InvalidInputException(
|
||||
f'Failed to parse {JENKINS_CONFIG_FILE}: {e}') from e
|
||||
|
||||
missing = [
|
||||
key for key in ('url', 'username', 'token') if not config.get(key)
|
||||
]
|
||||
if missing:
|
||||
raise InvalidInputException(
|
||||
f'{JENKINS_CONFIG_FILE} is missing required field(s): '
|
||||
f'{", ".join(missing)}.')
|
||||
|
||||
return config['url'].rstrip('/'), config['username'], config['token']
|
||||
|
||||
@staticmethod
|
||||
def _get_crumb(session: requests.Session, base_url: str) -> dict[str, str]:
|
||||
"""Fetches a Jenkins CSRF crumb as a ready-to-merge header dict.
|
||||
|
||||
Returns an empty dict when the crumb issuer is unavailable. API-token
|
||||
auth is usually crumb-exempt, so a missing issuer is treated as "no
|
||||
crumb needed" rather than a hard failure.
|
||||
"""
|
||||
try:
|
||||
response = session.get(f'{base_url}/crumbIssuer/api/json',
|
||||
timeout=15)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return {data['crumbRequestField']: data['crumb']}
|
||||
except (requests.RequestException, KeyError, ValueError):
|
||||
return {}
|
||||
|
||||
def execute(self, tag: str, watch: bool = False):
|
||||
# Resolve the version first so a bad tag/label fails before we touch
|
||||
# any Jenkins state.
|
||||
version = _fetch_chromium_tag(tag)
|
||||
|
||||
base_url, username, token = self._load_jenkins_config()
|
||||
|
||||
session = requests.Session()
|
||||
session.auth = (username, token)
|
||||
crumb = self._get_crumb(session, base_url)
|
||||
|
||||
terminal.log_task(
|
||||
f'Triggering Rust toolchain pipelines for Chromium {version}:')
|
||||
|
||||
watched: list[_WatchedJob] = []
|
||||
failures: list[str] = []
|
||||
for job in RUST_TOOLCHAIN_JOBS:
|
||||
try:
|
||||
response = session.post(
|
||||
f'{base_url}/job/{job}/buildWithParameters',
|
||||
params={'CHROMIUM_TAG': str(version)},
|
||||
headers=crumb,
|
||||
timeout=30)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
failures.append(job)
|
||||
console.log(Padding(f'✘ {job}: {e}', (0, 4)))
|
||||
continue
|
||||
|
||||
# Jenkins' 201 Location header points at the transient queue item,
|
||||
# not the build: the build number isn't assigned until an executor
|
||||
# picks the job up, and the queue URL only serves JSON.
|
||||
queue_url = response.headers.get('Location', '')
|
||||
watched.append(_WatchedJob(job=job, queue_url=queue_url))
|
||||
if not watch:
|
||||
# Link the job page, which always resolves and surfaces the
|
||||
# queued/running build.
|
||||
terminal.log_task(f'[bold]✔️ [/]{job} ➜ {base_url}/job/{job}/')
|
||||
|
||||
if failures:
|
||||
raise BadOutcomeException(
|
||||
'Failed to trigger the following Rust toolchain pipelines:\n%s'
|
||||
% '\n'.join(f' * {job}' for job in failures))
|
||||
|
||||
if watch:
|
||||
self._watch(session, base_url, version, watched)
|
||||
|
||||
@staticmethod
|
||||
def _get_json(session: requests.Session, url: str) -> dict | None:
|
||||
"""GETs `url` and returns the parsed JSON, or None on any failure."""
|
||||
try:
|
||||
response = session.get(url, timeout=15)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except (requests.RequestException, ValueError):
|
||||
return None
|
||||
|
||||
def _resolve_display_name(self, session: requests.Session, base_url: str,
|
||||
job: _WatchedJob) -> None:
|
||||
"""Records a pipeline's `display-name` for the Bot column, if set.
|
||||
|
||||
Jenkins returns the job name as `displayName` when no display name is
|
||||
configured, so a value equal to the job name is treated as "unset" and
|
||||
left as None -- `_WatchedJob.bot` then falls back to the job name.
|
||||
"""
|
||||
info = self._get_json(
|
||||
session,
|
||||
f'{base_url}/job/{job.job}/api/json?tree=displayName,name')
|
||||
if info is None:
|
||||
return
|
||||
display_name = info.get('displayName')
|
||||
if display_name and display_name != info.get('name'):
|
||||
job.display_name = display_name
|
||||
|
||||
@staticmethod
|
||||
def _format_duration(millis) -> str:
|
||||
"""Formats a Jenkins millisecond duration as e.g. "12m04s"."""
|
||||
if not millis:
|
||||
return ''
|
||||
total_seconds = int(millis) // 1000
|
||||
minutes, seconds = divmod(total_seconds, 60)
|
||||
if minutes:
|
||||
return f'{minutes}m{seconds:02d}s'
|
||||
return f'{seconds}s'
|
||||
|
||||
def _poll_job(self, session: requests.Session, job: _WatchedJob) -> None:
|
||||
"""Refreshes one pipeline's state in place.
|
||||
|
||||
Resolves the queue item to a build the first time an executor picks it
|
||||
up, then tracks the running build's stage and result via the Pipeline
|
||||
Stage View API (falling back to the plain build API).
|
||||
"""
|
||||
if job.is_terminal:
|
||||
return
|
||||
|
||||
if job.build_url is None:
|
||||
if not job.queue_url:
|
||||
job.state = 'UNKNOWN'
|
||||
job.stage = 'no queue item returned'
|
||||
return
|
||||
item = self._get_json(session, f'{job.queue_url}api/json')
|
||||
if item is None:
|
||||
return # Transient; retry on the next poll.
|
||||
if item.get('cancelled'):
|
||||
job.state = 'CANCELLED'
|
||||
job.stage = 'queue item cancelled'
|
||||
return
|
||||
executable = item.get('executable')
|
||||
if not executable:
|
||||
# Still queued; surface Jenkins' reason (e.g. "Waiting for
|
||||
# next available executor").
|
||||
job.state = 'QUEUED'
|
||||
job.stage = (item.get('why') or 'waiting').strip()
|
||||
return
|
||||
job.build_url = executable.get('url') or ''
|
||||
job.state = 'RUNNING'
|
||||
|
||||
self._refresh_build_state(session, job)
|
||||
|
||||
def _record_elapsed(self, job: _WatchedJob, millis) -> None:
|
||||
"""Anchors a job's elapsed time to the latest server-reported duration.
|
||||
|
||||
Stores the raw duration and a monotonic timestamp so a RUNNING row can
|
||||
tick forward between polls (see `_ElapsedClock`), and keeps the
|
||||
formatted string used to render every non-running row.
|
||||
"""
|
||||
job.duration_millis = int(millis) if millis else None
|
||||
job.elapsed_anchor = time.monotonic()
|
||||
job.elapsed = self._format_duration(millis)
|
||||
|
||||
def _refresh_build_state(self, session: requests.Session,
|
||||
job: _WatchedJob) -> None:
|
||||
"""Updates state/stage/elapsed for a job that already has a build."""
|
||||
describe = self._get_json(session, f'{job.build_url}wfapi/describe')
|
||||
if describe is not None:
|
||||
job.state = _WFAPI_STATUS_TO_STATE.get(describe.get('status', ''),
|
||||
job.state)
|
||||
self._record_elapsed(job, describe.get('durationMillis'))
|
||||
stages = describe.get('stages') or []
|
||||
running = [s for s in stages if s.get('status') == 'IN_PROGRESS']
|
||||
if running:
|
||||
# The deepest reported in-progress stage is the most specific.
|
||||
job.stage = running[-1].get('name', '')
|
||||
elif job.is_terminal:
|
||||
job.stage = '(done)'
|
||||
elif stages:
|
||||
job.stage = stages[-1].get('name', '')
|
||||
return
|
||||
|
||||
# Fallback for jobs without the Stage View plugin: the plain build API
|
||||
# gives building/result but no per-stage detail.
|
||||
info = self._get_json(session, f'{job.build_url}api/json')
|
||||
if info is None:
|
||||
return
|
||||
self._record_elapsed(job, info.get('duration'))
|
||||
if info.get('building'):
|
||||
job.state = 'RUNNING'
|
||||
job.stage = 'building'
|
||||
else:
|
||||
job.state = info.get('result') or 'UNKNOWN'
|
||||
job.stage = '(done)'
|
||||
|
||||
@staticmethod
|
||||
def _state_cell(state: str) -> str | Spinner:
|
||||
"""Renders the State column.
|
||||
|
||||
RUNNING gets an animated throbber (the `Live` display drives the
|
||||
animation); every other state is a static icon + label.
|
||||
"""
|
||||
if state == 'RUNNING':
|
||||
return Spinner('dots', text='RUNNING', style='cyan')
|
||||
icon, style = _WATCH_STATE_STYLE.get(state, ('?', 'dim'))
|
||||
return f'[{style}]{icon} {state}[/]'
|
||||
|
||||
@staticmethod
|
||||
def _elapsed_cell(job: _WatchedJob) -> str | _ElapsedClock:
|
||||
"""Renders the Elapsed column.
|
||||
|
||||
A RUNNING build with a resolved duration ticks forward between polls
|
||||
via `_ElapsedClock`; every other state shows the static,
|
||||
server-accurate value captured at the last poll.
|
||||
"""
|
||||
if (job.state == 'RUNNING' and job.duration_millis is not None
|
||||
and job.elapsed_anchor is not None):
|
||||
return _ElapsedClock(job.duration_millis, job.elapsed_anchor)
|
||||
return job.elapsed or '—'
|
||||
|
||||
@staticmethod
|
||||
def _link_cell(url: str) -> Text:
|
||||
"""Renders a URL as a styled, clickable hyperlink for the Build column.
|
||||
|
||||
Table cells skip the `ReprHighlighter` that `console.log`/`print` run
|
||||
over their output, so a bare URL string renders as plain text. Wrapping
|
||||
it in a `Text` with a `link` style emits the OSC 8 hyperlink escape
|
||||
(clickable in capable terminals) and the blue underline mirrors the
|
||||
look Rich gives auto-detected URLs elsewhere in brockit.
|
||||
"""
|
||||
return Text(url, style=f'underline blue link {url}')
|
||||
|
||||
@staticmethod
|
||||
def _dim_cell(renderable: str | Text, dim: bool) -> str | Text:
|
||||
"""Dims a non-state cell for finished, non-successful rows.
|
||||
|
||||
Returns the renderable untouched when `dim` is False. Otherwise wraps
|
||||
it in Rich's `dim` style (preserving any existing style, such as the
|
||||
Build column's hyperlink) so the whole row reads as muted -- except the
|
||||
State cell, which the caller leaves coloured so the outcome stands out.
|
||||
"""
|
||||
if not dim:
|
||||
return renderable
|
||||
text = renderable if isinstance(renderable, Text) else Text(
|
||||
str(renderable))
|
||||
text.stylize('dim')
|
||||
return text
|
||||
|
||||
def _render_table(self, version: Version, base_url: str,
|
||||
jobs: list[_WatchedJob]) -> Table:
|
||||
"""Builds the per-pipeline progress table rendered in place by Live."""
|
||||
table = Table(title=f'Rust toolchain · Chromium {version}',
|
||||
title_justify='left',
|
||||
box=_WATCH_TABLE_BOX,
|
||||
show_edge=False,
|
||||
expand=True)
|
||||
table.add_column('Bot', no_wrap=True)
|
||||
table.add_column('State', no_wrap=True)
|
||||
table.add_column('Stage', no_wrap=True)
|
||||
table.add_column('Elapsed', justify='right', no_wrap=True)
|
||||
table.add_column('Build', overflow='fold')
|
||||
for job in jobs:
|
||||
# A job that has finished as anything other than SUCCESS is dimmed
|
||||
# across the row, except its State cell, which keeps its colour so
|
||||
# the failure still stands out.
|
||||
dim = job.is_terminal and job.state != 'SUCCESS'
|
||||
table.add_row(
|
||||
self._dim_cell(job.bot, dim), self._state_cell(job.state),
|
||||
self._dim_cell(job.stage or '—', dim),
|
||||
self._dim_cell(self._elapsed_cell(job), dim),
|
||||
self._dim_cell(self._link_cell(job.link(base_url)), dim))
|
||||
return table
|
||||
|
||||
def _watch(self, session: requests.Session, base_url: str,
|
||||
version: Version, jobs: list[_WatchedJob]) -> None:
|
||||
"""Polls the triggered pipelines, updating an in-place table until all
|
||||
finish or the user interrupts with Ctrl+C (which detaches and leaves
|
||||
the builds running).
|
||||
"""
|
||||
terminal.log_task('Watching pipelines — press [bold cyan]Ctrl+C[/] to '
|
||||
'stop watching (builds keep running).')
|
||||
# Resolve each pipeline's display name once up front so the Bot column
|
||||
# shows it from the very first render.
|
||||
for job in jobs:
|
||||
self._resolve_display_name(session, base_url, job)
|
||||
try:
|
||||
# The throbber on RUNNING rows animates off the Live's own refresh
|
||||
# (~12.5fps matches the `dots` spinner cadence); the data itself is
|
||||
# only re-polled every WATCH_POLL_INTERVAL_SECONDS.
|
||||
with Live(self._render_table(version, base_url, jobs),
|
||||
console=console,
|
||||
refresh_per_second=12.5) as live:
|
||||
while True:
|
||||
for job in jobs:
|
||||
self._poll_job(session, job)
|
||||
live.update(self._render_table(version, base_url, jobs))
|
||||
if all(job.is_terminal for job in jobs):
|
||||
break
|
||||
time.sleep(WATCH_POLL_INTERVAL_SECONDS)
|
||||
except KeyboardInterrupt:
|
||||
# Detach cleanly: the builds keep running on CI.
|
||||
console.log('[yellow]Stopped watching. Builds continue on CI:[/]')
|
||||
for job in jobs:
|
||||
console.log(Padding(f'{job.bot}: {job.link(base_url)}',
|
||||
(0, 4)))
|
||||
|
||||
|
||||
def fetch_chromium_dash_version(channel: str) -> Version:
|
||||
"""Fetches the highest latest version across all platforms for a channel.
|
||||
|
||||
@@ -2682,6 +3185,25 @@ def main():
|
||||
f'{CHROMIUM_MAC_SDK_GNI}.',
|
||||
dest='culprit')
|
||||
|
||||
gen_rust_parser = subparsers.add_parser(
|
||||
'gen-rust-toolchain',
|
||||
parents=[global_parser],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help='Triggers the Rust/WASM toolchain Jenkins pipelines for a '
|
||||
'Chromium tag. Requires credentials in ~/.jenkins.json.')
|
||||
gen_rust_parser.add_argument(
|
||||
'tag',
|
||||
help=('The Chromium version to build the toolchain for (e.g.\n'
|
||||
'150.0.7850.1), or one of the @latest-* labels accepted by\n'
|
||||
'`lift --to` (e.g. @latest-canary, @latest-m150,\n'
|
||||
'@latest-for-branch, @latest-tag).'))
|
||||
gen_rust_parser.add_argument(
|
||||
'--watch',
|
||||
action='store_true',
|
||||
help='After triggering, show a live-updating table of each '
|
||||
"pipeline's\nstage and status until all finish. Press Ctrl+C to stop "
|
||||
'watching;\nthe builds keep running.')
|
||||
|
||||
subparsers.add_parser('reference',
|
||||
help='Detailed documentation for this tool.')
|
||||
args = parser.parse_args()
|
||||
@@ -2741,6 +3263,14 @@ def main():
|
||||
Reassign().run(change=args.change)
|
||||
if args.command == 'update-xcode-toolchain':
|
||||
UpdateXcodeToolchain().run(url=args.url, culprit=args.culprit)
|
||||
if args.command == 'gen-rust-toolchain':
|
||||
task = GenRustToolchain()
|
||||
if args.watch:
|
||||
# `--watch` provdes live updates, therefore it doesn't use the
|
||||
# spinner provided by `Task.run`.
|
||||
task.run_watching(tag=args.tag)
|
||||
else:
|
||||
task.run(tag=args.tag)
|
||||
if args.command == 'reference':
|
||||
console.print(Markdown(__doc__))
|
||||
if args.command == 'show':
|
||||
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
#!/usr/bin/env vpython3
|
||||
# Copyright (c) 2026 The Brave Authors. All rights reserved.
|
||||
# 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 https://mozilla.org/MPL/2.0/.
|
||||
"""Integration tests for `brockit.GenRustToolchain` against a fake Jenkins.
|
||||
|
||||
Where `gen_rust_toolchain_test.py` mocks `requests.Session`, these tests stand
|
||||
up a real in-process HTTP server (`FakeJenkins`) that emulates the slice of the
|
||||
Jenkins REST API the task talks to:
|
||||
|
||||
* the CSRF crumb issuer (`/crumbIssuer/api/json`),
|
||||
* the parameterised trigger (`/job/<job>/buildWithParameters`, answered with a
|
||||
201 + `Location` header pointing at a transient queue item),
|
||||
* the build queue item (`/queue/item/<id>/api/json`), which hands back an
|
||||
`executable` build URL once an "executor" picks the job up, and
|
||||
* the Pipeline Stage View (`<build>/wfapi/describe`) with a plain build-API
|
||||
fallback (`<build>/api/json`).
|
||||
|
||||
The task drives genuine `requests` calls over a loopback socket, so basic-auth
|
||||
and crumb headers, the `CHROMIUM_TAG` parameter, the `Location`-header handoff,
|
||||
and the full queue -> build -> result lifecycle are all exercised end to end.
|
||||
|
||||
The fake advances each pipeline deterministically by counting polls (no wall
|
||||
clock involved): a build stays QUEUED for `queue_polls_before_start` polls,
|
||||
RUNNING for `running_polls_before_done` polls, then settles on `final_status`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from collections import defaultdict
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from unittest.mock import patch
|
||||
|
||||
import brockit
|
||||
|
||||
# A concrete version so `_fetch_chromium_tag` short-circuits to `Version(...)`
|
||||
# without touching git or the network.
|
||||
TAG = '150.0.7850.1'
|
||||
|
||||
# Maps the canonical `final_status` onto the equivalent Pipeline Stage View
|
||||
# (`wfapi`) status string the fake reports for a finished build.
|
||||
_FINAL_TO_WFAPI = {
|
||||
'SUCCESS': 'SUCCESS',
|
||||
'FAILURE': 'FAILED',
|
||||
'UNSTABLE': 'UNSTABLE',
|
||||
'ABORTED': 'ABORTED',
|
||||
}
|
||||
|
||||
|
||||
class _NullLive:
|
||||
"""Stand-in for `rich.live.Live` so the watch loop renders nothing."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def __enter__(self) -> _NullLive:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> bool:
|
||||
return False
|
||||
|
||||
def update(self, *args, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _job_from_build_path(path: str) -> str:
|
||||
"""Pulls `<job>` out of a `/job/<job>/<build-no>` path."""
|
||||
parts = [segment for segment in path.split('/') if segment]
|
||||
# parts == ['job', '<job>', '<build-no>'].
|
||||
return parts[1]
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
"""Routes the handful of endpoints the task hits to the owning fake."""
|
||||
|
||||
# Silence the default per-request stderr logging.
|
||||
def log_message(self, *args) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def fake(self) -> FakeJenkins:
|
||||
return self.server.fake
|
||||
|
||||
def send_json(self, payload: dict, status: int = 200) -> None:
|
||||
body = json.dumps(payload).encode('utf-8')
|
||||
self.send_response(status)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def send_empty(self, status: int, headers: dict | None = None) -> None:
|
||||
self.send_response(status)
|
||||
for key, value in (headers or {}).items():
|
||||
self.send_header(key, value)
|
||||
self.send_header('Content-Length', '0')
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
if path == '/crumbIssuer/api/json':
|
||||
self.fake.handle_crumb(self)
|
||||
elif path.startswith('/queue/item/') and path.endswith('/api/json'):
|
||||
qid = path[len('/queue/item/'):-len('/api/json')].strip('/')
|
||||
self.fake.handle_queue(self, qid)
|
||||
elif path.endswith('/wfapi/describe'):
|
||||
self.fake.handle_describe(
|
||||
self, _job_from_build_path(path[:-len('/wfapi/describe')]))
|
||||
elif path.startswith('/job/') and path.endswith('/api/json'):
|
||||
parts = [segment for segment in path.split('/') if segment]
|
||||
# ['job', <job>, 'api', 'json'] is the pipeline-level info, whereas
|
||||
# ['job', <job>, <build-no>, 'api', 'json'] is a build's API.
|
||||
if len(parts) == 4:
|
||||
self.fake.handle_job_info(self, parts[1])
|
||||
else:
|
||||
self.fake.handle_build_api(self, parts[1])
|
||||
else:
|
||||
self.send_empty(404)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
if path.startswith('/job/') and path.endswith('/buildWithParameters'):
|
||||
job = path[len('/job/'):-len('/buildWithParameters')].strip('/')
|
||||
self.fake.handle_trigger(self, job)
|
||||
else:
|
||||
self.send_empty(404)
|
||||
|
||||
|
||||
class FakeJenkins:
|
||||
"""An in-process HTTP server emulating the Jenkins endpoints the task uses.
|
||||
|
||||
Use as a context manager; `base_url` is populated on entry. Behaviour is
|
||||
tuned via the public attributes below before (or during) a run.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
*,
|
||||
username: str = 'alice',
|
||||
token: str = 'secret-token'):
|
||||
self.username = username
|
||||
self.token = token
|
||||
|
||||
# The crumb to serve as `(field, value)`, or None to 404 the issuer
|
||||
# (which API-token auth treats as "no crumb needed").
|
||||
self.crumb: tuple[str, str] | None = ('Jenkins-Crumb', 'deadbeef')
|
||||
|
||||
# Per-job HTTP status override for the trigger POST (job -> status).
|
||||
# Anything >= 400 makes that pipeline's trigger fail.
|
||||
self.trigger_status: dict[str, int] = {}
|
||||
|
||||
# Per-job configured `display-name` (job -> display name). A job absent
|
||||
# here reports its own name as `displayName`, i.e. no display name set.
|
||||
self.display_names: dict[str, str] = {}
|
||||
|
||||
# Lifecycle knobs, applied uniformly to every pipeline.
|
||||
self.queue_polls_before_start = 0
|
||||
self.running_polls_before_done = 1
|
||||
self.final_status = 'SUCCESS'
|
||||
self.serve_wfapi = True
|
||||
|
||||
# Recorded trigger POSTs, one dict per call (job, params, auth, crumb).
|
||||
self.triggers: list[dict] = []
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._next_qid = 1
|
||||
self._qid_to_job: dict[str, str] = {}
|
||||
self._queue_polls: dict[str, int] = defaultdict(int)
|
||||
self._describe_polls: dict[str, int] = defaultdict(int)
|
||||
self._build_polls: dict[str, int] = defaultdict(int)
|
||||
|
||||
# Server state, populated on `__enter__` once the server is bound.
|
||||
self._httpd: ThreadingHTTPServer | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self.base_url = ''
|
||||
|
||||
def __enter__(self) -> FakeJenkins:
|
||||
self._httpd = ThreadingHTTPServer(('127.0.0.1', 0), _Handler)
|
||||
self._httpd.fake = self
|
||||
self._thread = threading.Thread(target=self._httpd.serve_forever,
|
||||
daemon=True)
|
||||
self._thread.start()
|
||||
host, port = self._httpd.server_address
|
||||
self.base_url = f'http://{host}:{port}'
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> bool:
|
||||
self._httpd.shutdown()
|
||||
self._httpd.server_close()
|
||||
self._thread.join()
|
||||
return False
|
||||
|
||||
def write_config(self, path: Path) -> None:
|
||||
"""Writes a `~/.jenkins.json` pointing at this server."""
|
||||
path.write_text(json.dumps({
|
||||
'url': self.base_url,
|
||||
'username': self.username,
|
||||
'token': self.token,
|
||||
}),
|
||||
encoding='utf-8',
|
||||
newline='')
|
||||
|
||||
@staticmethod
|
||||
def _decode_auth(handler: _Handler) -> tuple[str, str] | None:
|
||||
header = handler.headers.get('Authorization', '')
|
||||
if not header.startswith('Basic '):
|
||||
return None
|
||||
raw = base64.b64decode(header[len('Basic '):]).decode('utf-8')
|
||||
user, _, token = raw.partition(':')
|
||||
return user, token
|
||||
|
||||
def handle_crumb(self, handler: _Handler) -> None:
|
||||
if self.crumb is None:
|
||||
handler.send_empty(404)
|
||||
return
|
||||
field, value = self.crumb
|
||||
handler.send_json({'crumbRequestField': field, 'crumb': value})
|
||||
|
||||
def handle_trigger(self, handler: _Handler, job: str) -> None:
|
||||
crumb_field = self.crumb[0] if self.crumb else None
|
||||
self.triggers.append({
|
||||
'job': job,
|
||||
'params': parse_qs(urlparse(handler.path).query),
|
||||
'auth': self._decode_auth(handler),
|
||||
'crumb_header': handler.headers.get(crumb_field)
|
||||
if crumb_field else None,
|
||||
})
|
||||
|
||||
status = self.trigger_status.get(job, 201)
|
||||
if status >= 400:
|
||||
handler.send_empty(status)
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
qid = str(self._next_qid)
|
||||
self._next_qid += 1
|
||||
self._qid_to_job[qid] = job
|
||||
handler.send_empty(201,
|
||||
{'Location': f'{self.base_url}/queue/item/{qid}/'})
|
||||
|
||||
def handle_job_info(self, handler: _Handler, job: str) -> None:
|
||||
# Jenkins reports the job name as `displayName` when none is set.
|
||||
handler.send_json({
|
||||
'name': job,
|
||||
'displayName': self.display_names.get(job, job),
|
||||
})
|
||||
|
||||
def handle_queue(self, handler: _Handler, qid: str) -> None:
|
||||
job = self._qid_to_job.get(qid)
|
||||
if job is None:
|
||||
handler.send_empty(404)
|
||||
return
|
||||
with self._lock:
|
||||
polls = self._queue_polls[qid]
|
||||
self._queue_polls[qid] += 1
|
||||
if polls < self.queue_polls_before_start:
|
||||
handler.send_json({
|
||||
'cancelled': False,
|
||||
'executable': None,
|
||||
'why': 'Waiting for next available executor',
|
||||
})
|
||||
return
|
||||
build_url = f'{self.base_url}/job/{job}/100/'
|
||||
handler.send_json({
|
||||
'cancelled': False,
|
||||
'executable': {
|
||||
'url': build_url
|
||||
}
|
||||
})
|
||||
|
||||
def handle_describe(self, handler: _Handler, job: str) -> None:
|
||||
if not self.serve_wfapi:
|
||||
# Emulate a controller without the Stage View plugin; the task
|
||||
# falls back to the plain build API.
|
||||
handler.send_empty(404)
|
||||
return
|
||||
with self._lock:
|
||||
polls = self._describe_polls[job]
|
||||
self._describe_polls[job] += 1
|
||||
if polls < self.running_polls_before_done:
|
||||
handler.send_json({
|
||||
'status': 'IN_PROGRESS',
|
||||
'durationMillis': 1000 * (polls + 1),
|
||||
'stages': [
|
||||
{
|
||||
'name': 'env',
|
||||
'status': 'SUCCESS'
|
||||
},
|
||||
{
|
||||
'name': 'build',
|
||||
'status': 'IN_PROGRESS'
|
||||
},
|
||||
],
|
||||
})
|
||||
return
|
||||
wfapi_status = _FINAL_TO_WFAPI.get(self.final_status, 'SUCCESS')
|
||||
handler.send_json({
|
||||
'status': wfapi_status,
|
||||
'durationMillis': 99000,
|
||||
'stages': [{
|
||||
'name': 'build',
|
||||
'status': wfapi_status
|
||||
}],
|
||||
})
|
||||
|
||||
def handle_build_api(self, handler: _Handler, job: str) -> None:
|
||||
with self._lock:
|
||||
polls = self._build_polls[job]
|
||||
self._build_polls[job] += 1
|
||||
if polls < self.running_polls_before_done:
|
||||
handler.send_json({
|
||||
'building': True,
|
||||
'duration': 0,
|
||||
'result': None
|
||||
})
|
||||
return
|
||||
handler.send_json({
|
||||
'building': False,
|
||||
'duration': 50000,
|
||||
'result': self.final_status,
|
||||
})
|
||||
|
||||
|
||||
class GenRustToolchainIntegrationTest(unittest.TestCase):
|
||||
"""Drives `GenRustToolchain.execute` against `FakeJenkins`."""
|
||||
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.config_path = Path(tmp.name) / '.jenkins.json'
|
||||
patcher = patch.object(brockit, 'JENKINS_CONFIG_FILE',
|
||||
self.config_path)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def _execute(self, fake: FakeJenkins, *, watch: bool = False) -> list:
|
||||
"""Runs `execute` against `fake`, returning the watched-job list.
|
||||
|
||||
The job list is captured by intercepting `_render_table`; because the
|
||||
`_WatchedJob` instances are mutated in place, the captured reference
|
||||
reflects their final state once `execute` returns.
|
||||
"""
|
||||
fake.write_config(self.config_path)
|
||||
captured: dict[str, list] = {}
|
||||
|
||||
def _record(_self, _version, _base_url, jobs):
|
||||
captured['jobs'] = jobs
|
||||
|
||||
if watch:
|
||||
with patch.object(brockit.GenRustToolchain, '_render_table',
|
||||
_record), \
|
||||
patch('brockit.time.sleep'), \
|
||||
patch('brockit.Live', _NullLive):
|
||||
brockit.GenRustToolchain().execute(tag=TAG, watch=True)
|
||||
else:
|
||||
brockit.GenRustToolchain().execute(tag=TAG)
|
||||
return captured.get('jobs', [])
|
||||
|
||||
def test_triggers_every_pipeline_with_auth_param_and_crumb(self):
|
||||
with FakeJenkins() as fake:
|
||||
self._execute(fake)
|
||||
|
||||
self.assertEqual({trigger['job']
|
||||
for trigger in fake.triggers},
|
||||
set(brockit.RUST_TOOLCHAIN_JOBS))
|
||||
self.assertEqual(len(fake.triggers), len(brockit.RUST_TOOLCHAIN_JOBS))
|
||||
for trigger in fake.triggers:
|
||||
self.assertEqual(trigger['params'], {'CHROMIUM_TAG': [TAG]})
|
||||
self.assertEqual(trigger['auth'], ('alice', 'secret-token'))
|
||||
self.assertEqual(trigger['crumb_header'], 'deadbeef')
|
||||
|
||||
def test_missing_crumb_issuer_still_triggers_without_header(self):
|
||||
with FakeJenkins() as fake:
|
||||
fake.crumb = None
|
||||
self._execute(fake)
|
||||
|
||||
self.assertEqual(len(fake.triggers), len(brockit.RUST_TOOLCHAIN_JOBS))
|
||||
for trigger in fake.triggers:
|
||||
self.assertIsNone(trigger['crumb_header'])
|
||||
|
||||
def test_trigger_failure_raises_bad_outcome(self):
|
||||
with FakeJenkins() as fake:
|
||||
fake.trigger_status = {brockit.RUST_TOOLCHAIN_JOBS[0]: 500}
|
||||
fake.write_config(self.config_path)
|
||||
with self.assertRaises(brockit.BadOutcomeException):
|
||||
brockit.GenRustToolchain().execute(tag=TAG)
|
||||
|
||||
def test_watch_runs_full_queue_to_success_lifecycle(self):
|
||||
with FakeJenkins() as fake:
|
||||
# Force a QUEUED -> RUNNING -> RUNNING -> SUCCESS progression so the
|
||||
# queue handoff and the running-state polling are both exercised.
|
||||
fake.queue_polls_before_start = 1
|
||||
fake.running_polls_before_done = 2
|
||||
jobs = self._execute(fake, watch=True)
|
||||
|
||||
self.assertEqual(len(jobs), len(brockit.RUST_TOOLCHAIN_JOBS))
|
||||
for job in jobs:
|
||||
self.assertEqual(job.state, 'SUCCESS')
|
||||
self.assertEqual(job.stage, '(done)')
|
||||
self.assertEqual(job.elapsed, '1m39s') # 99000 ms
|
||||
# The build URL was resolved from the queue item's executable.
|
||||
self.assertIn(f'/job/{job.job}/', job.build_url)
|
||||
|
||||
def test_watch_uses_pipeline_display_name_for_bot(self):
|
||||
with FakeJenkins() as fake:
|
||||
fake.display_names = {
|
||||
job: f'Display of {job}'
|
||||
for job in brockit.RUST_TOOLCHAIN_JOBS
|
||||
}
|
||||
jobs = self._execute(fake, watch=True)
|
||||
|
||||
for job in jobs:
|
||||
self.assertEqual(job.display_name, f'Display of {job.job}')
|
||||
self.assertEqual(job.bot, f'Display of {job.job}')
|
||||
|
||||
def test_watch_bot_falls_back_to_job_name_without_display_name(self):
|
||||
with FakeJenkins() as fake:
|
||||
# No display names configured: Jenkins echoes the job name as
|
||||
# `displayName`, which must be treated as "unset".
|
||||
jobs = self._execute(fake, watch=True)
|
||||
|
||||
for job in jobs:
|
||||
self.assertIsNone(job.display_name)
|
||||
self.assertEqual(job.bot, job.job)
|
||||
|
||||
def test_watch_falls_back_to_build_api_and_reports_failure(self):
|
||||
with FakeJenkins() as fake:
|
||||
fake.serve_wfapi = False
|
||||
fake.final_status = 'FAILURE'
|
||||
jobs = self._execute(fake, watch=True)
|
||||
|
||||
for job in jobs:
|
||||
self.assertEqual(job.state, 'FAILURE')
|
||||
self.assertEqual(job.stage, '(done)')
|
||||
self.assertEqual(job.elapsed, '50s') # 50000 ms
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Executable
+677
@@ -0,0 +1,677 @@
|
||||
#!/usr/bin/env vpython3
|
||||
# Copyright (c) 2026 The Brave Authors. All rights reserved.
|
||||
# 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 https://mozilla.org/MPL/2.0/.
|
||||
"""Tests for `brockit.GenRustToolchain`.
|
||||
|
||||
The file is split in two layers:
|
||||
|
||||
* `LoadJenkinsConfigTest` exercises the pure `~/.jenkins.json` reader,
|
||||
pointing `brockit.JENKINS_CONFIG_FILE` at a temp file. No network involved.
|
||||
|
||||
* `GenRustToolchainExecuteTest` drives `GenRustToolchain.execute` with a mocked
|
||||
`requests.Session`, asserting that every pipeline is triggered with the right
|
||||
URL, `CHROMIUM_TAG` parameter, auth, and CSRF crumb, and that a per-job
|
||||
failure surfaces as a `BadOutcomeException`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
import brockit
|
||||
|
||||
# A valid Jenkins config, written into the temp file by the execute tests.
|
||||
VALID_CONFIG = {
|
||||
'url': 'https://ci.brave.com',
|
||||
'username': 'alice',
|
||||
'token': 'secret-token',
|
||||
}
|
||||
|
||||
|
||||
class LoadJenkinsConfigTest(unittest.TestCase):
|
||||
"""Tests for `GenRustToolchain._load_jenkins_config`."""
|
||||
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.config_path = Path(tmp.name) / '.jenkins.json'
|
||||
patcher = patch.object(brockit, 'JENKINS_CONFIG_FILE',
|
||||
self.config_path)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def _write(self, data) -> None:
|
||||
self.config_path.write_text(json.dumps(data),
|
||||
encoding='utf-8',
|
||||
newline='')
|
||||
|
||||
def test_reads_all_fields_and_strips_slash(self):
|
||||
"""All three fields are returned, with any trailing slash dropped from
|
||||
the base URL so it can be concatenated with `/job/...` paths."""
|
||||
self._write({
|
||||
'url': 'https://ci.example.com/',
|
||||
'username': 'bob',
|
||||
'token': 'tok',
|
||||
})
|
||||
base_url, user, token = (
|
||||
brockit.GenRustToolchain._load_jenkins_config())
|
||||
self.assertEqual(base_url, 'https://ci.example.com')
|
||||
self.assertEqual(user, 'bob')
|
||||
self.assertEqual(token, 'tok')
|
||||
|
||||
def test_missing_file_raises(self):
|
||||
with self.assertRaises(brockit.InvalidInputException):
|
||||
brockit.GenRustToolchain._load_jenkins_config()
|
||||
|
||||
def test_missing_field_raises(self):
|
||||
self._write({'url': 'https://ci.brave.com', 'username': 'bob'})
|
||||
with self.assertRaises(brockit.InvalidInputException):
|
||||
brockit.GenRustToolchain._load_jenkins_config()
|
||||
|
||||
def test_empty_field_raises(self):
|
||||
"""A present-but-empty field is treated as missing."""
|
||||
self._write({
|
||||
'url': 'https://ci.brave.com',
|
||||
'username': '',
|
||||
'token': 'tok',
|
||||
})
|
||||
with self.assertRaises(brockit.InvalidInputException):
|
||||
brockit.GenRustToolchain._load_jenkins_config()
|
||||
|
||||
def test_invalid_json_raises(self):
|
||||
self.config_path.write_text('{not valid json',
|
||||
encoding='utf-8',
|
||||
newline='')
|
||||
with self.assertRaises(brockit.InvalidInputException):
|
||||
brockit.GenRustToolchain._load_jenkins_config()
|
||||
|
||||
|
||||
class GenRustToolchainExecuteTest(unittest.TestCase):
|
||||
"""End-to-end tests for `GenRustToolchain.execute`."""
|
||||
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.config_path = Path(tmp.name) / '.jenkins.json'
|
||||
self.config_path.write_text(json.dumps(VALID_CONFIG),
|
||||
encoding='utf-8',
|
||||
newline='')
|
||||
patcher = patch.object(brockit, 'JENKINS_CONFIG_FILE',
|
||||
self.config_path)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
@staticmethod
|
||||
def _make_session() -> MagicMock:
|
||||
"""Builds a `requests.Session` mock whose crumb GET and build POST both
|
||||
succeed."""
|
||||
session = MagicMock()
|
||||
|
||||
crumb_resp = MagicMock()
|
||||
crumb_resp.json.return_value = {
|
||||
'crumbRequestField': 'Jenkins-Crumb',
|
||||
'crumb': 'deadbeef',
|
||||
}
|
||||
crumb_resp.raise_for_status.return_value = None
|
||||
session.get.return_value = crumb_resp
|
||||
|
||||
post_resp = MagicMock()
|
||||
post_resp.headers = {'Location': 'https://ci.brave.com/queue/item/1/'}
|
||||
post_resp.raise_for_status.return_value = None
|
||||
session.post.return_value = post_resp
|
||||
|
||||
return session
|
||||
|
||||
@patch('brockit.requests.Session')
|
||||
def test_triggers_all_jobs(self, session_cls):
|
||||
session = self._make_session()
|
||||
session_cls.return_value = session
|
||||
|
||||
brockit.GenRustToolchain().execute(tag='150.0.7850.1')
|
||||
|
||||
# Basic-auth credentials come straight from the config.
|
||||
self.assertEqual(session.auth, ('alice', 'secret-token'))
|
||||
|
||||
# One POST per pipeline, each at the right buildWithParameters URL.
|
||||
self.assertEqual(session.post.call_count,
|
||||
len(brockit.RUST_TOOLCHAIN_JOBS))
|
||||
posted_urls = {call.args[0] for call in session.post.call_args_list}
|
||||
self.assertEqual(
|
||||
posted_urls, {
|
||||
f'https://ci.brave.com/job/{job}/buildWithParameters'
|
||||
for job in brockit.RUST_TOOLCHAIN_JOBS
|
||||
})
|
||||
|
||||
# Every call carries the resolved CHROMIUM_TAG and the crumb header.
|
||||
for call in session.post.call_args_list:
|
||||
self.assertEqual(call.kwargs['params'],
|
||||
{'CHROMIUM_TAG': '150.0.7850.1'})
|
||||
self.assertEqual(call.kwargs['headers'],
|
||||
{'Jenkins-Crumb': 'deadbeef'})
|
||||
|
||||
@patch('brockit.requests.Session')
|
||||
@patch('brockit._fetch_chromium_tag')
|
||||
def test_resolves_label_via_fetch_chromium_tag(self, fetch, session_cls):
|
||||
"""A label like `@latest-canary` is resolved through
|
||||
`_fetch_chromium_tag`, and the resolved version is what gets sent as
|
||||
`CHROMIUM_TAG`."""
|
||||
fetch.return_value = brockit.Version('151.0.1.2')
|
||||
session = self._make_session()
|
||||
session_cls.return_value = session
|
||||
|
||||
brockit.GenRustToolchain().execute(tag='@latest-canary')
|
||||
|
||||
fetch.assert_called_once_with('@latest-canary')
|
||||
for call in session.post.call_args_list:
|
||||
self.assertEqual(call.kwargs['params'],
|
||||
{'CHROMIUM_TAG': '151.0.1.2'})
|
||||
|
||||
@patch('brockit.requests.Session')
|
||||
def test_missing_crumb_issuer_proceeds_without_header(self, session_cls):
|
||||
"""When the crumb issuer is unavailable, the builds are still triggered
|
||||
with no crumb header (API-token auth is crumb-exempt)."""
|
||||
session = self._make_session()
|
||||
session.get.side_effect = brockit.requests.RequestException('no crumb')
|
||||
session_cls.return_value = session
|
||||
|
||||
brockit.GenRustToolchain().execute(tag='150.0.7850.1')
|
||||
|
||||
self.assertEqual(session.post.call_count,
|
||||
len(brockit.RUST_TOOLCHAIN_JOBS))
|
||||
for call in session.post.call_args_list:
|
||||
self.assertEqual(call.kwargs['headers'], {})
|
||||
|
||||
@patch('brockit.requests.Session')
|
||||
def test_job_failure_raises_bad_outcome(self, session_cls):
|
||||
"""A non-2xx response from any pipeline trigger surfaces as a
|
||||
`BadOutcomeException`."""
|
||||
session = self._make_session()
|
||||
failing = MagicMock()
|
||||
failing.raise_for_status.side_effect = brockit.requests.HTTPError(
|
||||
'403 Forbidden')
|
||||
session.post.return_value = failing
|
||||
session_cls.return_value = session
|
||||
|
||||
with self.assertRaises(brockit.BadOutcomeException):
|
||||
brockit.GenRustToolchain().execute(tag='150.0.7850.1')
|
||||
|
||||
@patch('brockit.requests.Session')
|
||||
def test_missing_config_raises_before_any_request(self, session_cls):
|
||||
"""With no config file present, the task fails before issuing any
|
||||
Jenkins request."""
|
||||
self.config_path.unlink()
|
||||
session = self._make_session()
|
||||
session_cls.return_value = session
|
||||
|
||||
with self.assertRaises(brockit.InvalidInputException):
|
||||
brockit.GenRustToolchain().execute(tag='150.0.7850.1')
|
||||
|
||||
session.post.assert_not_called()
|
||||
|
||||
|
||||
def _json_response(payload):
|
||||
"""A response mock whose `.json()` yields `payload` and which is 2xx."""
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = payload
|
||||
resp.raise_for_status.return_value = None
|
||||
return resp
|
||||
|
||||
|
||||
def _dispatching_session(routes):
|
||||
"""A session mock whose `.get` dispatches by first matching URL substring.
|
||||
|
||||
`routes` is an ordered list of `(substring, payload)` pairs; a payload that
|
||||
is an `Exception` is raised (to simulate a failed/absent endpoint),
|
||||
otherwise it is returned as a JSON response.
|
||||
"""
|
||||
session = MagicMock()
|
||||
|
||||
def _get(url, timeout=None): # pylint: disable=unused-argument
|
||||
for needle, payload in routes:
|
||||
if needle in url:
|
||||
if isinstance(payload, Exception):
|
||||
raise payload
|
||||
return _json_response(payload)
|
||||
raise brockit.requests.RequestException(f'unexpected GET {url}')
|
||||
|
||||
session.get.side_effect = _get
|
||||
return session
|
||||
|
||||
|
||||
class RunWatchingTest(unittest.TestCase):
|
||||
"""`--watch` must bypass `Task.run`'s status spinner (only one live
|
||||
display at a time) and drive `execute` with `watch=True`."""
|
||||
|
||||
def test_drives_execute_with_watch_without_spinner(self):
|
||||
task = brockit.GenRustToolchain()
|
||||
with patch.object(task, 'execute') as execute, \
|
||||
patch('brockit.terminal.with_status') as with_status:
|
||||
task.run_watching(tag='150.0.7850.1')
|
||||
|
||||
execute.assert_called_once_with(tag='150.0.7850.1', watch=True)
|
||||
with_status.assert_not_called()
|
||||
|
||||
|
||||
class WatchedJobTest(unittest.TestCase):
|
||||
"""Tests for the `_WatchedJob` helpers."""
|
||||
|
||||
def _job(self, **kwargs):
|
||||
defaults = {
|
||||
'job': 'brave-browser-rust-toolchain-aux-build-linux-x64',
|
||||
'queue_url': 'https://ci.brave.com/queue/item/1/',
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return brockit._WatchedJob(**defaults)
|
||||
|
||||
def test_bot_is_pipeline_name(self):
|
||||
self.assertEqual(self._job().bot,
|
||||
'brave-browser-rust-toolchain-aux-build-linux-x64')
|
||||
|
||||
def test_bot_prefers_display_name(self):
|
||||
self.assertEqual(self._job(display_name='Linux x64').bot, 'Linux x64')
|
||||
|
||||
def test_is_terminal(self):
|
||||
self.assertTrue(self._job(state='SUCCESS').is_terminal)
|
||||
self.assertTrue(self._job(state='FAILURE').is_terminal)
|
||||
self.assertFalse(self._job(state='RUNNING').is_terminal)
|
||||
self.assertFalse(self._job(state='QUEUED').is_terminal)
|
||||
|
||||
def test_link_prefers_build_url(self):
|
||||
job = self._job(build_url='https://ci.brave.com/job/x/5/')
|
||||
self.assertEqual(job.link('https://ci.brave.com'),
|
||||
'https://ci.brave.com/job/x/5/')
|
||||
|
||||
def test_link_falls_back_to_job_page(self):
|
||||
self.assertEqual(
|
||||
self._job().link('https://ci.brave.com'),
|
||||
'https://ci.brave.com/job/'
|
||||
'brave-browser-rust-toolchain-aux-build-linux-x64/')
|
||||
|
||||
|
||||
class FormatDurationTest(unittest.TestCase):
|
||||
"""Tests for `GenRustToolchain._format_duration`."""
|
||||
|
||||
def test_empty_for_missing(self):
|
||||
self.assertEqual(brockit.GenRustToolchain._format_duration(None), '')
|
||||
self.assertEqual(brockit.GenRustToolchain._format_duration(0), '')
|
||||
|
||||
def test_seconds_only(self):
|
||||
self.assertEqual(brockit.GenRustToolchain._format_duration(5000), '5s')
|
||||
|
||||
def test_minutes_and_seconds_zero_padded(self):
|
||||
self.assertEqual(brockit.GenRustToolchain._format_duration(62000),
|
||||
'1m02s')
|
||||
self.assertEqual(brockit.GenRustToolchain._format_duration(100000),
|
||||
'1m40s')
|
||||
|
||||
|
||||
class StateCellTest(unittest.TestCase):
|
||||
"""Tests for `GenRustToolchain._state_cell`."""
|
||||
|
||||
def test_running_is_animated_spinner(self):
|
||||
cell = brockit.GenRustToolchain._state_cell('RUNNING')
|
||||
self.assertIsInstance(cell, brockit.Spinner)
|
||||
|
||||
def test_other_states_are_static_markup(self):
|
||||
cell = brockit.GenRustToolchain._state_cell('SUCCESS')
|
||||
self.assertIsInstance(cell, str)
|
||||
self.assertIn('SUCCESS', cell)
|
||||
|
||||
|
||||
class LinkCellTest(unittest.TestCase):
|
||||
"""The Build column renders URLs as styled, clickable hyperlinks."""
|
||||
|
||||
def test_wraps_url_in_link_style(self):
|
||||
url = 'https://ci.brave.com/job/x/5/'
|
||||
cell = brockit.GenRustToolchain._link_cell(url)
|
||||
self.assertIsInstance(cell, brockit.Text)
|
||||
# The raw URL is preserved (so terminal URL detection still works)...
|
||||
self.assertEqual(cell.plain, url)
|
||||
# ...and an OSC 8 link plus the blue underline are applied.
|
||||
self.assertIn(f'link {url}', cell.style)
|
||||
self.assertIn('underline', cell.style)
|
||||
|
||||
|
||||
class DimCellTest(unittest.TestCase):
|
||||
"""`_dim_cell` mutes finished, non-successful rows except the State cell."""
|
||||
|
||||
def test_passes_through_when_not_dim(self):
|
||||
self.assertEqual(brockit.GenRustToolchain._dim_cell('build', False),
|
||||
'build')
|
||||
|
||||
def test_wraps_string_in_dim(self):
|
||||
cell = brockit.GenRustToolchain._dim_cell('build', True)
|
||||
self.assertIsInstance(cell, brockit.Text)
|
||||
self.assertEqual(cell.plain, 'build')
|
||||
self.assertIn('dim', [span.style for span in cell.spans])
|
||||
|
||||
def test_dims_existing_text_preserving_base_style(self):
|
||||
link = brockit.GenRustToolchain._link_cell('https://ci.brave.com/x/')
|
||||
cell = brockit.GenRustToolchain._dim_cell(link, True)
|
||||
# The hyperlink styling is preserved and dim is layered on top.
|
||||
self.assertIn('link https://ci.brave.com/x/', cell.style)
|
||||
self.assertIn('dim', [span.style for span in cell.spans])
|
||||
|
||||
|
||||
class RenderTableDimTest(unittest.TestCase):
|
||||
"""Finished non-successful rows render dim; successful ones do not."""
|
||||
|
||||
DIM = '\x1b[2m' # SGR code Rich emits for the `dim` style.
|
||||
|
||||
def _render(self, state):
|
||||
job = brockit._WatchedJob(
|
||||
job='brave-browser-rust-toolchain-aux-build-linux-x64',
|
||||
queue_url='',
|
||||
build_url='https://ci.brave.com/job/x/5/',
|
||||
state=state,
|
||||
stage='(done)',
|
||||
elapsed='5m00s')
|
||||
table = brockit.GenRustToolchain()._render_table(
|
||||
brockit.Version('150.0.7850.1'), 'https://ci.brave.com', [job])
|
||||
console = Console(force_terminal=True, width=200)
|
||||
with console.capture() as capture:
|
||||
console.print(table)
|
||||
return capture.get()
|
||||
|
||||
def test_failure_row_is_dimmed(self):
|
||||
self.assertIn(self.DIM, self._render('FAILURE'))
|
||||
|
||||
def test_aborted_row_is_dimmed(self):
|
||||
self.assertIn(self.DIM, self._render('ABORTED'))
|
||||
|
||||
def test_success_row_is_not_dimmed(self):
|
||||
self.assertNotIn(self.DIM, self._render('SUCCESS'))
|
||||
|
||||
|
||||
class ElapsedClockTest(unittest.TestCase):
|
||||
"""`_ElapsedClock` ticks forward from its anchor on each render."""
|
||||
|
||||
@patch('brockit.time.monotonic')
|
||||
def test_ticks_from_anchor(self, monotonic):
|
||||
clock = brockit._ElapsedClock(base_millis=60000, anchor=100.0)
|
||||
monotonic.return_value = 130.0 # 30s past the anchor
|
||||
self.assertEqual(clock.__rich__().plain, '1m30s')
|
||||
|
||||
@patch('brockit.time.monotonic')
|
||||
def test_uses_base_when_no_time_passed(self, monotonic):
|
||||
clock = brockit._ElapsedClock(base_millis=5000, anchor=100.0)
|
||||
monotonic.return_value = 100.0
|
||||
self.assertEqual(clock.__rich__().plain, '5s')
|
||||
|
||||
|
||||
class ElapsedCellTest(unittest.TestCase):
|
||||
"""The Elapsed cell is a live clock only while RUNNING with a duration."""
|
||||
|
||||
def _job(self, **kwargs):
|
||||
defaults = {'job': 'job-linux', 'queue_url': ''}
|
||||
defaults.update(kwargs)
|
||||
return brockit._WatchedJob(**defaults)
|
||||
|
||||
def test_running_with_anchor_is_clock(self):
|
||||
job = self._job(state='RUNNING',
|
||||
duration_millis=1000,
|
||||
elapsed_anchor=10.0,
|
||||
elapsed='1s')
|
||||
self.assertIsInstance(brockit.GenRustToolchain._elapsed_cell(job),
|
||||
brockit._ElapsedClock)
|
||||
|
||||
def test_running_without_duration_is_static(self):
|
||||
"""A RUNNING build whose duration hasn't resolved yet shows a dash, not
|
||||
a clock anchored to nothing."""
|
||||
self.assertEqual(
|
||||
brockit.GenRustToolchain._elapsed_cell(self._job(state='RUNNING')),
|
||||
'—')
|
||||
|
||||
def test_terminal_shows_static_server_value(self):
|
||||
job = self._job(state='SUCCESS',
|
||||
duration_millis=100000,
|
||||
elapsed_anchor=10.0,
|
||||
elapsed='1m40s')
|
||||
self.assertEqual(brockit.GenRustToolchain._elapsed_cell(job), '1m40s')
|
||||
|
||||
|
||||
class ResolveDisplayNameTest(unittest.TestCase):
|
||||
"""Tests for `GenRustToolchain._resolve_display_name`."""
|
||||
|
||||
BASE = 'https://ci.brave.com'
|
||||
|
||||
def _job(self):
|
||||
return brockit._WatchedJob(job='job-linux', queue_url='')
|
||||
|
||||
def test_sets_display_name_when_distinct_from_job_name(self):
|
||||
job = self._job()
|
||||
session = _dispatching_session([('/job/job-linux/api/json', {
|
||||
'name': 'job-linux',
|
||||
'displayName': 'Linux x64',
|
||||
})])
|
||||
|
||||
brockit.GenRustToolchain()._resolve_display_name(
|
||||
session, self.BASE, job)
|
||||
|
||||
self.assertEqual(job.display_name, 'Linux x64')
|
||||
self.assertEqual(job.bot, 'Linux x64')
|
||||
|
||||
def test_leaves_none_when_display_name_equals_job_name(self):
|
||||
"""Jenkins echoes the job name as `displayName` when none is set; that
|
||||
must not be treated as a real display name."""
|
||||
job = self._job()
|
||||
session = _dispatching_session([('api/json', {
|
||||
'name': 'job-linux',
|
||||
'displayName': 'job-linux',
|
||||
})])
|
||||
|
||||
brockit.GenRustToolchain()._resolve_display_name(
|
||||
session, self.BASE, job)
|
||||
|
||||
self.assertIsNone(job.display_name)
|
||||
self.assertEqual(job.bot, 'job-linux')
|
||||
|
||||
def test_leaves_none_on_request_failure(self):
|
||||
job = self._job()
|
||||
session = _dispatching_session([
|
||||
('api/json', brockit.requests.RequestException('boom'))
|
||||
])
|
||||
|
||||
brockit.GenRustToolchain()._resolve_display_name(
|
||||
session, self.BASE, job)
|
||||
|
||||
self.assertIsNone(job.display_name)
|
||||
|
||||
|
||||
class PollJobTest(unittest.TestCase):
|
||||
"""Drives the `_poll_job` state machine through its transitions."""
|
||||
|
||||
QUEUE_URL = 'https://ci.brave.com/queue/item/1/'
|
||||
BUILD_URL = 'https://ci.brave.com/job/x/5/'
|
||||
|
||||
def _job(self, **kwargs):
|
||||
defaults = {'job': 'job-linux', 'queue_url': self.QUEUE_URL}
|
||||
defaults.update(kwargs)
|
||||
return brockit._WatchedJob(**defaults)
|
||||
|
||||
def test_still_queued_records_reason(self):
|
||||
job = self._job()
|
||||
session = _dispatching_session([('queue/item', {
|
||||
'cancelled': False,
|
||||
'executable': None,
|
||||
'why': 'Waiting for next available executor',
|
||||
})])
|
||||
|
||||
brockit.GenRustToolchain()._poll_job(session, job)
|
||||
|
||||
self.assertEqual(job.state, 'QUEUED')
|
||||
self.assertEqual(job.stage, 'Waiting for next available executor')
|
||||
self.assertIsNone(job.build_url)
|
||||
|
||||
def test_cancelled_queue_item(self):
|
||||
job = self._job()
|
||||
session = _dispatching_session([('queue/item', {'cancelled': True})])
|
||||
|
||||
brockit.GenRustToolchain()._poll_job(session, job)
|
||||
|
||||
self.assertEqual(job.state, 'CANCELLED')
|
||||
|
||||
def test_dequeue_then_running_stage(self):
|
||||
"""Once an executor picks the job up, the build is resolved and the
|
||||
running stage is read from wfapi in the same poll."""
|
||||
job = self._job()
|
||||
session = _dispatching_session([
|
||||
('queue/item', {
|
||||
'executable': {
|
||||
'url': self.BUILD_URL
|
||||
}
|
||||
}),
|
||||
('wfapi/describe', {
|
||||
'status': 'IN_PROGRESS',
|
||||
'durationMillis': 62000,
|
||||
'stages': [
|
||||
{
|
||||
'name': 'env',
|
||||
'status': 'SUCCESS'
|
||||
},
|
||||
{
|
||||
'name': 'build',
|
||||
'status': 'IN_PROGRESS'
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
brockit.GenRustToolchain()._poll_job(session, job)
|
||||
|
||||
self.assertEqual(job.build_url, self.BUILD_URL)
|
||||
self.assertEqual(job.state, 'RUNNING')
|
||||
self.assertEqual(job.stage, 'build')
|
||||
self.assertEqual(job.elapsed, '1m02s')
|
||||
# The raw duration is also anchored so the row can tick between polls.
|
||||
self.assertEqual(job.duration_millis, 62000)
|
||||
self.assertIsNotNone(job.elapsed_anchor)
|
||||
|
||||
def test_running_to_success(self):
|
||||
job = self._job(build_url=self.BUILD_URL, state='RUNNING')
|
||||
session = _dispatching_session([('wfapi/describe', {
|
||||
'status': 'SUCCESS',
|
||||
'durationMillis': 100000,
|
||||
'stages': [{
|
||||
'name': 's3-upload',
|
||||
'status': 'SUCCESS'
|
||||
}],
|
||||
})])
|
||||
|
||||
brockit.GenRustToolchain()._poll_job(session, job)
|
||||
|
||||
self.assertEqual(job.state, 'SUCCESS')
|
||||
self.assertEqual(job.stage, '(done)')
|
||||
self.assertEqual(job.elapsed, '1m40s')
|
||||
|
||||
def test_falls_back_to_build_api_without_stage_view(self):
|
||||
"""When wfapi is unavailable, state/result come from the plain build
|
||||
API instead."""
|
||||
job = self._job(build_url=self.BUILD_URL, state='RUNNING')
|
||||
session = _dispatching_session([
|
||||
('wfapi/describe',
|
||||
brockit.requests.HTTPError('404 no stage view')),
|
||||
('api/json', {
|
||||
'building': False,
|
||||
'result': 'FAILURE',
|
||||
'duration': 50000
|
||||
}),
|
||||
])
|
||||
|
||||
brockit.GenRustToolchain()._poll_job(session, job)
|
||||
|
||||
self.assertEqual(job.state, 'FAILURE')
|
||||
self.assertEqual(job.stage, '(done)')
|
||||
self.assertEqual(job.elapsed, '50s')
|
||||
|
||||
def test_terminal_job_is_not_repolled(self):
|
||||
job = self._job(state='SUCCESS')
|
||||
session = _dispatching_session([])
|
||||
|
||||
brockit.GenRustToolchain()._poll_job(session, job)
|
||||
|
||||
session.get.assert_not_called()
|
||||
|
||||
def test_missing_queue_url_marks_unknown(self):
|
||||
job = self._job(queue_url='')
|
||||
session = _dispatching_session([])
|
||||
|
||||
brockit.GenRustToolchain()._poll_job(session, job)
|
||||
|
||||
self.assertEqual(job.state, 'UNKNOWN')
|
||||
session.get.assert_not_called()
|
||||
|
||||
|
||||
class WatchLoopTest(unittest.TestCase):
|
||||
"""Smoke test for `execute(watch=True)`: triggers all jobs, then polls
|
||||
until every pipeline reaches a terminal state."""
|
||||
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.config_path = Path(tmp.name) / '.jenkins.json'
|
||||
self.config_path.write_text(json.dumps(VALID_CONFIG),
|
||||
encoding='utf-8',
|
||||
newline='')
|
||||
patcher = patch.object(brockit, 'JENKINS_CONFIG_FILE',
|
||||
self.config_path)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
@patch('brockit.time.sleep')
|
||||
@patch('brockit.Live')
|
||||
@patch('brockit.requests.Session')
|
||||
def test_watch_polls_until_all_terminal(self, session_cls, live_cls,
|
||||
sleep):
|
||||
# Every build is already SUCCESS on the first poll, so the loop should
|
||||
# break before ever sleeping.
|
||||
def _get(url, timeout=None): # pylint: disable=unused-argument
|
||||
if 'crumbIssuer' in url:
|
||||
return _json_response({
|
||||
'crumbRequestField': 'Jenkins-Crumb',
|
||||
'crumb': 'x'
|
||||
})
|
||||
if 'queue/item' in url:
|
||||
return _json_response(
|
||||
{'executable': {
|
||||
'url': 'https://ci.brave.com/job/x/5/'
|
||||
}})
|
||||
if 'wfapi/describe' in url:
|
||||
return _json_response({
|
||||
'status': 'SUCCESS',
|
||||
'durationMillis': 1000,
|
||||
'stages': [{
|
||||
'name': 'build',
|
||||
'status': 'SUCCESS'
|
||||
}],
|
||||
})
|
||||
raise brockit.requests.RequestException(url)
|
||||
|
||||
post_resp = MagicMock()
|
||||
post_resp.headers = {'Location': 'https://ci.brave.com/queue/item/1/'}
|
||||
post_resp.raise_for_status.return_value = None
|
||||
session = MagicMock()
|
||||
session.post.return_value = post_resp
|
||||
session.get.side_effect = _get
|
||||
session_cls.return_value = session
|
||||
|
||||
brockit.GenRustToolchain().execute(tag='150.0.7850.1', watch=True)
|
||||
|
||||
# All four pipelines were triggered, the live display was entered, and
|
||||
# because every build was terminal on the first poll, we never slept.
|
||||
self.assertEqual(session.post.call_count,
|
||||
len(brockit.RUST_TOOLCHAIN_JOBS))
|
||||
live_cls.assert_called()
|
||||
sleep.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -335,3 +335,48 @@ class Terminal:
|
||||
|
||||
|
||||
terminal = Terminal()
|
||||
|
||||
|
||||
class Task:
|
||||
"""Base class for a console task that runs under a status spinner.
|
||||
|
||||
A task encapsulates a unit of work and the message shown while it runs.
|
||||
`run` drives the work inside a live status spinner (see
|
||||
`Terminal.with_status`), optionally framed by a start and an end banner.
|
||||
|
||||
Subclasses implement:
|
||||
* `execute(**kwargs)` -- the actual work; keyword arguments are
|
||||
forwarded verbatim from `run`.
|
||||
* `status_message()` -- the text shown in the spinner while running.
|
||||
|
||||
A subclass (or a whole tool) can set `start_banner` / `end_banner` to log
|
||||
a line immediately before and after the work, e.g. to frame a command-line
|
||||
run with a recognisable header and footer.
|
||||
"""
|
||||
|
||||
# Optional banner lines logged immediately before and after `execute`.
|
||||
# Leave as None to run the task without any framing output.
|
||||
start_banner: str | None = None
|
||||
end_banner: str | None = None
|
||||
|
||||
def run(self, **kwargs) -> None:
|
||||
"""Runs the task inside a status spinner, framed by the banners.
|
||||
|
||||
Keyword arguments are forwarded verbatim to the subclass's `execute`.
|
||||
"""
|
||||
if self.start_banner is not None:
|
||||
console.log(self.start_banner)
|
||||
with terminal.with_status(self.status_message()):
|
||||
# `execute` is provided by subclasses; the base class deliberately
|
||||
# does not define it.
|
||||
# pylint: disable=no-member
|
||||
self.execute(**kwargs)
|
||||
if self.end_banner is not None:
|
||||
console.log(self.end_banner)
|
||||
|
||||
def status_message(self) -> str:
|
||||
"""Returns the message shown in the status spinner while running.
|
||||
|
||||
Must be implemented by the derived class.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
Reference in New Issue
Block a user