|
2 | 2 |
|
3 | 3 | import json |
4 | 4 | import logging |
| 5 | +import re |
5 | 6 | from datetime import datetime |
6 | 7 | from pathlib import Path |
7 | 8 | from typing import Optional |
|
13 | 14 | logger = logging.getLogger(__name__) |
14 | 15 |
|
15 | 16 |
|
| 17 | +def parse_boot_list_table(output: str) -> list[dict]: |
| 18 | + """ |
| 19 | + Parse journalctl --list-boots table format output. |
| 20 | +
|
| 21 | + Format: <index> <boot_id> <start_time>—<end_time> |
| 22 | + Example: -12 8d18dbbee48a4a62868e139603ab7fd3 Fri 2025-09-05 13:17:01 BST—Fri 2025-09-05 13:44:34 BST |
| 23 | + """ |
| 24 | + boots = [] |
| 25 | + for line in output.strip().split("\n"): |
| 26 | + line = line.strip() |
| 27 | + if not line: |
| 28 | + continue |
| 29 | + |
| 30 | + # Match: <index> <boot_id> <rest of line> |
| 31 | + match = re.match(r"^\s*(-?\d+)\s+([0-9a-f]+)\s+(.+)$", line) |
| 32 | + if not match: |
| 33 | + continue |
| 34 | + |
| 35 | + index = int(match.group(1)) |
| 36 | + boot_id = match.group(2) |
| 37 | + time_range = match.group(3) |
| 38 | + |
| 39 | + # Extract start time from the time range (before the em dash or en dash) |
| 40 | + # The separator is — (em dash U+2014) or – (en dash U+2013), NOT regular hyphen |
| 41 | + time_parts = re.split(r"[—–]", time_range, maxsplit=1) |
| 42 | + if time_parts: |
| 43 | + start_time_str = time_parts[0].strip() |
| 44 | + # Try to parse the timestamp - multiple formats possible |
| 45 | + # Format: "Fri 2025-09-05 13:17:01 BST" |
| 46 | + try: |
| 47 | + # Remove timezone abbreviation for simpler parsing |
| 48 | + time_no_tz = re.sub(r"\s+[A-Z]{2,4}$", "", start_time_str) |
| 49 | + # Parse without the weekday - split on first space to remove "Fri" |
| 50 | + date_time_part = time_no_tz.split(" ", 1)[1] |
| 51 | + dt = datetime.strptime(date_time_part, "%Y-%m-%d %H:%M:%S") |
| 52 | + first_entry = int(dt.timestamp() * 1000000) # Convert to microseconds |
| 53 | + except (ValueError, IndexError) as e: |
| 54 | + logger.warning(f"Could not parse timestamp '{start_time_str}': {e}") |
| 55 | + first_entry = 0 |
| 56 | + else: |
| 57 | + first_entry = 0 |
| 58 | + |
| 59 | + boots.append({"index": index, "boot_id": boot_id, "first_entry": first_entry}) |
| 60 | + |
| 61 | + return boots |
| 62 | + |
| 63 | + |
16 | 64 | def get_boot_info(boot_index: int) -> Optional[dict]: |
17 | 65 | try: |
18 | 66 | result = run_command("journalctl --list-boots --output=json") |
19 | 67 | if result.returncode != 0: |
20 | 68 | logger.error(f"Failed to get boot list: {result.stderr}") |
21 | 69 | return None |
22 | | - boots = json.loads(result.stdout) |
| 70 | + |
| 71 | + # Try to parse as JSON first |
| 72 | + try: |
| 73 | + boots = json.loads(result.stdout) |
| 74 | + except json.JSONDecodeError as e: |
| 75 | + # Fallback: journalctl has a bug where --output=json is ignored for --list-boots |
| 76 | + logger.warning(f"JSON parsing failed ({e}), falling back to table format parser") |
| 77 | + boots = parse_boot_list_table(result.stdout) |
| 78 | + |
23 | 79 | boots_dict = {boot["index"]: boot for boot in boots} |
24 | 80 | if boot_index not in boots_dict: |
25 | 81 | logger.error(f"Boot index {boot_index} not found in boot list") |
|
0 commit comments