From 638216d6e7ac984e2c2052c2acd6fe2dfde961ec Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Sun, 12 Jul 2026 16:45:12 +0800 Subject: [PATCH] feat(cloud): validate control plane and host configuration --- apps/device-host-agent/host_agent/config.py | 91 +++++++++++++ apps/device-host-agent/tests/test_config.py | 60 ++++++++ .../cloud-control-plane-integration/tasks.md | 2 +- .../cloud-platform/cloud/control_config.py | 128 ++++++++++++++++++ tests/test_cloud_control_config.py | 75 ++++++++++ 5 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 apps/device-host-agent/host_agent/config.py create mode 100644 apps/device-host-agent/tests/test_config.py create mode 100644 packages/cloud-platform/cloud/control_config.py create mode 100644 tests/test_cloud_control_config.py diff --git a/apps/device-host-agent/host_agent/config.py b/apps/device-host-agent/host_agent/config.py new file mode 100644 index 0000000..05410ba --- /dev/null +++ b/apps/device-host-agent/host_agent/config.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass +from urllib.parse import urlparse + + +class HostAgentConfigurationError(ValueError): + """Raised when Host Agent process configuration is invalid.""" + + +@dataclass(frozen=True) +class HostAgentConfig: + control_plane_url: str + host_id: str + token: str + heartbeat_interval_seconds: float = 30.0 + poll_timeout_seconds: float = 20.0 + retry_backoff_seconds: float = 1.0 + max_retry_backoff_seconds: float = 30.0 + + +def load_host_agent_config( + env: Mapping[str, str] | None = None, +) -> HostAgentConfig: + values = os.environ if env is None else env + control_plane_url = values.get( + "HOST_AGENT_CONTROL_PLANE_URL", + "http://127.0.0.1:8001", + ).strip().rstrip("/") + parsed_url = urlparse(control_plane_url) + if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: + raise HostAgentConfigurationError( + "HOST_AGENT_CONTROL_PLANE_URL must be an HTTP(S) URL" + ) + + host_id = values.get("HOST_AGENT_HOST_ID", "").strip() + if not host_id: + raise HostAgentConfigurationError("HOST_AGENT_HOST_ID is required") + token = values.get("HOST_AGENT_TOKEN", "").strip() + if not token: + raise HostAgentConfigurationError("HOST_AGENT_TOKEN is required") + + config = HostAgentConfig( + control_plane_url=control_plane_url, + host_id=host_id, + token=token, + heartbeat_interval_seconds=_positive_float( + values, + "HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS", + 30.0, + ), + poll_timeout_seconds=_positive_float( + values, + "HOST_AGENT_POLL_TIMEOUT_SECONDS", + 20.0, + ), + retry_backoff_seconds=_positive_float( + values, + "HOST_AGENT_RETRY_BACKOFF_SECONDS", + 1.0, + ), + max_retry_backoff_seconds=_positive_float( + values, + "HOST_AGENT_MAX_RETRY_BACKOFF_SECONDS", + 30.0, + ), + ) + if config.max_retry_backoff_seconds < config.retry_backoff_seconds: + raise HostAgentConfigurationError( + "maximum retry backoff must not be less than initial backoff" + ) + return config + + +def _positive_float( + values: Mapping[str, str], + name: str, + default: float, +) -> float: + raw_value = values.get(name) + if raw_value is None: + return default + try: + value = float(raw_value) + except ValueError as exc: + raise HostAgentConfigurationError(f"{name} must be a number") from exc + if value <= 0: + raise HostAgentConfigurationError(f"{name} must be greater than zero") + return value diff --git a/apps/device-host-agent/tests/test_config.py b/apps/device-host-agent/tests/test_config.py new file mode 100644 index 0000000..582cfaf --- /dev/null +++ b/apps/device-host-agent/tests/test_config.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import pytest + +from host_agent.config import ( + HostAgentConfigurationError, + HostAgentConfig, + load_host_agent_config, +) + + +BASE_ENV = { + "HOST_AGENT_HOST_ID": "host-a", + "HOST_AGENT_TOKEN": "secret", +} + + +def test_load_host_agent_config_uses_local_network_defaults() -> None: + assert load_host_agent_config(BASE_ENV) == HostAgentConfig( + control_plane_url="http://127.0.0.1:8001", + host_id="host-a", + token="secret", + ) + + +def test_load_host_agent_config_parses_poll_and_retry_values() -> None: + config = load_host_agent_config( + { + **BASE_ENV, + "HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example/v1/", + "HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS": "10", + "HOST_AGENT_POLL_TIMEOUT_SECONDS": "15", + "HOST_AGENT_RETRY_BACKOFF_SECONDS": "2", + "HOST_AGENT_MAX_RETRY_BACKOFF_SECONDS": "20", + } + ) + + assert config.control_plane_url == "https://cloud.example/v1" + assert config.poll_timeout_seconds == 15 + assert config.max_retry_backoff_seconds == 20 + + +@pytest.mark.parametrize( + "overrides", + [ + {"HOST_AGENT_HOST_ID": ""}, + {"HOST_AGENT_TOKEN": ""}, + {"HOST_AGENT_CONTROL_PLANE_URL": "ftp://cloud.example"}, + {"HOST_AGENT_POLL_TIMEOUT_SECONDS": "0"}, + { + "HOST_AGENT_RETRY_BACKOFF_SECONDS": "10", + "HOST_AGENT_MAX_RETRY_BACKOFF_SECONDS": "5", + }, + ], +) +def test_load_host_agent_config_rejects_invalid_values( + overrides: dict[str, str], +) -> None: + with pytest.raises(HostAgentConfigurationError): + load_host_agent_config({**BASE_ENV, **overrides}) diff --git a/openspec/changes/cloud-control-plane-integration/tasks.md b/openspec/changes/cloud-control-plane-integration/tasks.md index 7542768..28eab75 100644 --- a/openspec/changes/cloud-control-plane-integration/tasks.md +++ b/openspec/changes/cloud-control-plane-integration/tasks.md @@ -4,7 +4,7 @@ - Verified 17/17 tasks complete, strict OpenSpec validation passed, and the shared lockfile is current. - [x] 1.2 Add `apps/cloud-api` as the `device-cloud-api` workspace project with an app factory and CLI/server entry point. - [x] 1.3 Add `apps/device-host-agent` as the `device-host-agent` workspace project with a CLI entry point and explicit Runtime/cloud dependencies. -- [ ] 1.4 Add configuration models that validate environment, database URL, scheduler intervals, lease durations, retry limits, poll settings, host identity, and insecure-development overrides. +- [x] 1.4 Add configuration models that validate environment, database URL, scheduler intervals, lease durations, retry limits, poll settings, host identity, and insecure-development overrides. ## 2. Repository Contract And Migrations diff --git a/packages/cloud-platform/cloud/control_config.py b/packages/cloud-platform/cloud/control_config.py new file mode 100644 index 0000000..09d22b3 --- /dev/null +++ b/packages/cloud-platform/cloud/control_config.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Literal + + +EnvironmentName = Literal["local", "test", "production"] +SUPPORTED_DATABASE_PREFIXES = ( + "sqlite:///", + "postgresql://", + "postgresql+psycopg://", +) + + +class CloudConfigurationError(ValueError): + """Raised when control-plane configuration is unsafe or invalid.""" + + +@dataclass(frozen=True) +class CloudControlConfig: + environment: EnvironmentName = "local" + database_url: str = "sqlite:///cloud/cloud.sqlite3" + scheduler_interval_seconds: float = 1.0 + lease_reaper_interval_seconds: float = 5.0 + lease_duration_seconds: float = 60.0 + max_task_attempts: int = 3 + allow_insecure_anonymous: bool = False + + +def load_control_config( + env: Mapping[str, str] | None = None, +) -> CloudControlConfig: + values = os.environ if env is None else env + environment = values.get("CLOUD_ENVIRONMENT", "local").strip().lower() + if environment not in {"local", "test", "production"}: + raise CloudConfigurationError( + "CLOUD_ENVIRONMENT must be local, test, or production" + ) + + database_url = values.get( + "CLOUD_DATABASE_URL", + "sqlite:///cloud/cloud.sqlite3", + ).strip() + if not database_url.startswith(SUPPORTED_DATABASE_PREFIXES): + raise CloudConfigurationError( + "CLOUD_DATABASE_URL must use sqlite or postgresql" + ) + + config = CloudControlConfig( + environment=environment, # type: ignore[arg-type] + database_url=database_url, + scheduler_interval_seconds=_positive_float( + values, + "CLOUD_SCHEDULER_INTERVAL_SECONDS", + 1.0, + ), + lease_reaper_interval_seconds=_positive_float( + values, + "CLOUD_LEASE_REAPER_INTERVAL_SECONDS", + 5.0, + ), + lease_duration_seconds=_positive_float( + values, + "CLOUD_LEASE_DURATION_SECONDS", + 60.0, + ), + max_task_attempts=_positive_int( + values, + "CLOUD_MAX_TASK_ATTEMPTS", + 3, + ), + allow_insecure_anonymous=_parse_bool( + values.get("CLOUD_ALLOW_INSECURE_ANONYMOUS"), + default=False, + ), + ) + if config.environment == "production" and config.allow_insecure_anonymous: + raise CloudConfigurationError( + "anonymous access cannot be enabled in production" + ) + return config + + +def _positive_float( + values: Mapping[str, str], + name: str, + default: float, +) -> float: + raw_value = values.get(name) + if raw_value is None: + return default + try: + value = float(raw_value) + except ValueError as exc: + raise CloudConfigurationError(f"{name} must be a number") from exc + if value <= 0: + raise CloudConfigurationError(f"{name} must be greater than zero") + return value + + +def _positive_int( + values: Mapping[str, str], + name: str, + default: int, +) -> int: + raw_value = values.get(name) + if raw_value is None: + return default + try: + value = int(raw_value) + except ValueError as exc: + raise CloudConfigurationError(f"{name} must be an integer") from exc + if value <= 0: + raise CloudConfigurationError(f"{name} must be greater than zero") + return value + + +def _parse_bool(value: str | None, *, default: bool) -> bool: + if value is None: + return default + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on", "enabled"}: + return True + if normalized in {"0", "false", "no", "off", "disabled", ""}: + return False + raise CloudConfigurationError("boolean configuration value is invalid") diff --git a/tests/test_cloud_control_config.py b/tests/test_cloud_control_config.py new file mode 100644 index 0000000..77c0497 --- /dev/null +++ b/tests/test_cloud_control_config.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import pytest + +from cloud.control_config import ( + CloudConfigurationError, + CloudControlConfig, + load_control_config, +) + + +def test_load_control_config_uses_safe_local_defaults() -> None: + assert load_control_config({}) == CloudControlConfig() + + +def test_load_control_config_parses_deployment_values() -> None: + config = load_control_config( + { + "CLOUD_ENVIRONMENT": "production", + "CLOUD_DATABASE_URL": "postgresql+psycopg://cloud@db/cloud", + "CLOUD_SCHEDULER_INTERVAL_SECONDS": "2.5", + "CLOUD_LEASE_REAPER_INTERVAL_SECONDS": "7", + "CLOUD_LEASE_DURATION_SECONDS": "90", + "CLOUD_MAX_TASK_ATTEMPTS": "5", + } + ) + + assert config.environment == "production" + assert config.database_url.startswith("postgresql+psycopg://") + assert config.scheduler_interval_seconds == 2.5 + assert config.max_task_attempts == 5 + + +@pytest.mark.parametrize( + "environment,database_url", + [("invalid", "sqlite:///test.db"), ("local", "mysql://db/cloud")], +) +def test_load_control_config_rejects_invalid_environment_or_database( + environment: str, + database_url: str, +) -> None: + with pytest.raises(CloudConfigurationError): + load_control_config( + { + "CLOUD_ENVIRONMENT": environment, + "CLOUD_DATABASE_URL": database_url, + } + ) + + +def test_load_control_config_rejects_unsafe_production_anonymous_mode() -> None: + with pytest.raises(CloudConfigurationError, match="production"): + load_control_config( + { + "CLOUD_ENVIRONMENT": "production", + "CLOUD_DATABASE_URL": "postgresql://db/cloud", + "CLOUD_ALLOW_INSECURE_ANONYMOUS": "true", + } + ) + + +@pytest.mark.parametrize( + "name,value", + [ + ("CLOUD_SCHEDULER_INTERVAL_SECONDS", "0"), + ("CLOUD_LEASE_DURATION_SECONDS", "invalid"), + ("CLOUD_MAX_TASK_ATTEMPTS", "-1"), + ], +) +def test_load_control_config_rejects_invalid_numeric_values( + name: str, + value: str, +) -> None: + with pytest.raises(CloudConfigurationError): + load_control_config({name: value})