diff --git a/schema/snapcraft.json b/schema/snapcraft.json index c4c0a5b3ec..2d019dbc4a 100644 --- a/schema/snapcraft.json +++ b/schema/snapcraft.json @@ -711,19 +711,19 @@ }, "start-timeout": { "type": "string", - "pattern": "^[0-9]+(ns|us|ms|s|m)*$", + "pattern": "^([0-9]+(ns|us|ms|s|m)){1,5}$", "validation-failure": "{.instance!r} is not a valid timeout value.", "description": "Optional time to wait for daemon to start - ns | us | ms | s | m" }, "stop-timeout": { "type": "string", - "pattern": "^[0-9]+(ns|us|ms|s|m)*$", + "pattern": "^([0-9]+(ns|us|ms|s|m)){1,5}$", "validation-failure": "{.instance!r} is not a valid timeout value.", "description": "Optional time to wait for daemon to stop - ns | us | ms | s | m" }, "watchdog-timeout": { "type": "string", - "pattern": "^[0-9]+(ns|us|ms|s|m)*$", + "pattern": "^([0-9]+(ns|us|ms|s|m)){1,5}$", "validation-failure": "{.instance!r} is not a valid timeout value.", "description": "Service watchdog timeout - ns | us | ms | s | m" }, @@ -733,7 +733,7 @@ }, "restart-delay": { "type": "string", - "pattern": "^[0-9]+(ns|us|ms|s|m)*$", + "pattern": "^([0-9]+(ns|us|ms|s|m)){1,5}$", "validation-failure": "{.instance!r} is not a valid delay value.", "description": "Delay between service restarts - ns | us | ms | s | m. Defaults to unset. See the systemd.service manual on RestartSec for details." }, diff --git a/snapcraft/models/project.py b/snapcraft/models/project.py index 7c95a789c3..e3220676de 100644 --- a/snapcraft/models/project.py +++ b/snapcraft/models/project.py @@ -46,6 +46,7 @@ from snapcraft.providers import SNAPCRAFT_BASE_TO_PROVIDER_BASE from snapcraft.utils import get_effective_base +TIME_DURATION_REGEX = re.compile(r"^([0-9]+(ns|us|ms|s|m)){1,5}$") ProjectName = Annotated[str, StringConstraints(max_length=40)] @@ -282,6 +283,24 @@ def _validate_mandatory_base(base: str | None, snap_type: str | None) -> None: ) +def _validate_duration_string(duration: str): + if not TIME_DURATION_REGEX.match(duration): + raise ValueError(f"{duration!r} is not a valid time value") + + return duration + + +DurationString = Annotated[ + str, + pydantic.Field( + examples=["1", "2s", "3m", "4ms", "5us", "6m7s8ms"], + pattern=TIME_DURATION_REGEX, + description="A duration string to be parsed by snapd.", + ), + pydantic.BeforeValidator(_validate_duration_string), +] + + class Socket(models.CraftBaseModel): """Snapcraft app socket definition.""" @@ -415,17 +434,17 @@ class App(models.CraftBaseModel): description="The command to run after the service is stopped.", examples=["post-stop-command: bin/logrotate --force"], ) - start_timeout: str | None = pydantic.Field( + start_timeout: DurationString | None = pydantic.Field( default=None, description="The maximum amount of time to wait for the service to start.", examples=["start-timeout: 10s", "start-timeout: 2m"], ) - stop_timeout: str | None = pydantic.Field( + stop_timeout: DurationString | None = pydantic.Field( default=None, description="The maximum amount of time to wait for the service to stop.", examples=["stop-timeout: 10s", "stop-timeout: 2m"], ) - watchdog_timeout: str | None = pydantic.Field( + watchdog_timeout: DurationString | None = pydantic.Field( default=None, description="The maximum amount of time the service can run without sending a heartbeat to the watchdog.", examples=["watchdog-timeout: 10s", "watchdog-timeout: 2m"], @@ -435,7 +454,7 @@ class App(models.CraftBaseModel): description="The command to run to restart the service.", examples=["reload-command: bin/foo-app --restart"], ) - restart_delay: str | None = pydantic.Field( + restart_delay: DurationString | None = pydantic.Field( default=None, description="The time to wait between service restarts.", examples=["restart-delay: 10s", "restart-delay: 2m"], @@ -588,16 +607,6 @@ def _validate_apps_section_content(cls, command: str) -> str: return command - @pydantic.field_validator( - "start_timeout", "stop_timeout", "watchdog_timeout", "restart_delay" - ) - @classmethod - def _validate_time(cls, timeval): - if not re.match(r"^[0-9]+(ns|us|ms|s|m)*$", timeval): - raise ValueError(f"{timeval!r} is not a valid time value") - - return timeval - @pydantic.field_validator("command_chain") @classmethod def _validate_command_chain(cls, command_chains): diff --git a/tests/unit/models/test_projects.py b/tests/unit/models/test_projects.py index f5aa7f8cea..08b5a0955e 100644 --- a/tests/unit/models/test_projects.py +++ b/tests/unit/models/test_projects.py @@ -40,6 +40,8 @@ # required project data for core24 snaps CORE24_DATA = {"base": "core24", "grade": "devel"} +VALID_DURATIONS = ["10ns", "10us", "10ms", "10s", "10m", "10m4s3us"] +INVALID_DURATIONS = ["10", "10 s", "10 seconds", "1:00", "invalid"] @pytest.fixture @@ -982,19 +984,14 @@ def test_app_post_stop_command(self, app_yaml_data): assert project.apps is not None assert project.apps["app1"].post_stop_command == "test-post-stop-command" - @pytest.mark.parametrize( - "start_timeout", ["10", "10ns", "10us", "10ms", "10s", "10m"] - ) + @pytest.mark.parametrize("start_timeout", VALID_DURATIONS) def test_app_start_timeout_valid(self, start_timeout, app_yaml_data): data = app_yaml_data(start_timeout=start_timeout) project = Project.unmarshal(data) assert project.apps is not None assert project.apps["app1"].start_timeout == start_timeout - @pytest.mark.parametrize( - "start_timeout", - ["10 s", "10 seconds", "1:00", "invalid"], - ) + @pytest.mark.parametrize("start_timeout", INVALID_DURATIONS) def test_app_start_timeout_invalid(self, start_timeout, app_yaml_data): data = app_yaml_data(start_timeout=start_timeout) @@ -1002,19 +999,14 @@ def test_app_start_timeout_invalid(self, start_timeout, app_yaml_data): with pytest.raises(pydantic.ValidationError, match=error): Project.unmarshal(data) - @pytest.mark.parametrize( - "stop_timeout", ["10", "10ns", "10us", "10ms", "10s", "10m"] - ) + @pytest.mark.parametrize("stop_timeout", VALID_DURATIONS) def test_app_stop_timeout_valid(self, stop_timeout, app_yaml_data): data = app_yaml_data(stop_timeout=stop_timeout) project = Project.unmarshal(data) assert project.apps is not None assert project.apps["app1"].stop_timeout == stop_timeout - @pytest.mark.parametrize( - "stop_timeout", - ["10 s", "10 seconds", "1:00", "invalid"], - ) + @pytest.mark.parametrize("stop_timeout", INVALID_DURATIONS) def test_app_stop_timeout_invalid(self, stop_timeout, app_yaml_data): data = app_yaml_data(stop_timeout=stop_timeout) @@ -1022,19 +1014,14 @@ def test_app_stop_timeout_invalid(self, stop_timeout, app_yaml_data): with pytest.raises(pydantic.ValidationError, match=error): Project.unmarshal(data) - @pytest.mark.parametrize( - "watchdog_timeout", ["10", "10ns", "10us", "10ms", "10s", "10m"] - ) + @pytest.mark.parametrize("watchdog_timeout", VALID_DURATIONS) def test_app_watchdog_timeout_valid(self, watchdog_timeout, app_yaml_data): data = app_yaml_data(watchdog_timeout=watchdog_timeout) project = Project.unmarshal(data) assert project.apps is not None assert project.apps["app1"].watchdog_timeout == watchdog_timeout - @pytest.mark.parametrize( - "watchdog_timeout", - ["10 s", "10 seconds", "1:00", "invalid"], - ) + @pytest.mark.parametrize("watchdog_timeout", INVALID_DURATIONS) def test_app_watchdog_timeout_invalid(self, watchdog_timeout, app_yaml_data): data = app_yaml_data(watchdog_timeout=watchdog_timeout) @@ -1048,19 +1035,14 @@ def test_app_reload_command(self, app_yaml_data): assert project.apps is not None assert project.apps["app1"].reload_command == "test-reload-command" - @pytest.mark.parametrize( - "restart_delay", ["10", "10ns", "10us", "10ms", "10s", "10m"] - ) + @pytest.mark.parametrize("restart_delay", VALID_DURATIONS) def test_app_restart_delay_valid(self, restart_delay, app_yaml_data): data = app_yaml_data(restart_delay=restart_delay) project = Project.unmarshal(data) assert project.apps is not None assert project.apps["app1"].restart_delay == restart_delay - @pytest.mark.parametrize( - "restart_delay", - ["10 s", "10 seconds", "1:00", "invalid"], - ) + @pytest.mark.parametrize("restart_delay", INVALID_DURATIONS) def test_app_restart_delay_invalid(self, restart_delay, app_yaml_data): data = app_yaml_data(restart_delay=restart_delay)