Skip to content

Commit 0ab473f

Browse files
committed
feat: add human-readable duration format support for reservation timeouts
Add support for human-readable duration formats (e.g., '2h30m', '5h', '30m') for reservation timeouts in addition to existing integer seconds format. Changes: - Add duration parser in testflinger-common with support for s/m/h/d units - Update CLI reserve command with --timeout/-t argument accepting duration formats - Add DurationField to server schema for automatic parsing and validation - Update documentation with duration format examples - Maintain full backward compatibility with existing integer timeout values Examples: - testflinger-cli reserve -q queue -i image.img -k lp:user -t 2h30m - Job YAML: timeout: '5h' (parsed to 18000 seconds) - API: {"timeout": "30m"} (validated and converted to 1800) Fixes: CERTTF-586
1 parent 490adc9 commit 0ab473f

12 files changed

Lines changed: 435 additions & 6 deletions

File tree

cli/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ dependencies = [
1212
"pyjwt>=2.10.1",
1313
"pyyaml>=6.0.2",
1414
"requests>=2.32.3",
15+
"testflinger-common",
1516
"xdg-base-dirs>=6.0.2",
1617
]
1718
dynamic = ["version"]
@@ -70,3 +71,6 @@ convention = "pep257"
7071

7172
[tool.uv]
7273
cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true } }]
74+
75+
[tool.uv.sources]
76+
testflinger-common = { path = "../common" }

cli/testflinger_cli/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import argcomplete
3838
import requests
3939
import yaml
40+
from testflinger_common.duration import parse_duration, DurationParseError
4041

4142
from testflinger_cli import (
4243
autocomplete,
@@ -286,6 +287,14 @@ def _add_reserve_args(self, subparsers):
286287
"(ex: -k lp:userid -k gh:userid)"
287288
),
288289
)
290+
parser.add_argument(
291+
"--timeout",
292+
"-t",
293+
help=(
294+
"Reservation timeout. Can be specified in seconds (3600) "
295+
"or using duration format (30m, 5h, 4d). Default is 1 hour."
296+
),
297+
)
289298

290299
def _add_status_args(self, subparsers):
291300
"""Command line arguments for status."""
@@ -1022,6 +1031,15 @@ def reserve(self):
10221031
for ssh_key in ssh_keys:
10231032
if not ssh_key.startswith("lp:") and not ssh_key.startswith("gh:"):
10241033
logger.error("Invalid SSH key format: %s", ssh_key)
1034+
1035+
# Parse timeout if provided
1036+
timeout_seconds = None
1037+
if self.args.timeout:
1038+
try:
1039+
timeout_seconds = parse_duration(self.args.timeout)
1040+
except DurationParseError as e:
1041+
logger.error("Invalid timeout format: %s", e)
1042+
sys.exit(1)
10251043
template = inspect.cleandoc(
10261044
"""job_queue: {queue}
10271045
provision_data:
@@ -1031,6 +1049,8 @@ def reserve(self):
10311049
)
10321050
for ssh_key in ssh_keys:
10331051
template += f"\n - {ssh_key}"
1052+
if timeout_seconds is not None:
1053+
template += f"\n timeout: {timeout_seconds}"
10341054
job_data = template.format(queue=queue, image=image)
10351055
print("\nThe following yaml will be submitted:")
10361056
print(job_data)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Copyright (C) 2025 Canonical
2+
#
3+
# This program is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License.
6+
#
7+
# This program is distributed in the hope that it will be useful,
8+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10+
# GNU General Public License for more details.
11+
#
12+
# You should have received a copy of the GNU General Public License
13+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
14+
15+
"""Tests for duration format support in CLI reserve command."""
16+
17+
import sys
18+
import testflinger_cli
19+
20+
21+
def test_reserve_with_timeout(capsys, requests_mock):
22+
"""Test reserve command with duration timeout generates correct YAML."""
23+
requests_mock.get("https://testflinger.canonical.com/v1/agents/queues", json={})
24+
requests_mock.get("https://testflinger.canonical.com/v1/agents/images/fake", json={})
25+
26+
sys.argv = ["", "reserve", "-q", "fake", "-i", "http://fake_image.xz",
27+
"-k", "lp:fakeuser", "-t", "2h30m", "-d"]
28+
29+
tfcli = testflinger_cli.TestflingerCli()
30+
tfcli.reserve()
31+
std = capsys.readouterr()
32+
assert "timeout: 9000" in std.out # 2h30m = 9000 seconds
33+
34+
35+
def test_reserve_without_timeout(capsys, requests_mock):
36+
"""Test reserve command without timeout omits timeout field."""
37+
requests_mock.get("https://testflinger.canonical.com/v1/agents/queues", json={})
38+
requests_mock.get("https://testflinger.canonical.com/v1/agents/images/fake", json={})
39+
40+
sys.argv = ["", "reserve", "-q", "fake", "-i", "http://fake_image.xz",
41+
"-k", "lp:fakeuser", "-d"]
42+
43+
tfcli = testflinger_cli.TestflingerCli()
44+
tfcli.reserve()
45+
std = capsys.readouterr()
46+
assert "timeout:" not in std.out

cli/uv.lock

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# Copyright (C) 2025 Canonical
2+
#
3+
# This program is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License.
6+
#
7+
# This program is distributed in the hope that it will be useful,
8+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10+
# GNU General Public License for more details.
11+
#
12+
# You should have received a copy of the GNU General Public License
13+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
14+
15+
"""Duration parsing utilities for Testflinger.
16+
17+
This module provides utilities to parse duration strings in human-readable
18+
formats (like '30m', '5h', '4d') into seconds, similar to the sleep command.
19+
"""
20+
21+
import re
22+
from typing import Union
23+
24+
25+
class DurationParseError(ValueError):
26+
"""Raised when a duration string cannot be parsed."""
27+
pass
28+
29+
30+
def parse_duration(duration: Union[str, int]) -> int:
31+
"""Parse a duration string or integer into seconds.
32+
33+
Supports the following formats:
34+
- Plain integers (interpreted as seconds): 3600
35+
- Duration strings with suffixes:
36+
- 's' or 'sec' for seconds: '30s', '30sec'
37+
- 'm' or 'min' for minutes: '30m', '30min'
38+
- 'h' or 'hour' for hours: '5h', '5hour'
39+
- 'd' or 'day' for days: '4d', '4day'
40+
41+
Multiple units can be combined: '1h30m', '2d5h30m'
42+
43+
Args:
44+
duration: Duration as string or integer
45+
46+
Returns:
47+
Duration in seconds as integer
48+
49+
Raises:
50+
DurationParseError: If the duration string is invalid
51+
52+
Examples:
53+
>>> parse_duration(3600)
54+
3600
55+
>>> parse_duration('30m')
56+
1800
57+
>>> parse_duration('5h')
58+
18000
59+
>>> parse_duration('4d')
60+
345600
61+
>>> parse_duration('1h30m')
62+
5400
63+
>>> parse_duration('2d5h30m')
64+
192600
65+
"""
66+
if isinstance(duration, int):
67+
if duration < 0:
68+
raise DurationParseError("Duration cannot be negative")
69+
return duration
70+
71+
if not isinstance(duration, str):
72+
raise DurationParseError(f"Duration must be string or int, got {type(duration)}")
73+
74+
duration = duration.strip()
75+
if not duration:
76+
raise DurationParseError("Duration cannot be empty")
77+
78+
# Try parsing as plain integer first
79+
try:
80+
value = int(duration)
81+
if value < 0:
82+
raise DurationParseError("Duration cannot be negative")
83+
return value
84+
except ValueError:
85+
pass
86+
87+
# Check for negative numbers in duration strings
88+
if duration.strip().startswith('-'):
89+
raise DurationParseError("Duration cannot be negative")
90+
91+
# Parse duration string with units
92+
# Pattern matches: number followed by optional unit
93+
# Units: s/sec, m/min, h/hour, d/day (case insensitive)
94+
# Order matters - longer forms first to avoid partial matches
95+
pattern = r'(\d+)\s*(secs?|mins?|hours?|days?|sec|min|hour|day|s|m|h|d)'
96+
matches = re.findall(pattern, duration.lower())
97+
98+
if not matches:
99+
raise DurationParseError(f"Invalid duration format: '{duration}'")
100+
101+
# Check if the entire string was consumed by matches
102+
# Reconstruct what should have been matched and compare
103+
reconstructed = ''
104+
for num, unit in matches:
105+
reconstructed += f"{num}{unit}"
106+
107+
# Remove all whitespace for comparison
108+
normalized_input = re.sub(r'\s+', '', duration.lower())
109+
110+
if normalized_input != reconstructed:
111+
raise DurationParseError(f"Invalid duration format: '{duration}'")
112+
113+
total_seconds = 0
114+
unit_multipliers = {
115+
's': 1,
116+
'sec': 1,
117+
'secs': 1,
118+
'm': 60,
119+
'min': 60,
120+
'mins': 60,
121+
'h': 3600,
122+
'hour': 3600,
123+
'hours': 3600,
124+
'd': 86400,
125+
'day': 86400,
126+
'days': 86400,
127+
}
128+
129+
for num_str, unit in matches:
130+
num = int(num_str)
131+
if num < 0:
132+
raise DurationParseError("Duration components cannot be negative")
133+
134+
multiplier = unit_multipliers[unit]
135+
total_seconds += num * multiplier
136+
137+
return total_seconds
138+
139+
140+
def format_duration(seconds: int) -> str:
141+
"""Format seconds into a human-readable duration string.
142+
143+
Args:
144+
seconds: Duration in seconds
145+
146+
Returns:
147+
Human-readable duration string
148+
149+
Examples:
150+
>>> format_duration(3600)
151+
'1h'
152+
>>> format_duration(1800)
153+
'30m'
154+
>>> format_duration(5400)
155+
'1h30m'
156+
>>> format_duration(345600)
157+
'4d'
158+
"""
159+
if seconds < 0:
160+
raise ValueError("Duration cannot be negative")
161+
162+
if seconds == 0:
163+
return '0s'
164+
165+
parts = []
166+
167+
# Days
168+
if seconds >= 86400:
169+
days = seconds // 86400
170+
parts.append(f"{days}d")
171+
seconds %= 86400
172+
173+
# Hours
174+
if seconds >= 3600:
175+
hours = seconds // 3600
176+
parts.append(f"{hours}h")
177+
seconds %= 3600
178+
179+
# Minutes
180+
if seconds >= 60:
181+
minutes = seconds // 60
182+
parts.append(f"{minutes}m")
183+
seconds %= 60
184+
185+
# Seconds
186+
if seconds > 0:
187+
parts.append(f"{seconds}s")
188+
189+
return ''.join(parts)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright (C) 2025 Canonical
2+
#
3+
# This program is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License.
6+
#
7+
# This program is distributed in the hope that it will be useful,
8+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10+
# GNU General Public License for more details.
11+
#
12+
# You should have received a copy of the GNU General Public License
13+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
14+
15+
"""Tests for testflinger_common package."""

0 commit comments

Comments
 (0)