openqa-log-local 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openqa_log_local-0.0.1/PKG-INFO +121 -0
- openqa_log_local-0.0.1/README.md +110 -0
- openqa_log_local-0.0.1/pyproject.toml +29 -0
- openqa_log_local-0.0.1/src/openqa_log_local/__init__.py +4 -0
- openqa_log_local-0.0.1/src/openqa_log_local/cache.py +176 -0
- openqa_log_local-0.0.1/src/openqa_log_local/cli.py +84 -0
- openqa_log_local-0.0.1/src/openqa_log_local/client.py +105 -0
- openqa_log_local-0.0.1/src/openqa_log_local/main.py +129 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: openqa-log-local
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Create a local cache of openQA job log files
|
|
5
|
+
Author: Michele Pagot
|
|
6
|
+
Author-email: Michele Pagot <michele.pagot@suse.com>
|
|
7
|
+
Requires-Dist: openqa-client>=4.3.1
|
|
8
|
+
Requires-Dist: click
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# openQA log local
|
|
13
|
+
|
|
14
|
+
Library and cli to locally collect and inspect logs from openQA
|
|
15
|
+
|
|
16
|
+
File will be locally cached on disk, downloaded and read transparently.
|
|
17
|
+
|
|
18
|
+
## Dependency
|
|
19
|
+
|
|
20
|
+
This package internally depend on [openQA-python-client](https://github.com/os-autoinst/openQA-python-client): please refer to
|
|
21
|
+
documentation about openQA autentication.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install openqa_log_local
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
To install the package from the source code you can use `uv`:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv pip install -e .
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
### Library
|
|
39
|
+
|
|
40
|
+
To use the library in your Python project, you first need to import the `openQA_log_local` class:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from openqa_log_local import openQA_log_local
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then, you can create an instance of the class, providing the openQA host URL:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
oll = openQA_log_local(host='http://openqa.opensuse.org')
|
|
50
|
+
|
|
51
|
+
# Get job details
|
|
52
|
+
log_details = oll.get_details(job_id=1234)
|
|
53
|
+
|
|
54
|
+
# Get a list of log files associated to an openQA job.
|
|
55
|
+
# No download any log file yet.
|
|
56
|
+
log_list = oll.get_log_list(job_id=1234)
|
|
57
|
+
log_txt_list = oll.get_log_list(job_id=4567, name_pattern=[r'*\.txt'])
|
|
58
|
+
|
|
59
|
+
# Get content of a single log file. The file is downloaded to the cache
|
|
60
|
+
# if not already available locally.
|
|
61
|
+
# All the log file content is returned in `log_data`
|
|
62
|
+
log_data = oll.get_log_data(job_id=1234, filename=log_list[3])
|
|
63
|
+
|
|
64
|
+
# Get absolute path with filename of a single log file from the cache.
|
|
65
|
+
# The file is downloaded to the cache if not already available locally.
|
|
66
|
+
log_filename = oll.get_log_filename(job_id=1234, filename=log_list[3])
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Cache can be configured:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
oll = openQA_log_local(
|
|
73
|
+
host='http://openqa.opensuse.org',
|
|
74
|
+
cache_location='/home/user/.openqa_cache',
|
|
75
|
+
max_size=100000,
|
|
76
|
+
time_to_live=3600)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Or also forced to be ignored and refreshed
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
oll = openQA_log_local(
|
|
83
|
+
host='http://openqa.opensuse.org',
|
|
84
|
+
user_ignore_cache)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### CLI
|
|
88
|
+
|
|
89
|
+
The package also provides a command-line interface (CLI) for interacting with openQA logs.
|
|
90
|
+
|
|
91
|
+
#### Get Job Details
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
openqa-log-local get-details --host http://openqa.opensuse.org --job-id 1234
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Run via `uv` if you have used `uv` to install it
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
uv run openqa-log-local get-details --host http://openqa.opensuse.org --job-id 1234
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
#### Get Log List
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
openqa-log-local get-log-list --host http://openqa.opensuse.org --job-id 1234
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
#### Get Log Data
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
openqa-log-local get-log-data --host http://openqa.opensuse.org --job-id 1234 --filename autoinst-log.txt
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
#### Get Log Filename
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
openqa-log-local get-log-filename --host http://openqa.opensuse.org --job-id 1234 --filename autoinst-log.txt
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# openQA log local
|
|
2
|
+
|
|
3
|
+
Library and cli to locally collect and inspect logs from openQA
|
|
4
|
+
|
|
5
|
+
File will be locally cached on disk, downloaded and read transparently.
|
|
6
|
+
|
|
7
|
+
## Dependency
|
|
8
|
+
|
|
9
|
+
This package internally depend on [openQA-python-client](https://github.com/os-autoinst/openQA-python-client): please refer to
|
|
10
|
+
documentation about openQA autentication.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install openqa_log_local
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
To install the package from the source code you can use `uv`:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv pip install -e .
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
### Library
|
|
28
|
+
|
|
29
|
+
To use the library in your Python project, you first need to import the `openQA_log_local` class:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from openqa_log_local import openQA_log_local
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then, you can create an instance of the class, providing the openQA host URL:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
oll = openQA_log_local(host='http://openqa.opensuse.org')
|
|
39
|
+
|
|
40
|
+
# Get job details
|
|
41
|
+
log_details = oll.get_details(job_id=1234)
|
|
42
|
+
|
|
43
|
+
# Get a list of log files associated to an openQA job.
|
|
44
|
+
# No download any log file yet.
|
|
45
|
+
log_list = oll.get_log_list(job_id=1234)
|
|
46
|
+
log_txt_list = oll.get_log_list(job_id=4567, name_pattern=[r'*\.txt'])
|
|
47
|
+
|
|
48
|
+
# Get content of a single log file. The file is downloaded to the cache
|
|
49
|
+
# if not already available locally.
|
|
50
|
+
# All the log file content is returned in `log_data`
|
|
51
|
+
log_data = oll.get_log_data(job_id=1234, filename=log_list[3])
|
|
52
|
+
|
|
53
|
+
# Get absolute path with filename of a single log file from the cache.
|
|
54
|
+
# The file is downloaded to the cache if not already available locally.
|
|
55
|
+
log_filename = oll.get_log_filename(job_id=1234, filename=log_list[3])
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Cache can be configured:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
oll = openQA_log_local(
|
|
62
|
+
host='http://openqa.opensuse.org',
|
|
63
|
+
cache_location='/home/user/.openqa_cache',
|
|
64
|
+
max_size=100000,
|
|
65
|
+
time_to_live=3600)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or also forced to be ignored and refreshed
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
oll = openQA_log_local(
|
|
72
|
+
host='http://openqa.opensuse.org',
|
|
73
|
+
user_ignore_cache)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### CLI
|
|
77
|
+
|
|
78
|
+
The package also provides a command-line interface (CLI) for interacting with openQA logs.
|
|
79
|
+
|
|
80
|
+
#### Get Job Details
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
openqa-log-local get-details --host http://openqa.opensuse.org --job-id 1234
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Run via `uv` if you have used `uv` to install it
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
uv run openqa-log-local get-details --host http://openqa.opensuse.org --job-id 1234
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### Get Log List
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
openqa-log-local get-log-list --host http://openqa.opensuse.org --job-id 1234
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
#### Get Log Data
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
openqa-log-local get-log-data --host http://openqa.opensuse.org --job-id 1234 --filename autoinst-log.txt
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
#### Get Log Filename
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
openqa-log-local get-log-filename --host http://openqa.opensuse.org --job-id 1234 --filename autoinst-log.txt
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "openqa-log-local"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Create a local cache of openQA job log files"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Michele Pagot", email = "michele.pagot@suse.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"openqa-client>=4.3.1",
|
|
12
|
+
"click",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
openqa-log-local = "openqa_log_local.cli:cli"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.9.11,<0.10.0"]
|
|
20
|
+
build-backend = "uv_build"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"black>=25.11.0",
|
|
25
|
+
"mypy>=1.19.0",
|
|
26
|
+
"pytest>=8.4.2",
|
|
27
|
+
"ruff>=0.14.7",
|
|
28
|
+
"types-requests>=2.32.4.20250913",
|
|
29
|
+
]
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class openQACache:
|
|
8
|
+
"""Handles the file-based caching mechanism for openQA job data and logs.
|
|
9
|
+
|
|
10
|
+
This module provides the `openQACache` class, which is responsible
|
|
11
|
+
for storing and retrieving openQA job details and log files to and from
|
|
12
|
+
the backend local filesystem (usually your laptop).
|
|
13
|
+
The primary goal is to speed up analysis by avoiding repeated downloads
|
|
14
|
+
of the same data from the openQA server.
|
|
15
|
+
|
|
16
|
+
Architecture and Design
|
|
17
|
+
-----------------------
|
|
18
|
+
|
|
19
|
+
- **Directory Structure:** The cache is organized in a hierarchical structure.
|
|
20
|
+
A main cache directory (configurable by `cache_dir` in `config.yaml`)
|
|
21
|
+
contains subdirectories for each openQA server hostname.
|
|
22
|
+
Inside each hostname directory, cached data for a specific job is stored
|
|
23
|
+
in a JSON file named after the job ID (e.g., `.cache/openqa.suse.de/12345.json`).
|
|
24
|
+
|
|
25
|
+
- **Data Format:** Each cache file is a JSON object containing two main keys:
|
|
26
|
+
- `job_details`: A dictionary holding the complete JSON response for a job's
|
|
27
|
+
details from the openQA API.
|
|
28
|
+
- `log_files`: a list of log files downloaded from openQA and stored as
|
|
29
|
+
separated files assiciated to this job_id. Log files are stored in a folder
|
|
30
|
+
named with the value of the job_id, log filename is the one in this list.
|
|
31
|
+
- [DEPRECATED] `log_content`: A string containing the full content of the
|
|
32
|
+
`autoinst-log.txt` for that job.
|
|
33
|
+
|
|
34
|
+
- **Data Flow:** the API provided by this class are only responsible to manage
|
|
35
|
+
openQA job details metadata and log file path.
|
|
36
|
+
There is no API to write or read any log content.
|
|
37
|
+
|
|
38
|
+
Workflow
|
|
39
|
+
--------
|
|
40
|
+
The caching logic is integrated into the main application flow in `app/main.py`:
|
|
41
|
+
|
|
42
|
+
1. **Job Discovery (`discover_jobs`):** When discovering related jobs, the
|
|
43
|
+
application first checks if a cache file exists for a given job ID using
|
|
44
|
+
`cache.is_details_cached()`. If it does, `cache.get_job_details()` is called
|
|
45
|
+
to retrieve the `job_details`, and the API call to the openQA server is skipped.
|
|
46
|
+
|
|
47
|
+
2. **Log Processing (`process_job_logs`):** Before attempting to download a
|
|
48
|
+
log file, the application calls `cache.get_cached_log_filepath('filename.whatever')`.
|
|
49
|
+
If the log is found in the cache, the download is skipped.
|
|
50
|
+
|
|
51
|
+
3. **Cache Writing (`_get_log_from_api`):** A cache file is written only after
|
|
52
|
+
job data and its corresponding log file have been successfully downloaded
|
|
53
|
+
from the openQA API. The `cache.write_data()` method is called to save
|
|
54
|
+
both the `job_details` and `log_content` into a single JSON file.
|
|
55
|
+
|
|
56
|
+
Configuration and Invalidation
|
|
57
|
+
------------------------------
|
|
58
|
+
- The cache directory and maximum size are configured in the `config.yaml` file.
|
|
59
|
+
- As this project only consider and care about completed jobs,
|
|
60
|
+
the cache never become invalid or obsolete due to changes in the openQA side.
|
|
61
|
+
Job details or log files are not supposed to change in the openQA server
|
|
62
|
+
for a completed jobs.
|
|
63
|
+
- The cache is persistent and does not have an automatic expiration or TTL
|
|
64
|
+
(Time To Live) mechanism. It can be manually cleared by deleting the cache
|
|
65
|
+
directory.
|
|
66
|
+
- The application frontend provides an `ignore_cache` option in the `/analyze`
|
|
67
|
+
endpoint to bypass the cache and force a fresh download of all data.
|
|
68
|
+
A user_ignore_cache is available in the class constructor. It allows to
|
|
69
|
+
annotate that the cache is there but user ask to ignore data from it.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
cache_path: str,
|
|
75
|
+
hostname: str,
|
|
76
|
+
max_size: int,
|
|
77
|
+
time_to_live: int,
|
|
78
|
+
logger: logging.Logger,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Initializes the cache handler.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
cache_path (str): The root directory for the cache.
|
|
84
|
+
hostname (str): The openQA host, used to create a subdirectory in the cache.
|
|
85
|
+
max_size (int): The maximum size of the cache in bytes.
|
|
86
|
+
time_to_live (int): The time in seconds after which cached data is considered stale. -1 means
|
|
87
|
+
data never expires, 0 means data is
|
|
88
|
+
always refreshed.
|
|
89
|
+
logger (logging.Logger): The logger instance to use.
|
|
90
|
+
"""
|
|
91
|
+
self.cache_path = cache_path
|
|
92
|
+
self.hostname = hostname
|
|
93
|
+
self.cache_host_dir = os.path.join(self.cache_path, self.hostname)
|
|
94
|
+
self.max_size = max_size
|
|
95
|
+
self.time_to_leave = time_to_live
|
|
96
|
+
self.logger = logger
|
|
97
|
+
|
|
98
|
+
os.makedirs(self.cache_path, exist_ok=True)
|
|
99
|
+
# os.makedirs(self.cache_host_dir, exist_ok=True)
|
|
100
|
+
|
|
101
|
+
def _file_path(self, job_id: str) -> str:
|
|
102
|
+
"""Constructs the full path for a job's details metadata JSON file.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
job_id (str): The ID of the job.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
str: The absolute path to the cache file for the job.
|
|
109
|
+
"""
|
|
110
|
+
return os.path.join(self.cache_host_dir, f"{job_id}.json")
|
|
111
|
+
|
|
112
|
+
def is_details_cached(self, job_id: str) -> bool:
|
|
113
|
+
"""Checks if the cache metadata file exists for a given job ID.
|
|
114
|
+
|
|
115
|
+
It also considers the time_to_live setting. If time_to_live is 0,
|
|
116
|
+
it will always return False.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
job_id (str): The ID of the job.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
bool: True if the job details are in the cache and not stale, False otherwise.
|
|
123
|
+
"""
|
|
124
|
+
if self.time_to_leave == 0:
|
|
125
|
+
return False
|
|
126
|
+
return os.path.exists(self._file_path(job_id))
|
|
127
|
+
|
|
128
|
+
def get_job_details(self, job_id: str) -> Optional[dict[str, Any]]:
|
|
129
|
+
"""Retrieves cached job details for a specific job ID.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
job_id (str): The ID of the job.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Optional[dict[str, Any]]: A dictionary containing the job details,
|
|
136
|
+
or None if not found in cache or on error.
|
|
137
|
+
"""
|
|
138
|
+
if self.time_to_leave == 0:
|
|
139
|
+
return None
|
|
140
|
+
try:
|
|
141
|
+
with open(self._file_path(job_id), "r") as f:
|
|
142
|
+
cached_data = json.load(f)
|
|
143
|
+
job_details: Optional[dict[str, Any]] = cached_data.get("job_details")
|
|
144
|
+
if job_details:
|
|
145
|
+
return job_details
|
|
146
|
+
else:
|
|
147
|
+
self.logger.info(
|
|
148
|
+
f"Missing job_details in cached_data for job {job_id}"
|
|
149
|
+
)
|
|
150
|
+
return None
|
|
151
|
+
except (IOError, json.JSONDecodeError) as e:
|
|
152
|
+
self.logger.error(f"Error reading cache for job {job_id}: {e}")
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
def write_details(
|
|
156
|
+
self, job_id: str, job_details: dict[str, Any], log_files: list[str]
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Writes job details to a cache file.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
job_id (str): The ID of the job.
|
|
162
|
+
job_details (dict[str, Any]): The dictionary of job details to cache.
|
|
163
|
+
log_files (list[str]): A list of log files associated with the job.
|
|
164
|
+
"""
|
|
165
|
+
cache_file = self._file_path(job_id)
|
|
166
|
+
data_to_cache: dict[str, Any] = {
|
|
167
|
+
"job_details": job_details,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
os.makedirs(self.cache_host_dir, exist_ok=True)
|
|
172
|
+
with open(cache_file, "w") as f:
|
|
173
|
+
json.dump(data_to_cache, f)
|
|
174
|
+
self.logger.info(f"Successfully cached metadata for job {job_id}.")
|
|
175
|
+
except (IOError, TypeError) as e:
|
|
176
|
+
self.logger.error(f"Failed to write cache for job {job_id}: {e}")
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import logging
|
|
3
|
+
from .main import openQA_log_local
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@click.group()
|
|
7
|
+
@click.option("--debug/--no-debug", default=False)
|
|
8
|
+
@click.pass_context
|
|
9
|
+
def cli(ctx, debug):
|
|
10
|
+
"""A CLI to locally collect and inspect logs from openQA.
|
|
11
|
+
|
|
12
|
+
Files will be locally cached on disk, downloaded and read transparently.
|
|
13
|
+
"""
|
|
14
|
+
ctx.ensure_object(dict)
|
|
15
|
+
ctx.obj["DEBUG"] = debug
|
|
16
|
+
if debug:
|
|
17
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
18
|
+
else:
|
|
19
|
+
logging.basicConfig(level=logging.WARNING)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@cli.command()
|
|
23
|
+
@click.option("--host", required=True, help="The openQA host URL.")
|
|
24
|
+
@click.option("--job-id", required=True, type=int, help="The job ID.")
|
|
25
|
+
@click.pass_context
|
|
26
|
+
def get_details(ctx, host, job_id):
|
|
27
|
+
"""Get job details for a specific openQA job."""
|
|
28
|
+
oll = openQA_log_local(host=host)
|
|
29
|
+
details = oll.get_details(job_id)
|
|
30
|
+
if details is None:
|
|
31
|
+
click.echo(f"Job {job_id} not found.", err=True)
|
|
32
|
+
ctx.exit(1)
|
|
33
|
+
click.echo(details)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@cli.command()
|
|
37
|
+
@click.option("--host", required=True, help="The openQA host URL.")
|
|
38
|
+
@click.option("--job-id", required=True, type=int, help="The job ID.")
|
|
39
|
+
@click.option("--name-pattern", help="A regex pattern to filter log files.")
|
|
40
|
+
@click.pass_context
|
|
41
|
+
def get_log_list(ctx, host, job_id, name_pattern):
|
|
42
|
+
"""Get a list of log files associated to an openQA job.
|
|
43
|
+
|
|
44
|
+
This command does not download any log file.
|
|
45
|
+
"""
|
|
46
|
+
oll = openQA_log_local(host=host)
|
|
47
|
+
log_list = oll.get_log_list(job_id, name_pattern)
|
|
48
|
+
for log in log_list:
|
|
49
|
+
click.echo(log)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@cli.command()
|
|
53
|
+
@click.option("--host", required=True, help="The openQA host URL.")
|
|
54
|
+
@click.option("--job-id", required=True, type=int, help="The job ID.")
|
|
55
|
+
@click.option("--filename", required=True, help="The name of the log file.")
|
|
56
|
+
@click.pass_context
|
|
57
|
+
def get_log_data(ctx, host, job_id, filename):
|
|
58
|
+
"""Get content of a single log file.
|
|
59
|
+
|
|
60
|
+
The file is downloaded to the cache if not already available locally.
|
|
61
|
+
All the log file content is returned.
|
|
62
|
+
"""
|
|
63
|
+
oll = openQA_log_local(host=host)
|
|
64
|
+
log_data = oll.get_log_data(job_id, filename)
|
|
65
|
+
click.echo(log_data)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@cli.command()
|
|
69
|
+
@click.option("--host", required=True, help="The openQA host URL.")
|
|
70
|
+
@click.option("--job-id", required=True, type=int, help="The job ID.")
|
|
71
|
+
@click.option("--filename", required=True, help="The name of the log file.")
|
|
72
|
+
@click.pass_context
|
|
73
|
+
def get_log_filename(ctx, host, job_id, filename):
|
|
74
|
+
"""Get absolute path with filename of a single log file from the cache.
|
|
75
|
+
|
|
76
|
+
The file is downloaded to the cache if not already available locally.
|
|
77
|
+
"""
|
|
78
|
+
oll = openQA_log_local(host=host)
|
|
79
|
+
log_filename = oll.get_log_filename(job_id, filename)
|
|
80
|
+
click.echo(log_filename)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
cli(obj={})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
import requests.exceptions
|
|
6
|
+
from openqa_client.client import OpenQA_Client
|
|
7
|
+
from openqa_client.exceptions import RequestError
|
|
8
|
+
|
|
9
|
+
"""Custom exception classes for the application."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class openQAClientError(Exception):
|
|
13
|
+
"""Base exception for all openQAClientWrapper errors."""
|
|
14
|
+
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class openQAClientAPIError(openQAClientError):
|
|
19
|
+
"""Raised for errors during openQA API requests."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class openQAClientConnectionError(openQAClientError):
|
|
25
|
+
"""Raised for errors during connection to openQA."""
|
|
26
|
+
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class openQAClientWrapper:
|
|
31
|
+
"""A wrapper class for the openqa_client to simplify interactions."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
hostname: str,
|
|
36
|
+
logger: logging.Logger,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Initializes the client wrapper.
|
|
39
|
+
|
|
40
|
+
It does not create an OpenQA_Client instance immediately. The client
|
|
41
|
+
is lazily initialized on first use.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
hostname (str): The openQA host URL.
|
|
45
|
+
logger (logging.Logger): The logger instance to use.
|
|
46
|
+
"""
|
|
47
|
+
self.logger = logger
|
|
48
|
+
self.hostname = hostname
|
|
49
|
+
self._client: Optional[OpenQA_Client] = None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def client(self) -> OpenQA_Client:
|
|
53
|
+
"""Lazily initializes and returns the OpenQA_Client instance.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
OpenQA_Client: The initialized openqa_client instance.
|
|
57
|
+
"""
|
|
58
|
+
if self._client is None:
|
|
59
|
+
self.logger.info("Initializing OpenQA_Client for %s", self.hostname)
|
|
60
|
+
client = OpenQA_Client(server=self.hostname)
|
|
61
|
+
client.session.verify = False
|
|
62
|
+
self.logger.warning(
|
|
63
|
+
"SSL certificate verification disabled for client connecting to %s",
|
|
64
|
+
self.hostname,
|
|
65
|
+
)
|
|
66
|
+
self._client = client
|
|
67
|
+
return self._client
|
|
68
|
+
|
|
69
|
+
def get_job_details(self, job_id: int) -> Optional[dict[str, Any]]:
|
|
70
|
+
"""Fetches the details for a specific job from the openQA API.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
job_id (int): The ID of the job.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
openQAClientAPIError: For non-404 API errors.
|
|
77
|
+
openQAClientConnectionError: For network connection errors.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Optional[dict[str, Any]]: A dictionary with job details, or None if the job is not found (404).
|
|
81
|
+
"""
|
|
82
|
+
self.logger.info(
|
|
83
|
+
"get_job_details(job_id:%s) for hostname:%s", job_id, self.hostname
|
|
84
|
+
)
|
|
85
|
+
try:
|
|
86
|
+
response = self.client.openqa_request("GET", f"jobs/{job_id}")
|
|
87
|
+
job = response.get("job")
|
|
88
|
+
if not job:
|
|
89
|
+
raise openQAClientAPIError(
|
|
90
|
+
f"Could not find 'job' key in API response for ID {job_id}."
|
|
91
|
+
)
|
|
92
|
+
return job
|
|
93
|
+
except RequestError as e:
|
|
94
|
+
if e.status_code == 404:
|
|
95
|
+
self.logger.warning("Job %s not found (404)", job_id)
|
|
96
|
+
return None
|
|
97
|
+
error_message = (
|
|
98
|
+
f"API Error for job {job_id}: Status {e.status_code} - {e.text}"
|
|
99
|
+
)
|
|
100
|
+
self.logger.error(error_message)
|
|
101
|
+
raise openQAClientAPIError(error_message) from e
|
|
102
|
+
except requests.exceptions.ConnectionError as e:
|
|
103
|
+
error_message = f"Connection to host '{self.hostname}' failed"
|
|
104
|
+
self.logger.error(error_message)
|
|
105
|
+
raise openQAClientConnectionError(error_message) from e
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
from .client import openQAClientWrapper
|
|
5
|
+
from .cache import openQACache
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class openQA_log_local:
|
|
9
|
+
"""
|
|
10
|
+
Main class for the openqa_log_local library.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
host: str,
|
|
16
|
+
cache_location: Optional[str] = ".cache",
|
|
17
|
+
max_size: Optional[int] = 1024 * 1024 * 100, # 100 MB
|
|
18
|
+
time_to_live: Optional[int] = -1,
|
|
19
|
+
logger: Optional[logging.Logger] = None,
|
|
20
|
+
):
|
|
21
|
+
"""
|
|
22
|
+
Initializes the openQA_log_local library.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
host (str): The openQA host URL.
|
|
26
|
+
cache_location (Optional[str]): The directory to store cached logs.
|
|
27
|
+
Defaults to ".cache".
|
|
28
|
+
max_size (Optional[int]): The maximum size of the cache in bytes.
|
|
29
|
+
Defaults to 100MB.
|
|
30
|
+
time_to_live (Optional[int]): The time in seconds after which cached
|
|
31
|
+
data is considered stale. -1 means
|
|
32
|
+
data never expires, 0 means data is
|
|
33
|
+
always refreshed. Defaults to -1.
|
|
34
|
+
logger (Optional[logging.Logger]): A logger instance. If None, a
|
|
35
|
+
new one is created.
|
|
36
|
+
"""
|
|
37
|
+
if logger is None:
|
|
38
|
+
self.logger = logging.getLogger(__name__)
|
|
39
|
+
else:
|
|
40
|
+
self.logger = logger
|
|
41
|
+
self.client = openQAClientWrapper(host, self.logger)
|
|
42
|
+
if cache_location is None:
|
|
43
|
+
cl = ".cache"
|
|
44
|
+
else:
|
|
45
|
+
cl = cache_location
|
|
46
|
+
if max_size is None:
|
|
47
|
+
ms = 1024 * 1024 * 100
|
|
48
|
+
else:
|
|
49
|
+
ms = max_size
|
|
50
|
+
if time_to_live is None:
|
|
51
|
+
tl = -1
|
|
52
|
+
else:
|
|
53
|
+
tl = time_to_live
|
|
54
|
+
self.cache = openQACache(
|
|
55
|
+
cl,
|
|
56
|
+
host,
|
|
57
|
+
ms,
|
|
58
|
+
tl,
|
|
59
|
+
self.logger,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def get_details(self, job_id: int) -> Optional[Dict[str, Any]]:
|
|
63
|
+
"""Get job details for a specific openQA job.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
job_id (int): The job ID.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Optional[Dict[str, Any]]: A dictionary containing job details,
|
|
70
|
+
or None if the job is not found.
|
|
71
|
+
"""
|
|
72
|
+
job_details: Optional[Dict[str, Any]] = None
|
|
73
|
+
if not self.cache.is_details_cached(str(job_id)):
|
|
74
|
+
self.logger.info(f"Cache miss for job {job_id} details.")
|
|
75
|
+
job_details = self.client.get_job_details(job_id)
|
|
76
|
+
# Assuming we don't have the log files list at this point.
|
|
77
|
+
# We will update the cache when we fetch the log files.
|
|
78
|
+
if job_details:
|
|
79
|
+
self.cache.write_details(str(job_id), job_details, [])
|
|
80
|
+
else:
|
|
81
|
+
self.logger.info(f"Cache hit for job {job_id} details.")
|
|
82
|
+
job_details = self.cache.get_job_details(str(job_id))
|
|
83
|
+
|
|
84
|
+
return job_details
|
|
85
|
+
|
|
86
|
+
def get_log_list(
|
|
87
|
+
self, job_id: int, name_pattern: Optional[str] = None
|
|
88
|
+
) -> List[str]:
|
|
89
|
+
"""Get a list of log files associated to an openQA job.
|
|
90
|
+
|
|
91
|
+
This method does not download any log files.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
job_id (int): The job ID.
|
|
95
|
+
name_pattern (Optional[str]): A regex pattern to filter log files by name.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
List[str]: A list of log file names.
|
|
99
|
+
"""
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
def get_log_data(self, job_id: int, filename: str) -> str:
|
|
103
|
+
"""Get content of a single log file.
|
|
104
|
+
|
|
105
|
+
The file is downloaded to the cache if not already available locally.
|
|
106
|
+
All the log file content is returned.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
job_id (int): The job ID.
|
|
110
|
+
filename (str): The name of the log file.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
str: The content of the log file.
|
|
114
|
+
"""
|
|
115
|
+
return ""
|
|
116
|
+
|
|
117
|
+
def get_log_filename(self, job_id: int, filename: str) -> str:
|
|
118
|
+
"""Get absolute path with filename of a single log file from the cache.
|
|
119
|
+
|
|
120
|
+
The file is downloaded to the cache if not already available locally.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
job_id (int): The job ID.
|
|
124
|
+
filename (str): The name of the log file.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
str: The absolute path to the cached log file.
|
|
128
|
+
"""
|
|
129
|
+
return ""
|