-
Notifications
You must be signed in to change notification settings - Fork 39
s3stream: follow CI logs from the CLI #9274
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| #!/usr/bin/env python3 | ||
|
|
||
| # SPDX-FileCopyrightText: 2026 Red Hat, Inc. | ||
| # SPDX-License-Identifier: GPL-3.0-or-later | ||
|
|
||
| import argparse | ||
| import contextlib | ||
| import json | ||
| import logging | ||
| import time | ||
| import urllib.error | ||
| import urllib.request | ||
| from collections.abc import Iterator | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def fetch_once(url: str, offset: int) -> str: | ||
| req = urllib.request.Request(url, headers={"Range": f"bytes={offset}-"}) | ||
| with urllib.request.urlopen(req) as resp: | ||
| data = resp.read() | ||
| if resp.status == 206: | ||
| return data.decode() | ||
| # Server doesn't support Range (e.g. python -m http.server) | ||
| if resp.status == 200: | ||
| return data[offset:].decode() | ||
| raise ValueError(f"Unexpected status {resp.status}") | ||
|
|
||
|
|
||
| def fetch(url: str, offset: int = 0) -> str: | ||
| for attempt in range(8): | ||
| try: | ||
| return fetch_once(url, offset) | ||
| except OSError as exc: | ||
| if isinstance(exc, urllib.error.HTTPError) and exc.code < 500: | ||
| raise | ||
| delay = 2 ** attempt | ||
| logger.debug("error fetching %r, retrying after %r", url, delay) | ||
| time.sleep(delay) | ||
|
|
||
| return fetch_once(url, offset) | ||
|
|
||
|
|
||
| def fetch_log(base_url: str, follow: bool) -> Iterator[str]: | ||
| bytes_received = 0 | ||
|
|
||
| while True: | ||
| try: | ||
| chunks: list[int] = json.loads(fetch(f"{base_url}.chunks")) | ||
| logger.debug("Chunks: %r, bytes: %r", chunks, bytes_received) | ||
|
|
||
| chunk_start = 0 | ||
| for chunk_size in chunks: | ||
| chunk_end = chunk_start + chunk_size | ||
| if bytes_received < chunk_end: | ||
| yield fetch( | ||
| f"{base_url}.{chunk_start}-{chunk_end}", | ||
| bytes_received - chunk_start, | ||
| ) | ||
| bytes_received = chunk_end | ||
| chunk_start = chunk_end | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This assumes that the
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I reworked this a bit, but the general logic here is: we trust that |
||
| except urllib.error.HTTPError as exc: | ||
| # S3 returns 403 instead of 404 when s3:ListBucket is not granted | ||
| if exc.code in (403, 404): | ||
| break | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this break the
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| raise | ||
|
|
||
| if not follow: | ||
| return | ||
|
|
||
| time.sleep(10) | ||
|
|
||
| # Streaming is over (or never started) — fetch the (remaining) final log | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So the
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. exactly. |
||
| yield fetch(base_url, bytes_received) | ||
|
|
||
|
|
||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description="Fetch streamed logs") | ||
| parser.add_argument("-d", "--debug", action="store_true", | ||
| help="Enable debug logging") | ||
| parser.add_argument("-f", "--follow", action="store_true", | ||
| help="Follow log output until job completes") | ||
| parser.add_argument("url", help="Base log URL (e.g. https://…/slug/log)") | ||
| args = parser.parse_args() | ||
|
|
||
| if args.debug: | ||
| logging.basicConfig(format="%(message)s", level=logging.DEBUG) | ||
|
|
||
| url = args.url | ||
| if url.endswith("/"): | ||
| url += "log" | ||
|
|
||
| with contextlib.suppress(KeyboardInterrupt): | ||
| for chunk in fetch_log(url, follow=args.follow): | ||
| print(chunk, end='', flush=True) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
here you promise UTF-8 in the entire protocol but
:)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ascii is utf8 :)