diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..0fabc9f --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,41 @@ +server: + host: 0.0.0.0 + port: 19090 + +vehicle: + vehicle_id: vehicle-test-001 + vin: vehicle-test-001 + current_release: vehicle-release-0.0.1 + +cloud: + base_url: http://your-ota-server:8080 + heartbeat_path: /api/agent/heartbeat + update_check_path: /api/agent/update-check + report_path: /api/agent/report + timeout_seconds: 10 + token: change-me + token_header: X-OTA-TOKEN + +compose: + working_dir: D:/Procedure/noblelift/hangzhou/OTA-Agent/runtime + file: docker-compose.yml + env_file: ota-images.env + backup_env_file: ota-images.env.bak + pull_command: docker compose pull + up_command: docker compose up -d + health_check_seconds: 30 + healthcheck_interval_seconds: 3 + healthcheck_url: http://127.0.0.1:8081/actuator/health + +polling: + update_interval_seconds: 30 + heartbeat_interval_seconds: 30 + +storage: + state_file: D:/Procedure/noblelift/hangzhou/OTA-Agent/runtime/state.json + manifest_dir: D:/Procedure/noblelift/hangzhou/OTA-Agent/runtime/manifests + log_dir: D:/Procedure/noblelift/hangzhou/OTA-Agent/runtime/logsmysql_backup: + enabled: false + backup_dir: D:/Procedure/noblelift/hangzhou/OTA-Agent/runtime/mysql-backups + dump_command: docker exec mysql mysqldump -uroot -p123456 app_db > {backup_file} + timeout_seconds: 120 \ No newline at end of file diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..444ea2d --- /dev/null +++ b/config.yaml @@ -0,0 +1,50 @@ +server: + host: 0.0.0.0 + port: 19090 + +vehicle: + vehicle_id: agent-001 + vin: agent-001 + current_release: vehicle-release-0.0.1 + +cloud: + base_url: http://192.168.10.193:8080 + heartbeat_path: /api/agent/heartbeat + update_check_path: /api/agent/update-check + report_path: /api/agent/report + timeout_seconds: 10 + token: f47ac10b-58cc-4372-a567-0e02b2c3d479 + token_header: X-OTA-TOKEN + +registry: + enabled: true + server: 125.122.25.219:5000 + username: admin + password: "123456" + timeout_seconds: 30 + +compose: + working_dir: /home/liejiu/ota-agent/runtime + file: docker-compose.yml + env_file: ota-images.env + backup_env_file: ota-images.env.bak + pull_command: docker compose pull + up_command: docker compose up -d + health_check_seconds: 60 + healthcheck_interval_seconds: 3 + healthcheck_url: http://127.0.0.1:8011/actuator/health + +polling: + update_interval_seconds: 30 + heartbeat_interval_seconds: 30 + +storage: + state_file: /home/liejiu/ota-agent/runtime/state.json + manifest_dir: /home/liejiu/ota-agent/runtime/manifests + log_dir: /home/liejiu/ota-agent/runtime/logs + +mysql_backup: + enabled: true + backup_dir: /home/liejiu/ota-agent/runtime/mysql-backups + dump_command: docker exec mysql mysqldump -uroot -pnlrobot nl_frobot > {backup_file} + timeout_seconds: 120 diff --git a/dist/main.exe b/dist/main.exe new file mode 100644 index 0000000..bd35355 Binary files /dev/null and b/dist/main.exe differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..c03827d --- /dev/null +++ b/main.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import os + +import uvicorn + +from ota_agent.app import create_app +from ota_agent.config import load_config +from ota_agent.logging_utils import setup_logging + + +def main() -> None: + config_path = os.environ.get("OTA_AGENT_CONFIG", "config.yaml") + config = load_config(config_path) + setup_logging(config.storage.log_dir) + app = create_app(config) + uvicorn.run(app, host=config.server.host, port=config.server.port) + + +if __name__ == "__main__": + main() diff --git a/main.spec b/main.spec new file mode 100644 index 0000000..2ba8dd9 --- /dev/null +++ b/main.spec @@ -0,0 +1,38 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['main.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='main', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/ota_agent/__init__.py b/ota_agent/__init__.py new file mode 100644 index 0000000..df638a7 --- /dev/null +++ b/ota_agent/__init__.py @@ -0,0 +1 @@ +"""OTA Agent package.""" diff --git a/ota_agent/app.py b/ota_agent/app.py new file mode 100644 index 0000000..8a386fb --- /dev/null +++ b/ota_agent/app.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import asyncio +from contextlib import suppress +from pathlib import Path + +from fastapi import FastAPI, HTTPException + +from .cloud_client import CloudClient +from .compose_manager import ComposeManager +from .config import AgentConfig +from .manifest_store import ManifestStore +from .models import ( + AgentStatus, + ConfirmUpgradeRequest, + HeartbeatPayload, + LocalStatusResponse, + OperationResult, + PostponeUpgradeRequest, + ReportPayload, + UpdateCheckRequest, + utc_now, +) +from .mysql_backup import MysqlBackupManager +from .registry_login import RegistryLoginManager +from .state_store import StateStore + + +class AgentService: + def __init__(self, config: AgentConfig) -> None: + self.config = config + self.compose = ComposeManager(config.compose) + self.cloud = CloudClient(config.cloud) + self.state_store = StateStore(config.storage.state_file) + self.manifest_store = ManifestStore(config.storage.manifest_dir) + self.mysql_backup = MysqlBackupManager( + enabled=config.mysql_backup.enabled, + backup_dir=config.mysql_backup.backup_dir, + dump_command=config.mysql_backup.dump_command, + timeout_seconds=config.mysql_backup.timeout_seconds, + compose_manager=self.compose, + ) + self.registry_login = RegistryLoginManager( + enabled=config.registry.enabled, + server=config.registry.server, + username=config.registry.username, + password=config.registry.password, + timeout_seconds=config.registry.timeout_seconds, + compose_manager=self.compose, + ) + self.state = self.state_store.load( + vehicle_id=config.vehicle.vehicle_id, + vin=config.vehicle.vin, + current_release=config.vehicle.current_release, + ) + self.lock = asyncio.Lock() + self.upgrade_task: asyncio.Task[None] | None = None + self.background_tasks: list[asyncio.Task[None]] = [] + self.last_backup_file: str | None = None + self.last_target_release: str | None = None + self.last_images: dict[str, str] = {} + + async def startup(self) -> None: + self.background_tasks = [ + asyncio.create_task(self._heartbeat_loop(), name="heartbeat-loop"), + asyncio.create_task(self._update_loop(), name="update-loop"), + ] + + async def shutdown(self) -> None: + for task in self.background_tasks: + task.cancel() + for task in self.background_tasks: + with suppress(asyncio.CancelledError): + await task + + def get_local_status(self) -> LocalStatusResponse: + return LocalStatusResponse( + vehicle_id=self.state.vehicle_id, + vin=self.state.vin, + current_release=self.state.current_release, + status=self.state.status.value, + available_update=self.state.available_update, + last_result=self.state.last_result, + updated_at=self.state.updated_at, + ) + + async def postpone_upgrade(self, request: PostponeUpgradeRequest) -> OperationResult: + async with self.lock: + if not self.state.available_update: + return OperationResult(success=False, detail="当前没有可延期的升级任务") + self.state.status = AgentStatus.WAIT_USER_CONFIRM + self.state.last_result = f"用户稍后提醒: {request.reason}" + self._touch_and_save() + await self._safe_report(AgentStatus.WAIT_USER_CONFIRM, self.state.last_result) + return OperationResult(success=True, detail="已记录稍后提醒") + + async def confirm_upgrade(self, request: ConfirmUpgradeRequest) -> OperationResult: + async with self.lock: + if not self.state.available_update: + raise HTTPException(status_code=400, detail="当前没有可升级版本") + if self.upgrade_task and not self.upgrade_task.done(): + return OperationResult(success=False, detail="升级任务正在执行中") + self.state.last_result = f"用户已确认升级: {request.confirmed_by}" + self._touch_and_save() + self.upgrade_task = asyncio.create_task(self._execute_upgrade(), name="upgrade-task") + return OperationResult(success=True, detail="升级任务已启动") + + async def check_update_once(self) -> None: + async with self.lock: + payload = UpdateCheckRequest( + vehicle_id=self.state.vehicle_id, + vin=self.state.vin, + current_release=self.state.current_release, + ) + try: + response = await self.cloud.check_update(payload) + except Exception as exc: + async with self.lock: + self.state.last_check_at = utc_now() + self.state.last_result = f"检查更新失败: {exc}" + self._touch_and_save() + return + + async with self.lock: + self.state.last_check_at = utc_now() + if response.has_update and response.manifest: + self.manifest_store.save(response.manifest) + self.state.available_update = response.manifest + self.state.status = AgentStatus.WAIT_USER_CONFIRM + self.state.last_result = f"发现新版本 {response.manifest.release_version},等待人工确认" + else: + if self.state.status in {AgentStatus.IDLE, AgentStatus.SUCCESS, AgentStatus.WAIT_USER_CONFIRM}: + self.state.status = AgentStatus.IDLE + self.state.available_update = None + self.state.last_result = response.message or "当前无可用更新" + self._touch_and_save() + + async def heartbeat_once(self) -> None: + async with self.lock: + if self.state.status == AgentStatus.SUCCESS: + self.state.status = AgentStatus.IDLE + self.state.last_result = self.state.last_result or "升级成功,恢复空闲状态" + self._touch_and_save() + payload = HeartbeatPayload( + vehicle_id=self.state.vehicle_id, + vin=self.state.vin, + current_release=self.state.current_release, + agent_status=self.state.status.value, + target_release=self.last_target_release, + last_result=self.state.last_result, + images=self.last_images, + backup_file=self.last_backup_file, + updated_at=self.state.updated_at, + ) + try: + await self.cloud.heartbeat(payload) + async with self.lock: + self.state.last_heartbeat_at = utc_now() + self._touch_and_save() + except Exception as exc: + async with self.lock: + self.state.last_result = f"心跳上报失败: {exc}" + self._touch_and_save() + + async def _heartbeat_loop(self) -> None: + while True: + await self.heartbeat_once() + await asyncio.sleep(self.config.polling.heartbeat_interval_seconds) + + async def _update_loop(self) -> None: + while True: + await self.check_update_once() + await asyncio.sleep(self.config.polling.update_interval_seconds) + + async def _execute_upgrade(self) -> None: + async with self.lock: + manifest = self.state.available_update + if not manifest: + return + target_release = manifest.release_version + env_mapping = manifest.components.to_env_mapping() + self.last_target_release = target_release + self.last_images = dict(env_mapping) + self.last_backup_file = None + self.state.status = AgentStatus.BACKING_UP_DATABASE + self.state.last_result = f"开始升级到 {target_release},先执行数据库备份" + self._touch_and_save() + await self._safe_report(AgentStatus.BACKING_UP_DATABASE, f"开始升级到 {target_release},先执行数据库备份", target_release) + + try: + backup_result = await self.mysql_backup.backup_before_upgrade(target_release) + if not backup_result.success: + raise RuntimeError(f"数据库备份失败: {backup_result.stderr or backup_result.stdout}") + + backup_file_path = (backup_result.stdout or "").strip() + self.last_backup_file = backup_file_path or None + backup_file_name = Path(backup_file_path).name if backup_file_path else "" + backup_message = f"数据库备份完成,开始拉取镜像: {target_release}" + if backup_file_name: + backup_message = f"数据库备份完成({backup_file_name}),开始拉取镜像: {target_release}" + + async with self.lock: + self.state.status = AgentStatus.PULLING_IMAGE + self.state.last_result = backup_message + self._touch_and_save() + await self._safe_report(AgentStatus.PULLING_IMAGE, backup_message, target_release) + + login_result = await self.registry_login.login_if_needed() + if not login_result.success: + raise RuntimeError(f"私有仓库登录失败: {login_result.stderr or login_result.stdout}") + + await self.compose.apply_manifest(env_mapping) + pull_result = await self.compose.pull() + if not pull_result.success: + raise RuntimeError(f"拉取镜像失败: {pull_result.stderr or pull_result.stdout}") + + async with self.lock: + self.state.status = AgentStatus.RESTARTING_SERVICE + self.state.last_result = "镜像拉取完成,开始重启服务" + self._touch_and_save() + await self._safe_report(AgentStatus.RESTARTING_SERVICE, "镜像拉取完成,开始重启服务", target_release) + + up_result = await self.compose.up() + if not up_result.success: + raise RuntimeError(f"服务启动失败: {up_result.stderr or up_result.stdout}") + + async with self.lock: + self.state.status = AgentStatus.HEALTH_CHECKING + self.state.last_result = "服务已启动,开始健康检查" + self._touch_and_save() + await self._safe_report(AgentStatus.HEALTH_CHECKING, "服务已启动,开始健康检查", target_release) + + health = await self.compose.health_check() + if not health.success: + raise RuntimeError(health.detail) + + async with self.lock: + self.state.status = AgentStatus.SUCCESS + self.state.current_release = target_release + self.state.available_update = None + self.state.last_result = f"升级成功: {target_release}" + self._touch_and_save() + await self._safe_report(AgentStatus.SUCCESS, f"升级成功: {target_release}", target_release) + self.last_target_release = None + except Exception as exc: + await self.compose.rollback() + async with self.lock: + self.state.status = AgentStatus.ROLLED_BACK + self.state.last_result = f"升级失败并已回滚: {exc}" + self._touch_and_save() + await self._safe_report(AgentStatus.ROLLED_BACK, f"升级失败并已回滚: {exc}", target_release) + + async def _safe_report(self, status: AgentStatus, detail: str, release_version: str | None = None) -> None: + payload = ReportPayload( + vehicle_id=self.state.vehicle_id, + vin=self.state.vin, + current_release=self.state.current_release, + target_release=release_version or self.last_target_release, + agent_status=status.value, + success=status == AgentStatus.SUCCESS, + message=detail, + images=self.last_images, + backup_file=self.last_backup_file, + ) + with suppress(Exception): + await self.cloud.report(payload) + + def _touch_and_save(self) -> None: + self.state.updated_at = utc_now() + self.state_store.save(self.state) + + +def create_app(config: AgentConfig) -> FastAPI: + service = AgentService(config) + app = FastAPI(title="Vehicle OTA Agent", version="0.1.0") + + @app.on_event("startup") + async def on_startup() -> None: + await service.startup() + + @app.on_event("shutdown") + async def on_shutdown() -> None: + await service.shutdown() + + @app.get("/health") + async def health() -> dict[str, str]: + return {"status": "ok"} + + @app.get("/ota/status", response_model=LocalStatusResponse) + async def local_status() -> LocalStatusResponse: + return service.get_local_status() + + @app.post("/ota/check-update") + async def check_update() -> OperationResult: + await service.check_update_once() + return OperationResult(success=True, detail="已执行一次检查更新") + + @app.post("/ota/confirm", response_model=OperationResult) + async def confirm_upgrade(request: ConfirmUpgradeRequest) -> OperationResult: + return await service.confirm_upgrade(request) + + @app.post("/ota/postpone", response_model=OperationResult) + async def postpone_upgrade(request: PostponeUpgradeRequest) -> OperationResult: + return await service.postpone_upgrade(request) + + return app diff --git a/ota_agent/cloud_client.py b/ota_agent/cloud_client.py new file mode 100644 index 0000000..1121432 --- /dev/null +++ b/ota_agent/cloud_client.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import json + +import httpx + +from .config import CloudConfig +from .models import HeartbeatPayload, ReportPayload, UpdateCheckRequest, UpdateCheckResponse + + +class CloudClient: + def __init__(self, config: CloudConfig) -> None: + self.config = config + self.timeout = httpx.Timeout(config.timeout_seconds) + + def _headers(self) -> dict[str, str]: + return { + self.config.token_header: self.config.token, + "Content-Type": "application/json", + } + + async def heartbeat(self, payload: HeartbeatPayload) -> None: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + self.config.base_url.rstrip("/") + self.config.heartbeat_path, + headers=self._headers(), + json=payload.model_dump(by_alias=True), + ) + response.raise_for_status() + + async def check_update(self, payload: UpdateCheckRequest) -> UpdateCheckResponse: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + self.config.base_url.rstrip("/") + self.config.update_check_path, + headers=self._headers(), + json=payload.model_dump(by_alias=True), + ) + response.raise_for_status() + return UpdateCheckResponse(**response.json()) + + async def report(self, payload: ReportPayload) -> None: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + self.config.base_url.rstrip("/") + self.config.report_path, + headers=self._headers(), + json=payload.model_dump(by_alias=True), + ) + response.raise_for_status() \ No newline at end of file diff --git a/ota_agent/compose_manager.py b/ota_agent/compose_manager.py new file mode 100644 index 0000000..d4ecf59 --- /dev/null +++ b/ota_agent/compose_manager.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path + +import httpx + +from .config import ComposeConfig +from .models import CommandResult, HealthcheckResult + +logger = logging.getLogger(__name__) + + +class ComposeManager: + def __init__(self, config: ComposeConfig) -> None: + self.config = config + self.working_dir = Path(config.working_dir) + self.working_dir.mkdir(parents=True, exist_ok=True) + self.compose_file_path = self.working_dir / config.file + self.env_path = self.working_dir / config.env_file + self.backup_env_path = self.working_dir / config.backup_env_file + + async def apply_manifest(self, env_mapping: dict[str, str]) -> None: + current_text = self.env_path.read_text(encoding="utf-8") if self.env_path.exists() else "" + if self.env_path.exists(): + self.backup_env_path.write_text(current_text, encoding="utf-8") + logger.info("已备份镜像环境文件: %s", self.backup_env_path) + + env_lines = self._merge_env(current_text, env_mapping) + self.env_path.write_text("\n".join(env_lines) + "\n", encoding="utf-8") + logger.info("已写入新镜像配置到 %s", self.env_path) + + async def rollback(self) -> None: + if self.backup_env_path.exists(): + self.env_path.write_text(self.backup_env_path.read_text(encoding="utf-8"), encoding="utf-8") + logger.warning("升级失败,已恢复镜像环境文件: %s", self.env_path) + await self.pull() + await self.up() + + async def pull(self) -> CommandResult: + return await self.run_command(self._build_command(self.config.pull_command)) + + async def up(self) -> CommandResult: + return await self.run_command(self._build_command(self.config.up_command)) + + async def health_check(self) -> HealthcheckResult: + if not self.config.healthcheck_url: + return HealthcheckResult(success=True, detail="healthcheck skipped") + + deadline = asyncio.get_running_loop().time() + self.config.health_check_seconds + timeout = httpx.Timeout(min(self.config.healthcheck_interval_seconds, self.config.health_check_seconds)) + last_detail = "healthcheck not started" + async with httpx.AsyncClient(timeout=timeout) as client: + while True: + try: + response = await client.get(self.config.healthcheck_url) + if 200 <= response.status_code < 300: + logger.info("健康检查通过: %s", response.status_code) + return HealthcheckResult(success=True, detail=f"healthcheck ok: {response.status_code}") + last_detail = f"healthcheck failed: {response.status_code} {response.text}" + except Exception as exc: + last_detail = f"healthcheck error: {exc}" + + if asyncio.get_running_loop().time() >= deadline: + logger.error("健康检查失败: %s", last_detail) + return HealthcheckResult(success=False, detail=last_detail) + await asyncio.sleep(self.config.healthcheck_interval_seconds) + + async def run_command(self, command: list[str]) -> CommandResult: + logger.info("执行命令: %s", " ".join(command)) + process = await asyncio.create_subprocess_exec( + *command, + cwd=str(self.working_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + result = CommandResult( + success=process.returncode == 0, + stdout=stdout.decode("utf-8", errors="ignore"), + stderr=stderr.decode("utf-8", errors="ignore"), + returncode=process.returncode or 0, + ) + if result.success: + logger.info("命令执行成功(returncode=%s)", result.returncode) + else: + logger.error("命令执行失败(returncode=%s): %s", result.returncode, result.stderr or result.stdout) + return result + + def _build_command(self, command: str) -> list[str]: + parts = command.split() + if len(parts) >= 2 and parts[0] == "docker" and parts[1] == "compose": + return [ + "docker", + "compose", + "-f", + str(self.compose_file_path), + "--env-file", + str(self.env_path), + *parts[2:], + ] + return [*command.split()] + + def _merge_env(self, current_text: str, env_mapping: dict[str, str]) -> list[str]: + data: dict[str, str] = {} + order: list[str] = [] + for raw_line in current_text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + if key not in order: + order.append(key) + data[key] = value.strip() + + for key, value in env_mapping.items(): + if key not in order: + order.append(key) + data[key] = value + + return [f"{key}={data[key]}" for key in order] diff --git a/ota_agent/config.py b/ota_agent/config.py new file mode 100644 index 0000000..8647889 --- /dev/null +++ b/ota_agent/config.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class ServerConfig(BaseModel): + host: str = "0.0.0.0" + port: int = 19090 + + +class VehicleConfig(BaseModel): + vehicle_id: str = "vehicle-test-001" + vin: str = "vehicle-test-001" + current_release: str = "vehicle-release-0.0.1" + + +class CloudConfig(BaseModel): + base_url: str = "http://127.0.0.1:8080" + heartbeat_path: str = "/api/agent/heartbeat" + update_check_path: str = "/api/agent/update-check" + report_path: str = "/api/agent/report" + timeout_seconds: int = 10 + token: str = "change-me" + token_header: str = "X-OTA-TOKEN" + + +class RegistryConfig(BaseModel): + enabled: bool = False + server: str = "" + username: str = "" + password: str = "" + timeout_seconds: int = 30 + + +class ComposeConfig(BaseModel): + working_dir: str + file: str = "docker-compose.yml" + env_file: str = ".env" + backup_env_file: str = ".env.bak" + pull_command: str = "docker compose pull" + up_command: str = "docker compose up -d" + health_check_seconds: int = 15 + healthcheck_url: str | None = None + healthcheck_interval_seconds: int = 3 + + +class PollingConfig(BaseModel): + update_interval_seconds: int = 30 + heartbeat_interval_seconds: int = 30 + + +class StorageConfig(BaseModel): + state_file: str + manifest_dir: str + log_dir: str = "./runtime/logs" + + +class MysqlBackupConfig(BaseModel): + enabled: bool = False + backup_dir: str = "./runtime/mysql-backups" + dump_command: str = "docker exec mysql mysqldump -uroot -p123456 app_db > {backup_file}" + timeout_seconds: int = 120 + + +class AgentConfig(BaseSettings): + model_config = SettingsConfigDict(extra="ignore") + + server: ServerConfig = Field(default_factory=ServerConfig) + vehicle: VehicleConfig = Field(default_factory=VehicleConfig) + cloud: CloudConfig = Field(default_factory=CloudConfig) + registry: RegistryConfig = Field(default_factory=RegistryConfig) + compose: ComposeConfig = Field(default_factory=lambda: ComposeConfig(working_dir="./runtime")) + polling: PollingConfig = Field(default_factory=PollingConfig) + storage: StorageConfig = Field(default_factory=lambda: StorageConfig(state_file="./runtime/state.json", manifest_dir="./runtime/manifests", log_dir="./runtime/logs")) + mysql_backup: MysqlBackupConfig = Field(default_factory=MysqlBackupConfig) + + +def load_config(config_path: str | Path) -> AgentConfig: + path = Path(config_path) + raw: dict[str, Any] = {} + if path.exists(): + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return AgentConfig(**raw) diff --git a/ota_agent/logging_utils.py b/ota_agent/logging_utils.py new file mode 100644 index 0000000..9a03350 --- /dev/null +++ b/ota_agent/logging_utils.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path + + +def setup_logging(log_dir: str) -> None: + target_dir = Path(log_dir) + target_dir.mkdir(parents=True, exist_ok=True) + log_file = target_dir / "ota-agent.log" + + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) + + formatter = logging.Formatter( + "%(asctime)s [%(levelname)s] %(name)s - %(message)s" + ) + + file_handler = RotatingFileHandler( + log_file, + maxBytes=2 * 1024 * 1024, + backupCount=3, + encoding="utf-8", + ) + file_handler.setFormatter(formatter) + + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(formatter) + + root_logger.handlers.clear() + root_logger.addHandler(file_handler) + root_logger.addHandler(stream_handler) diff --git a/ota_agent/manifest_store.py b/ota_agent/manifest_store.py new file mode 100644 index 0000000..31d5345 --- /dev/null +++ b/ota_agent/manifest_store.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from .models import UpdateManifest + + +class ManifestStore: + def __init__(self, manifest_dir: str) -> None: + self.path = Path(manifest_dir) + self.path.mkdir(parents=True, exist_ok=True) + + def save(self, manifest: UpdateManifest) -> Path: + target = self.path / f"{manifest.release_version}.json" + target.write_text(json.dumps(manifest.model_dump(), ensure_ascii=False, indent=2), encoding="utf-8") + return target diff --git a/ota_agent/models.py b/ota_agent/models.py new file mode 100644 index 0000000..a508e28 --- /dev/null +++ b/ota_agent/models.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from enum import Enum + +from pydantic import BaseModel, Field, ConfigDict + + +def to_camel(value: str) -> str: + parts = value.split("_") + return parts[0] + "".join(part.capitalize() for part in parts[1:]) + + +class ApiModel(BaseModel): + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) + + +class AgentStatus(str, Enum): + IDLE = "IDLE" + HAS_UPDATE = "HAS_UPDATE" + WAIT_USER_CONFIRM = "WAIT_USER_CONFIRM" + BACKING_UP_DATABASE = "BACKING_UP_DATABASE" + PULLING_IMAGE = "PULLING_IMAGE" + RESTARTING_SERVICE = "RESTARTING_SERVICE" + HEALTH_CHECKING = "HEALTH_CHECKING" + SUCCESS = "SUCCESS" + FAILED = "FAILED" + ROLLED_BACK = "ROLLED_BACK" + + +class ComponentImages(ApiModel): + images: dict[str, str] = Field(default_factory=dict) + + def to_env_mapping(self) -> dict[str, str]: + return {key: value for key, value in self.images.items() if value} + + +class UpdateManifest(ApiModel): + release_version: str + release_notes: str = "" + components: ComponentImages = Field(default_factory=ComponentImages) + upgrade_mode: str = "manual_confirm" + + +class AgentState(BaseModel): + vehicle_id: str + vin: str + current_release: str + status: AgentStatus = AgentStatus.IDLE + available_update: UpdateManifest | None = None + last_check_at: str | None = None + last_heartbeat_at: str | None = None + last_result: str | None = None + updated_at: str = Field(default_factory=lambda: utc_now()) + + +class HeartbeatPayload(ApiModel): + vehicle_id: str + vin: str + current_release: str + agent_status: str + target_release: str | None = None + last_result: str | None = None + images: dict[str, str] = Field(default_factory=dict) + backup_file: str | None = None + updated_at: str + + +class UpdateCheckRequest(ApiModel): + vehicle_id: str + vin: str + current_release: str + + +class UpdateCheckResponse(ApiModel): + has_update: bool = False + manifest: UpdateManifest | None = None + message: str = "" + + +class ReportPayload(ApiModel): + vehicle_id: str + vin: str + current_release: str + target_release: str | None = None + agent_status: str + success: bool + message: str = "" + images: dict[str, str] = Field(default_factory=dict) + backup_file: str | None = None + updated_at: str = Field(default_factory=lambda: utc_now()) + + +class ConfirmUpgradeRequest(BaseModel): + confirmed_by: str = "android-app" + + +class PostponeUpgradeRequest(BaseModel): + reason: str = "user_postpone" + + +class LocalStatusResponse(BaseModel): + vehicle_id: str + vin: str + current_release: str + status: str + available_update: UpdateManifest | None = None + last_result: str | None = None + updated_at: str + + +class CommandResult(BaseModel): + success: bool + stdout: str = "" + stderr: str = "" + returncode: int = 0 + + +class HealthcheckResult(BaseModel): + success: bool + detail: str + + +class OperationResult(BaseModel): + success: bool + detail: str + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() diff --git a/ota_agent/mysql_backup.py b/ota_agent/mysql_backup.py new file mode 100644 index 0000000..5534702 --- /dev/null +++ b/ota_agent/mysql_backup.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime +from pathlib import Path + +from .compose_manager import ComposeManager +from .models import CommandResult + +logger = logging.getLogger(__name__) + + +class MysqlBackupManager: + def __init__(self, *, enabled: bool, backup_dir: str, dump_command: str, timeout_seconds: int, compose_manager: ComposeManager) -> None: + self.enabled = enabled + self.backup_dir = Path(backup_dir) + self.dump_command = dump_command + self.timeout_seconds = timeout_seconds + self.compose_manager = compose_manager + if self.enabled: + self.backup_dir.mkdir(parents=True, exist_ok=True) + + async def backup_before_upgrade(self, target_release: str) -> CommandResult: + if not self.enabled: + return CommandResult(success=True, stdout="mysql backup skipped", returncode=0) + + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + backup_file = self.backup_dir / f"mysql-backup-{target_release}-{timestamp}.sql" + command = self.dump_command.replace("{backup_file}", str(backup_file)) + logger.info("开始执行MySQL备份,目标文件: %s", backup_file) + result = await self._run_shell_command(command) + if result.success: + logger.info("MySQL备份完成: %s", backup_file) + result.stdout = str(backup_file) + else: + logger.error("MySQL备份失败: %s", result.stderr or result.stdout) + return result + + async def _run_shell_command(self, command: str) -> CommandResult: + process = await asyncio.create_subprocess_shell( + command, + cwd=str(self.compose_manager.working_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=self.timeout_seconds) + except asyncio.TimeoutError: + process.kill() + await process.wait() + return CommandResult(success=False, stderr=f"mysql backup timeout after {self.timeout_seconds}s", returncode=-1) + + return CommandResult( + success=process.returncode == 0, + stdout=stdout.decode("utf-8", errors="ignore"), + stderr=stderr.decode("utf-8", errors="ignore"), + returncode=process.returncode or 0, + ) diff --git a/ota_agent/registry_login.py b/ota_agent/registry_login.py new file mode 100644 index 0000000..117f444 --- /dev/null +++ b/ota_agent/registry_login.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import asyncio +import logging + +from .compose_manager import ComposeManager +from .models import CommandResult + +logger = logging.getLogger(__name__) + + +class RegistryLoginManager: + def __init__( + self, + *, + enabled: bool, + server: str, + username: str, + password: str, + timeout_seconds: int, + compose_manager: ComposeManager, + ) -> None: + self.enabled = enabled + self.server = server + self.username = username + self.password = password + self.timeout_seconds = timeout_seconds + self.compose_manager = compose_manager + + async def login_if_needed(self) -> CommandResult: + if not self.enabled: + return CommandResult(success=True, stdout="registry login skipped", returncode=0) + + command = [ + "docker", + "login", + self.server, + "-u", + self.username, + "-p", + self.password, + ] + logger.info("开始执行私有仓库登录: %s", self.server) + result = await self._run_command(command) + if result.success: + logger.info("私有仓库登录成功: %s", self.server) + else: + logger.error("私有仓库登录失败: %s", result.stderr or result.stdout) + return result + + async def _run_command(self, command: list[str]) -> CommandResult: + process = await asyncio.create_subprocess_exec( + *command, + cwd=str(self.compose_manager.working_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=self.timeout_seconds) + except asyncio.TimeoutError: + process.kill() + await process.wait() + return CommandResult(success=False, stderr=f"registry login timeout after {self.timeout_seconds}s", returncode=-1) + + return CommandResult( + success=process.returncode == 0, + stdout=stdout.decode("utf-8", errors="ignore"), + stderr=stderr.decode("utf-8", errors="ignore"), + returncode=process.returncode or 0, + ) diff --git a/ota_agent/state_store.py b/ota_agent/state_store.py new file mode 100644 index 0000000..4c4be40 --- /dev/null +++ b/ota_agent/state_store.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from .models import AgentState + + +class StateStore: + def __init__(self, state_file: str) -> None: + self.path = Path(state_file) + self.path.parent.mkdir(parents=True, exist_ok=True) + + def load(self, vehicle_id: str, vin: str, current_release: str) -> AgentState: + if not self.path.exists() or self._is_empty_file(): + state = AgentState(vehicle_id=vehicle_id, vin=vin, current_release=current_release) + self.save(state) + return state + + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + state = AgentState(vehicle_id=vehicle_id, vin=vin, current_release=current_release) + self.save(state) + return state + + raw["vehicle_id"] = vehicle_id + raw["vin"] = vin + raw["current_release"] = current_release + return AgentState(**raw) + + def save(self, state: AgentState) -> None: + self.path.write_text( + state.model_dump_json(indent=2), + encoding="utf-8", + ) + + def _is_empty_file(self) -> bool: + return self.path.stat().st_size == 0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5228e58 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +httpx==0.28.1 +pydantic==2.11.7 +pydantic-settings==2.10.1 +PyYAML==6.0.2 diff --git a/runtime/README.txt b/runtime/README.txt new file mode 100644 index 0000000..0c152c7 --- /dev/null +++ b/runtime/README.txt @@ -0,0 +1,8 @@ +# 运行目录 + +该目录用于放置车端 `docker compose` 编排文件、`.env` 版本文件、状态文件和 manifest 缓存。 + +建议: +- 将真实业务的 `docker-compose.yml` 放到这里 +- 将镜像 tag 写入 `.env` +- 让 OTA Agent 只修改 `.env` 并执行 `docker compose pull/up` diff --git a/runtime/docker-compose.yml b/runtime/docker-compose.yml new file mode 100644 index 0000000..4476c50 --- /dev/null +++ b/runtime/docker-compose.yml @@ -0,0 +1,75 @@ +version: '3.8' + +services: + mysql: + image: ${MYSQL_IMAGE} + container_name: mysql + restart: always + environment: + MYSQL_ROOT_PASSWORD: "123456" + MYSQL_DATABASE: nl_frobt + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./mysql/:/docker-entrypoint-initdb.d/ + networks: + - app-network + + redis: + image: ${REDIS_IMAGE} + container_name: redis + restart: always + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - app-network + command: redis-server --requirepass 123456 + + backend: + image: ${BACKEND_IMAGE} + container_name: backend + restart: always + environment: + SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_URL: jdbc:mysql://mysql:3306/nl_frobt?serverTimezone=GMT%2B8&characterEncoding=utf-8&userSSL=false + SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_USERNAME: root + SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_PASSWORD: "123456" + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: 6379 + SPRING_DATA_REDIS_PASSWORD: "123456" + SA-TOKEN_ALONE-REDIS_HOST: redis + SA-TOKEN_ALONE-REDIS_PORT: 6379 + SA-TOKEN_ALONE-REDIS_PASSWORD: "123456" + ports: + - "8011:8011" + depends_on: + - mysql + - redis + volumes: + - /opt/ota-agent/backend-logs:/app/logs + - /opt/ota-agent/backend-data/file:/app/data/file + - /opt/ota-agent/backend-data/qrcode:/app/data/qrcode + - /opt/ota-agent/backend-data/avatar:/app/data/avatar + networks: + - app-network + + frontend: + image: ${FRONTEND_IMAGE} + container_name: frontend + restart: always + ports: + - "8013:8013" + depends_on: + - backend + networks: + - app-network + +networks: + app-network: + driver: bridge + +volumes: + mysql_data: + redis_data: \ No newline at end of file diff --git a/runtime/logs/ota-agent.log b/runtime/logs/ota-agent.log new file mode 100644 index 0000000..a8d2791 --- /dev/null +++ b/runtime/logs/ota-agent.log @@ -0,0 +1,332 @@ +2026-04-17 17:39:50,251 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:39:50,256 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:39:50,270 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:39:50,271 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=WAIT_USER_CONFIRM +2026-04-17 17:40:20,570 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:40:20,571 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:40:20,587 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:40:20,589 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:40:50,940 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:40:50,942 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:40:50,957 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:40:50,959 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:41:21,388 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:41:21,389 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:41:21,394 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:41:21,395 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:41:51,735 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:41:51,736 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:41:51,744 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:41:51,745 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:42:22,048 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:42:22,049 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:42:22,069 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:42:22,070 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:42:52,361 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:42:52,362 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:42:52,377 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:42:52,378 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:43:22,672 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:43:22,673 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:43:22,727 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:43:22,728 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:43:52,981 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:43:52,982 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:43:53,014 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:43:53,017 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:44:23,320 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:44:23,321 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:44:23,351 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:44:23,353 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:44:53,632 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:44:53,633 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:44:53,669 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:44:53,671 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:45:23,968 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:45:23,969 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:45:24,112 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:45:24,115 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:45:54,324 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:45:54,325 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:45:54,361 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:45:54,362 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:46:24,662 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:46:24,663 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:46:24,697 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:46:24,699 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:46:54,979 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:46:54,980 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:46:54,990 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:46:54,992 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:47:25,309 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:47:25,310 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:47:25,344 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:47:25,345 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:47:55,616 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:47:55,617 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:47:55,657 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:47:55,659 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:48:25,954 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:48:25,955 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:48:25,990 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:48:25,991 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:48:56,291 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:48:56,292 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:48:56,327 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:48:56,328 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:49:27,215 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:49:27,218 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:49:27,253 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:49:27,257 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:49:58,219 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:49:58,221 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:49:58,264 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:49:58,267 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:50:29,218 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:50:29,220 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:50:29,254 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:50:29,258 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:51:00,200 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:51:00,202 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:51:00,214 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:51:00,216 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:51:30,530 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:51:30,532 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:51:30,567 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:51:30,569 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:52:00,855 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:52:00,857 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:52:00,892 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:52:00,893 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:52:31,205 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:52:31,207 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:52:31,241 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:52:31,242 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:53:01,574 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:53:01,575 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:53:01,580 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:53:01,582 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:53:31,907 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:53:31,908 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:53:31,942 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:53:31,943 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:54:02,216 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:54:02,217 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:54:02,254 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:54:02,257 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:54:32,577 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:54:32,578 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:54:32,611 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:54:32,613 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:55:02,903 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:55:02,904 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:55:02,939 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:55:02,943 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:55:33,231 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:55:33,235 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:55:33,443 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:55:33,446 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:56:03,414 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:56:03,415 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:56:03,676 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:56:03,677 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:56:33,581 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:56:33,582 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:56:33,915 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:56:33,918 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:57:03,780 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:57:03,781 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:57:04,130 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:57:04,132 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:57:33,965 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:57:33,966 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:57:34,337 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:57:34,340 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:58:04,151 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:58:04,152 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:58:04,553 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:58:04,556 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:58:34,367 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:58:34,369 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:58:34,786 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:58:34,788 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:59:04,541 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:59:04,542 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:59:04,993 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:59:04,996 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 17:59:34,708 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 17:59:34,709 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 17:59:35,226 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 17:59:35,227 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:00:04,922 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:00:04,923 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 18:00:05,426 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:00:05,428 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:00:35,091 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:00:35,092 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 18:00:35,631 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:00:35,633 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:01:05,266 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:01:05,268 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 18:01:05,843 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:01:05,844 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:01:35,462 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:01:35,464 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 18:01:36,023 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:01:36,024 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:02:05,659 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:02:05,660 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 18:02:06,257 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:02:06,258 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:02:36,083 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:02:36,086 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 18:02:36,704 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:02:36,706 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:03:06,474 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:03:06,477 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 18:03:07,316 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:03:07,318 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:03:36,970 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:03:36,972 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 18:03:37,848 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:03:37,851 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:04:07,434 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:04:07,437 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False +2026-04-17 18:04:08,355 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:04:08,359 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:06:10,423 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:06:10,424 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:06:10,462 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:06:10,464 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:06:41,441 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:06:41,444 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:06:41,454 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:06:41,457 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:07:12,398 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:07:12,401 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:07:12,435 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:07:12,438 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:07:42,804 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:07:42,806 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:07:42,814 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:07:42,815 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:08:13,246 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:08:13,247 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:08:13,283 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:08:13,286 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:08:43,665 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:08:43,666 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:08:43,703 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:08:43,706 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:09:14,117 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:09:14,118 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:09:14,153 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:09:14,155 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:09:44,542 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:09:44,543 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:09:44,579 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:09:44,582 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:10:14,950 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:10:14,952 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:10:14,990 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:10:14,992 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:10:45,396 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:10:45,397 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:10:45,433 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:10:45,436 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:11:15,805 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:11:15,807 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:11:15,841 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:11:15,843 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:11:46,221 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:11:46,222 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:11:46,259 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:11:46,262 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:12:16,629 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:12:16,630 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:12:16,669 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:12:16,672 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:12:47,045 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:12:47,047 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:12:47,083 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:12:47,086 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:13:17,487 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:13:17,489 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:13:17,528 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:13:17,530 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:13:47,917 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:13:47,919 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:13:47,966 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:13:47,969 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:14:18,342 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:14:18,344 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:14:18,378 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:14:18,381 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:14:48,786 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:14:48,788 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:14:48,823 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:14:48,825 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:15:19,209 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:15:19,210 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:15:19,245 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:15:19,247 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:15:49,791 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:15:49,793 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:15:49,826 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:15:49,828 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:16:20,746 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:16:20,749 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:16:20,754 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:16:20,756 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:16:51,269 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:16:51,271 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:16:51,303 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:16:51,305 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:17:21,739 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:17:21,742 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:17:21,773 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:17:21,776 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:17:52,148 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:17:52,149 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:17:52,158 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:17:52,159 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:18:22,583 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:18:22,584 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:18:22,593 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:18:22,594 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:18:53,084 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:18:53,086 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:18:53,125 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:18:53,127 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:19:23,866 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:19:23,869 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:19:23,902 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:19:23,905 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:19:54,383 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:19:54,385 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:19:54,420 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:19:54,422 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:20:24,798 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:20:24,799 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:20:24,834 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:20:24,836 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:20:55,273 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:20:55,274 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:20:55,309 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:20:55,311 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:21:25,687 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:21:25,689 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:21:25,725 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:21:25,728 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:21:56,168 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:21:56,170 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:21:56,180 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:21:56,181 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-17 18:22:26,582 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 200 " +2026-04-17 18:22:26,584 [INFO] ota_agent.cloud_client - check update vehicleId=vehicle-test-001 hasUpdate=False message=No release assigned +2026-04-17 18:22:26,622 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 200 " +2026-04-17 18:22:26,625 [INFO] ota_agent.cloud_client - heartbeat ok vehicleId=vehicle-test-001 status=IDLE +2026-04-21 17:01:52,646 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 401 " +2026-04-21 17:01:52,647 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 401 " +2026-04-21 21:10:57,935 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/update-check "HTTP/1.1 401 " +2026-04-21 21:10:57,936 [INFO] httpx - HTTP Request: POST http://127.0.0.1:8080/api/agent/heartbeat "HTTP/1.1 401 " diff --git a/runtime/manifests/vehicle-release-0.0.2.json b/runtime/manifests/vehicle-release-0.0.2.json new file mode 100644 index 0000000..f0d4391 --- /dev/null +++ b/runtime/manifests/vehicle-release-0.0.2.json @@ -0,0 +1,12 @@ +{ + "release_version": "vehicle-release-0.0.2", + "release_notes": "1. Spring Boot 后端升级到 v0.0.2\n2. 两个 Vue 前端升级到 v0.0.2\n3. 两个 ROS2 程序升级到 v0.0.2\n", + "components": { + "backend": "repo/backend:v0.0.2", + "frontend1": "repo/frontend1:v0.0.2", + "frontend2": "repo/frontend2:v0.0.2", + "ros2node1": "repo/ros2node1:v0.0.2", + "ros2node2": "repo/ros2node2:v0.0.2" + }, + "upgrade_mode": "manual_confirm" +} \ No newline at end of file diff --git a/runtime/ota-images.env b/runtime/ota-images.env new file mode 100644 index 0000000..fa41ec7 --- /dev/null +++ b/runtime/ota-images.env @@ -0,0 +1,4 @@ +MYSQL_IMAGE=125.122.25.219:5000/ota/mysql:8.0 +REDIS_IMAGE=125.122.25.219:5000/ota/redis:7-alpine +BACKEND_IMAGE=125.122.25.219:5000/ota/backend:v1.0.1 +FRONTEND_IMAGE=125.122.25.219:5000/ota/frontend:v1.0.2 \ No newline at end of file diff --git a/runtime/state.json b/runtime/state.json new file mode 100644 index 0000000..37ee5a6 --- /dev/null +++ b/runtime/state.json @@ -0,0 +1,11 @@ +{ + "vehicle_id": "vehicle-test-001", + "vin": "vehicle-test-001", + "current_release": "vehicle-release-0.0.1", + "status": "IDLE", + "available_update": null, + "last_check_at": "2026-04-21T13:10:57.939089+00:00", + "last_heartbeat_at": "2026-04-17T10:22:26.626688+00:00", + "last_result": "心跳上报失败: Client error '401 ' for url 'http://127.0.0.1:8080/api/agent/heartbeat'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401", + "updated_at": "2026-04-21T13:10:57.940167+00:00" +} \ No newline at end of file