Add share-log subcommand to upload install.log to paste.rs (#4511)
* Add --share-log flag to upload install.log to paste.rs * Apply ruff-format to share_log.py * Rework share-log per review: subcommand, TUI confirmation, truncate large logs * Replace curl/SysCommand with urllib.request, remove defensive try/except Per review: use stdlib urllib instead of shelling out to curl, drop unnecessary try/except around TUI confirmation, remove tempfile (content is passed directly as bytes). * Fix TUI imports after ui module was pulled one level up (#4515) * Decouple share_install_log from TUI module * Add unit tests for share_install_log function * Update docs to use share-log subcommand syntax
This commit is contained in:
parent
7b5dddf34b
commit
ef1cfe7b56
|
|
@ -41,8 +41,8 @@ body:
|
||||||
attributes:
|
attributes:
|
||||||
value: >
|
value: >
|
||||||
**Note**: Assuming you have network connectivity,
|
**Note**: Assuming you have network connectivity,
|
||||||
you can easily post the installation log using the following command:
|
you can easily upload the installation log and get a shareable URL by running:
|
||||||
`curl -F'file=@/var/log/archinstall/install.log' https://0x0.st`
|
`archinstall share-log`
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: freeform
|
id: freeform
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,9 @@ If you come across any issues, kindly submit your issue here on GitHub or post y
|
||||||
When submitting an issue, please:
|
When submitting an issue, please:
|
||||||
* Provide the stacktrace of the output if applicable
|
* Provide the stacktrace of the output if applicable
|
||||||
* Attach the `/var/log/archinstall/install.log` to the issue ticket. This helps us help you!
|
* Attach the `/var/log/archinstall/install.log` to the issue ticket. This helps us help you!
|
||||||
* To extract the log from the ISO image, one way is to use<br>
|
* To upload the log from the ISO image and get a shareable URL, run<br>
|
||||||
```shell
|
```shell
|
||||||
curl -F'file=@/var/log/archinstall/install.log' https://0x0.st
|
archinstall share-log
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -431,7 +431,6 @@ class ArchConfigHandler:
|
||||||
default=False,
|
default=False,
|
||||||
help='Enabled verbose options',
|
help='Enabled verbose options',
|
||||||
)
|
)
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
def _parse_args(self) -> Arguments:
|
def _parse_args(self) -> Arguments:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import asdict, is_dataclass
|
from dataclasses import asdict, is_dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
@ -333,3 +335,51 @@ def log(
|
||||||
|
|
||||||
if level != logging.DEBUG:
|
if level != logging.DEBUG:
|
||||||
print(text)
|
print(text)
|
||||||
|
|
||||||
|
|
||||||
|
def share_install_log(
|
||||||
|
paste_url: str = 'https://paste.rs',
|
||||||
|
max_size: int = 10 * 1024 * 1024,
|
||||||
|
confirm: Callable[[str], bool] = lambda _: True,
|
||||||
|
) -> int:
|
||||||
|
log_path = logger.path
|
||||||
|
|
||||||
|
if not log_path.exists():
|
||||||
|
info(f'Log file not found: {log_path}')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
size = log_path.stat().st_size
|
||||||
|
if size == 0:
|
||||||
|
info(f'Log file is empty: {log_path}')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if size > max_size:
|
||||||
|
info(f'Log file exceeds {max_size} bytes, uploading last {max_size} bytes')
|
||||||
|
content = log_path.read_bytes()[-max_size:]
|
||||||
|
else:
|
||||||
|
content = log_path.read_bytes()
|
||||||
|
|
||||||
|
header = f'About to upload {log_path} ({len(content)} bytes) to {paste_url}\n\n'
|
||||||
|
header += 'The log may contain hostname, mirror URLs, package list and partition layout.\n'
|
||||||
|
header += 'The uploaded paste is public.\n\n'
|
||||||
|
header += 'Continue?'
|
||||||
|
|
||||||
|
if not confirm(header):
|
||||||
|
info('Cancelled.')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(paste_url, data=content)
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
url = response.read().decode().strip()
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
info(f'Upload failed: {e}')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not url.startswith('http'):
|
||||||
|
info(f'Unexpected response from {paste_url}: {url[:200]!r}')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# raw print so the URL is pipe-friendly (no ANSI colors, no log prefix)
|
||||||
|
print(url)
|
||||||
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,16 @@ from pathlib import Path
|
||||||
from archinstall.lib.args import ArchConfigHandler
|
from archinstall.lib.args import ArchConfigHandler
|
||||||
from archinstall.lib.disk.utils import disk_layouts
|
from archinstall.lib.disk.utils import disk_layouts
|
||||||
from archinstall.lib.hardware import SysInfo
|
from archinstall.lib.hardware import SysInfo
|
||||||
|
from archinstall.lib.menu.helpers import Confirmation
|
||||||
from archinstall.lib.network.wifi_handler import WifiHandler
|
from archinstall.lib.network.wifi_handler import WifiHandler
|
||||||
from archinstall.lib.networking import ping
|
from archinstall.lib.networking import ping
|
||||||
from archinstall.lib.output import debug, error, info, warn
|
from archinstall.lib.output import debug, error, info, share_install_log, warn
|
||||||
from archinstall.lib.packages.util import check_version_upgrade
|
from archinstall.lib.packages.util import check_version_upgrade
|
||||||
from archinstall.lib.pacman.pacman import Pacman
|
from archinstall.lib.pacman.pacman import Pacman
|
||||||
from archinstall.lib.translationhandler import tr, translation_handler
|
from archinstall.lib.translationhandler import tr, translation_handler
|
||||||
from archinstall.lib.utils.util import running_from_iso
|
from archinstall.lib.utils.util import running_from_iso
|
||||||
from archinstall.tui.components import tui
|
from archinstall.tui.components import tui
|
||||||
|
from archinstall.tui.menu_item import MenuItemGroup
|
||||||
|
|
||||||
|
|
||||||
def _log_sys_info() -> None:
|
def _log_sys_info() -> None:
|
||||||
|
|
@ -73,12 +75,28 @@ def _list_scripts() -> str:
|
||||||
return '\n'.join(lines)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _tui_confirm(header: str) -> bool:
|
||||||
|
async def _ask() -> bool:
|
||||||
|
result = await Confirmation(
|
||||||
|
group=MenuItemGroup.yes_no(),
|
||||||
|
header=header,
|
||||||
|
allow_skip=False,
|
||||||
|
preset=False,
|
||||||
|
).show()
|
||||||
|
return result.get_value()
|
||||||
|
|
||||||
|
return tui.run(_ask)
|
||||||
|
|
||||||
|
|
||||||
def run() -> int:
|
def run() -> int:
|
||||||
"""
|
"""
|
||||||
This can either be run as the compiled and installed application: python setup.py install
|
This can either be run as the compiled and installed application: python setup.py install
|
||||||
OR straight as a module: python -m archinstall
|
OR straight as a module: python -m archinstall
|
||||||
In any case we will be attempting to load the provided script to be run from the scripts/ folder
|
In any case we will be attempting to load the provided script to be run from the scripts/ folder
|
||||||
"""
|
"""
|
||||||
|
if 'share-log' in sys.argv:
|
||||||
|
return share_install_log(confirm=_tui_confirm)
|
||||||
|
|
||||||
arch_config_handler = ArchConfigHandler()
|
arch_config_handler = ArchConfigHandler()
|
||||||
|
|
||||||
if '--help' in sys.argv or '-h' in sys.argv:
|
if '--help' in sys.argv or '-h' in sys.argv:
|
||||||
|
|
@ -141,8 +159,8 @@ def _error_message(exc: Exception) -> None:
|
||||||
Archinstall experienced the above error. If you think this is a bug, please report it to
|
Archinstall experienced the above error. If you think this is a bug, please report it to
|
||||||
https://github.com/archlinux/archinstall and include the log file "/var/log/archinstall/install.log".
|
https://github.com/archlinux/archinstall and include the log file "/var/log/archinstall/install.log".
|
||||||
|
|
||||||
Hint: To extract the log from a live ISO
|
Hint: To upload the log and get a shareable URL, run
|
||||||
curl -F 'file=@/var/log/archinstall/install.log' https://0x0.st
|
archinstall share-log
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
warn(text)
|
warn(text)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ When submitting a help ticket, please include the :code:`/var/log/archinstall/in
|
||||||
It can be found both on the live ISO but also in the installed filesystem if the base packages were strapped in.
|
It can be found both on the live ISO but also in the installed filesystem if the base packages were strapped in.
|
||||||
|
|
||||||
.. tip::
|
.. tip::
|
||||||
| An easy way to submit logs is ``curl -F 'file=@/var/log/archinstall/install.log' https://0x0.st``.
|
| An easy way to submit logs is ``archinstall share-log``, which uploads ``install.log`` to paste.rs and prints a shareable URL.
|
||||||
| Use caution when submitting other log files, but ``archinstall`` pledges to keep ``install.log`` safe for posting publicly!
|
| Use caution when submitting other log files, but ``archinstall`` pledges to keep ``install.log`` safe for posting publicly!
|
||||||
|
|
||||||
There are additional log files under ``/var/log/archinstall/`` that can be useful:
|
There are additional log files under ``/var/log/archinstall/`` that can be useful:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
# pylint: disable=redefined-outer-name
|
||||||
|
import urllib.error
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from archinstall.lib.output import share_install_log
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def log_file(tmp_path: Path) -> Path:
|
||||||
|
log_dir = tmp_path / 'archinstall'
|
||||||
|
log_dir.mkdir()
|
||||||
|
return log_dir / 'install.log'
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_logger(log_file: Path) -> MagicMock:
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.path = log_file
|
||||||
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_not_found(tmp_path: Path) -> None:
|
||||||
|
missing = tmp_path / 'no-such' / 'install.log'
|
||||||
|
with patch('archinstall.lib.output.logger', _fake_logger(missing)):
|
||||||
|
assert share_install_log() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_file(log_file: Path) -> None:
|
||||||
|
log_file.write_bytes(b'')
|
||||||
|
with patch('archinstall.lib.output.logger', _fake_logger(log_file)):
|
||||||
|
assert share_install_log() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_cancels(log_file: Path) -> None:
|
||||||
|
log_file.write_text('some log content')
|
||||||
|
with patch('archinstall.lib.output.logger', _fake_logger(log_file)):
|
||||||
|
assert share_install_log(confirm=lambda _: False) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_upload(log_file: Path) -> None:
|
||||||
|
log_file.write_text('some log content')
|
||||||
|
fake_response = BytesIO(b'https://paste.rs/abc.def')
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('archinstall.lib.output.logger', _fake_logger(log_file)),
|
||||||
|
patch('urllib.request.urlopen', return_value=fake_response) as mock_open,
|
||||||
|
):
|
||||||
|
result = share_install_log()
|
||||||
|
|
||||||
|
assert result == 0
|
||||||
|
req = mock_open.call_args[0][0]
|
||||||
|
assert req.data == b'some log content'
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncation(log_file: Path) -> None:
|
||||||
|
max_size = 100
|
||||||
|
content = b'A' * 50 + b'B' * 80
|
||||||
|
log_file.write_bytes(content)
|
||||||
|
fake_response = BytesIO(b'https://paste.rs/abc.def')
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('archinstall.lib.output.logger', _fake_logger(log_file)),
|
||||||
|
patch('urllib.request.urlopen', return_value=fake_response) as mock_open,
|
||||||
|
):
|
||||||
|
result = share_install_log(max_size=max_size)
|
||||||
|
|
||||||
|
assert result == 0
|
||||||
|
req = mock_open.call_args[0][0]
|
||||||
|
assert len(req.data) == max_size
|
||||||
|
assert req.data == content[-max_size:]
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_error(log_file: Path) -> None:
|
||||||
|
log_file.write_text('some log content')
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('archinstall.lib.output.logger', _fake_logger(log_file)),
|
||||||
|
patch('urllib.request.urlopen', side_effect=urllib.error.URLError('no network')),
|
||||||
|
):
|
||||||
|
assert share_install_log() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_unexpected_response(log_file: Path) -> None:
|
||||||
|
log_file.write_text('some log content')
|
||||||
|
fake_response = BytesIO(b'ERROR: something went wrong')
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch('archinstall.lib.output.logger', _fake_logger(log_file)),
|
||||||
|
patch('urllib.request.urlopen', return_value=fake_response),
|
||||||
|
):
|
||||||
|
assert share_install_log() == 1
|
||||||
Loading…
Reference in New Issue