Skip to content

Commit fecf208

Browse files
authored
Merge pull request #16 from exkson/gsoc-2/implements-logs
Implements logs command Reviewed-by: Zack Cerza <[email protected]>
2 parents f0a92b0 + 7c8b43b commit fecf208

File tree

6 files changed

+279
-0
lines changed

6 files changed

+279
-0
lines changed

ceph_devstack/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ def parse_args(args: List[str]) -> argparse.Namespace:
104104
"container",
105105
help="The container to wait for",
106106
)
107+
parser_log = subparsers.add_parser("logs", help="Dump teuthology logs")
108+
parser_log.add_argument("-r", "--run-name", type=str, default=None)
109+
parser_log.add_argument("-j", "--job-id", type=str, default=None)
110+
parser_log.add_argument(
111+
"--locate",
112+
action=argparse.BooleanOptionalAction,
113+
help="Display log file path instead of contents",
114+
)
107115
return parser.parse_args(args)
108116

109117

ceph_devstack/cli.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ async def run():
4040
return
4141
elif args.command == "wait":
4242
return await obj.wait(container_name=args.container)
43+
elif args.command == "logs":
44+
return await obj.logs(
45+
run_name=args.run_name, job_id=args.job_id, locate=args.locate
46+
)
4347
else:
4448
await obj.apply(args.command)
4549
return 0

ceph_devstack/resources/ceph/__init__.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
LoopControlDeviceWriteable,
2424
SELinuxModule,
2525
)
26+
from ceph_devstack.resources.ceph.utils import get_most_recent_run, get_job_id
27+
from ceph_devstack.resources.ceph.exceptions import TooManyJobsFound
2628

2729

2830
class SSHKeyPair(Secret):
@@ -226,3 +228,39 @@ async def wait(self, container_name: str):
226228
return await object.wait()
227229
logger.error(f"Could not find container {container_name}")
228230
return 1
231+
232+
async def logs(
233+
self, run_name: str = None, job_id: str = None, locate: bool = False
234+
):
235+
try:
236+
log_file = self.get_log_file(run_name, job_id)
237+
except FileNotFoundError:
238+
logger.error("No log file found")
239+
except TooManyJobsFound as e:
240+
msg = "Found too many jobs ({jobs}) for target run. Please pick a job id with -j option.".format(
241+
jobs=", ".join(e.jobs)
242+
)
243+
logger.error(msg)
244+
else:
245+
if locate:
246+
print(log_file)
247+
else:
248+
buffer_size = 8 * 1024
249+
with open(log_file) as f:
250+
while chunk := f.read(buffer_size):
251+
print(chunk, end="")
252+
253+
def get_log_file(self, run_name: str = None, job_id: str = None):
254+
archive_dir = Teuthology().archive_dir.expanduser()
255+
256+
if not run_name:
257+
run_name = get_most_recent_run(os.listdir(archive_dir))
258+
run_dir = archive_dir.joinpath(run_name)
259+
260+
if not job_id:
261+
job_id = get_job_id(os.listdir(run_dir))
262+
263+
log_file = run_dir.joinpath(job_id, "teuthology.log")
264+
if not log_file.exists():
265+
raise FileNotFoundError
266+
return log_file
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class TooManyJobsFound(Exception):
2+
def __init__(self, jobs: list[str]):
3+
self.jobs = jobs
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import re
2+
from datetime import datetime
3+
4+
from ceph_devstack.resources.ceph.exceptions import TooManyJobsFound
5+
6+
RUN_DIRNAME_PATTERN = re.compile(
7+
r"^(?P<username>^[a-z_]([a-z0-9_-]{0,31}|[a-z0-9_-]{0,30}))-(?P<timestamp>\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2})"
8+
)
9+
10+
11+
def get_logtimestamp(dirname: str) -> datetime:
12+
match_ = RUN_DIRNAME_PATTERN.search(dirname)
13+
return datetime.strptime(match_.group("timestamp"), "%Y-%m-%d_%H:%M:%S")
14+
15+
16+
def get_most_recent_run(runs: list[str]) -> str:
17+
try:
18+
run_name = next(
19+
iter(
20+
sorted(
21+
(
22+
dirname
23+
for dirname in runs
24+
if RUN_DIRNAME_PATTERN.search(dirname)
25+
),
26+
key=lambda dirname: get_logtimestamp(dirname),
27+
reverse=True,
28+
)
29+
)
30+
)
31+
return run_name
32+
except StopIteration:
33+
raise FileNotFoundError
34+
35+
36+
def get_job_id(jobs: list[str]):
37+
job_dir_pattern = re.compile(r"^\d+$")
38+
dirs = [d for d in jobs if job_dir_pattern.match(d)]
39+
40+
if len(dirs) == 0:
41+
raise FileNotFoundError
42+
elif len(dirs) > 1:
43+
raise TooManyJobsFound(dirs)
44+
return dirs[0]
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import os
2+
import io
3+
import contextlib
4+
import random as rd
5+
from datetime import datetime, timedelta
6+
import secrets
7+
import string
8+
9+
import pytest
10+
11+
from ceph_devstack import config
12+
from ceph_devstack.resources.ceph.utils import (
13+
get_logtimestamp,
14+
get_most_recent_run,
15+
get_job_id,
16+
)
17+
from ceph_devstack.resources.ceph.exceptions import TooManyJobsFound
18+
from ceph_devstack.resources.ceph import CephDevStack
19+
20+
21+
class TestDevStack:
22+
def test_get_logtimestamp(self):
23+
dirname = "root-2025-03-20_18:34:43-orch:cephadm:smoke-small-main-distro-default-testnode"
24+
assert get_logtimestamp(dirname) == datetime(2025, 3, 20, 18, 34, 43)
25+
26+
def test_get_most_recent_run_returns_most_recent_run(self):
27+
runs = [
28+
"root-2024-02-07_12:23:43-orch:cephadm:smoke-small-devlop-distro-smithi-testnode",
29+
"root-2025-02-20_11:23:43-orch:cephadm:smoke-small-devlop-distro-smithi-testnode",
30+
"root-2025-03-20_18:34:43-orch:cephadm:smoke-small-main-distro-default-testnode",
31+
"root-2025-01-18_18:34:43-orch:cephadm:smoke-small-main-distro-default-testnode",
32+
]
33+
assert (
34+
get_most_recent_run(runs)
35+
== "root-2025-03-20_18:34:43-orch:cephadm:smoke-small-main-distro-default-testnode"
36+
)
37+
38+
def test_get_job_id_returns_job_on_unique_job(self):
39+
jobs = ["97"]
40+
assert get_job_id(jobs) == "97"
41+
42+
def test_get_job_id_throws_filenotfound_on_missing_job(self):
43+
jobs = []
44+
with pytest.raises(FileNotFoundError):
45+
get_job_id(jobs)
46+
47+
def test_get_job_id_throws_toomanyjobsfound_on_more_than_one_job(self):
48+
jobs = ["1", "2"]
49+
with pytest.raises(TooManyJobsFound) as exc:
50+
get_job_id(jobs)
51+
assert exc.value.jobs == jobs
52+
53+
async def test_logs_command_display_log_file_of_latest_run(
54+
self, tmp_path, create_log_file
55+
):
56+
data_dir = str(tmp_path)
57+
config["data_dir"] = data_dir
58+
f = io.StringIO()
59+
content = "custom log content"
60+
now = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
61+
forty_days_ago = (datetime.now() - timedelta(days=40)).strftime(
62+
"%Y-%m-%d_%H:%M:%S"
63+
)
64+
65+
create_log_file(data_dir, timestamp=now, content=content)
66+
create_log_file(data_dir, timestamp=forty_days_ago)
67+
68+
with contextlib.redirect_stdout(f):
69+
devstack = CephDevStack()
70+
await devstack.logs()
71+
assert content in f.getvalue()
72+
73+
async def test_logs_display_roughly_contents_of_log_file(
74+
self, tmp_path, create_log_file
75+
):
76+
data_dir = str(tmp_path)
77+
config["data_dir"] = data_dir
78+
f = io.StringIO()
79+
content = "".join(
80+
secrets.choice(string.ascii_letters + string.digits)
81+
for _ in range(6 * 8 * 1024)
82+
)
83+
now = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
84+
create_log_file(data_dir, timestamp=now, content=content)
85+
86+
with contextlib.redirect_stdout(f):
87+
devstack = CephDevStack()
88+
await devstack.logs()
89+
assert content == f.getvalue()
90+
91+
async def test_logs_command_display_log_file_of_given_job_id(
92+
self, tmp_path, create_log_file
93+
):
94+
data_dir = str(tmp_path)
95+
config["data_dir"] = data_dir
96+
f = io.StringIO()
97+
content = "custom log message"
98+
now = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
99+
100+
create_log_file(
101+
data_dir,
102+
timestamp=now,
103+
test_type="ceph",
104+
job_id="1",
105+
content="another log",
106+
)
107+
create_log_file(
108+
data_dir, timestamp=now, test_type="ceph", job_id="2", content=content
109+
)
110+
111+
with contextlib.redirect_stdout(f):
112+
devstack = CephDevStack()
113+
await devstack.logs(job_id="2")
114+
assert content in f.getvalue()
115+
116+
async def test_logs_display_content_of_provided_run_name(
117+
self, tmp_path, create_log_file
118+
):
119+
data_dir = str(tmp_path)
120+
config["data_dir"] = data_dir
121+
f = io.StringIO()
122+
content = "custom content"
123+
now = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
124+
three_days_ago = (datetime.now() - timedelta(days=3)).strftime(
125+
"%Y-%m-%d_%H:%M:%S"
126+
)
127+
128+
create_log_file(
129+
data_dir,
130+
timestamp=now,
131+
)
132+
run_name = create_log_file(
133+
data_dir,
134+
timestamp=three_days_ago,
135+
content=content,
136+
).split("/")[-3]
137+
138+
with contextlib.redirect_stdout(f):
139+
devstack = CephDevStack()
140+
await devstack.logs(run_name=run_name)
141+
assert content in f.getvalue()
142+
143+
async def test_logs_locate_display_file_path_instead_of_config(
144+
self, tmp_path, create_log_file
145+
):
146+
data_dir = str(tmp_path)
147+
148+
config["data_dir"] = data_dir
149+
f = io.StringIO()
150+
log_file = create_log_file(data_dir)
151+
with contextlib.redirect_stdout(f):
152+
devstack = CephDevStack()
153+
await devstack.logs(locate=True)
154+
assert log_file in f.getvalue()
155+
156+
@pytest.fixture(scope="class")
157+
def create_log_file(self):
158+
def _create_log_file(data_dir: str, **kwargs):
159+
parts = {
160+
"timestamp": (
161+
datetime.now() - timedelta(days=rd.randint(1, 100))
162+
).strftime("%Y-%m-%d_%H:%M:%S"),
163+
"test_type": rd.choice(["ceph", "rgw", "rbd", "mds"]),
164+
"job_id": rd.randint(1, 100),
165+
"content": "some log data",
166+
**kwargs,
167+
}
168+
timestamp = parts["timestamp"]
169+
test_type = parts["test_type"]
170+
job_id = parts["job_id"]
171+
content = parts["content"]
172+
173+
run_name = f"root-{timestamp}-orch:cephadm:{test_type}-small-main-distro-default-testnode"
174+
log_dir = f"{data_dir}/archive/{run_name}/{job_id}"
175+
176+
os.makedirs(log_dir, exist_ok=True)
177+
log_file = f"{log_dir}/teuthology.log"
178+
with open(log_file, "w") as f:
179+
f.write(content)
180+
return log_file
181+
182+
return _create_log_file

0 commit comments

Comments
 (0)