| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| |
| import logging |
| import os |
| import threading |
| import time |
| from contextlib import contextmanager |
| from typing import Any, Dict, Iterator, List, Optional |
| |
| from .constants import DEFAULT_IDLE_TIMEOUT_SECONDS |
| |
| |
| def parse_idle_timeout_seconds(obj: dict) -> int: |
| """Seconds of idle time (no completed HTTP requests) before unregister.""" |
| v = obj.get("idle_timeout_seconds", DEFAULT_IDLE_TIMEOUT_SECONDS) |
| if not isinstance(v, int): |
| raise ValueError("idle_timeout_seconds must be an integer") |
| v = int(v) |
| if v < 1: |
| v = 86400 |
| return v |
| |
| |
| def validate_transfer_config(obj: dict) -> dict: |
| """ |
| Validate and normalize a transfer config dict received over the control |
| socket. Returns the cleaned config or raises ValueError. |
| """ |
| idle_sec = parse_idle_timeout_seconds(obj) |
| |
| backend = obj.get("backend") |
| if backend is None: |
| backend = "nbd" |
| if not isinstance(backend, str): |
| raise ValueError("invalid backend type") |
| backend = backend.lower() |
| if backend not in ("nbd", "file"): |
| raise ValueError(f"unsupported backend: {backend}") |
| |
| if backend == "file": |
| file_path = obj.get("file") |
| if not isinstance(file_path, str) or not file_path.strip(): |
| raise ValueError("missing/invalid file path for file backend") |
| return {"backend": "file", "file": file_path.strip(), "idle_timeout_seconds": idle_sec} |
| |
| socket_path = obj.get("socket") |
| export = obj.get("export") |
| export_bitmap = obj.get("export_bitmap") |
| if not isinstance(socket_path, str) or not socket_path.strip(): |
| raise ValueError("missing/invalid socket path for nbd backend") |
| if export is not None and (not isinstance(export, str) or not export): |
| raise ValueError("invalid export name") |
| return { |
| "backend": "nbd", |
| "socket": socket_path.strip(), |
| "export": export, |
| "export_bitmap": export_bitmap, |
| "idle_timeout_seconds": idle_sec, |
| } |
| |
| |
| def safe_transfer_id(image_id: str) -> Optional[str]: |
| """ |
| Only allow a single filename component to avoid path traversal. |
| Rejects anything containing '/' or '\\'. |
| """ |
| if not image_id: |
| return None |
| if image_id != os.path.basename(image_id): |
| return None |
| if "/" in image_id or "\\" in image_id: |
| return None |
| if image_id in (".", ".."): |
| return None |
| return image_id |
| |
| |
| class TransferRegistry: |
| """ |
| Thread-safe in-memory registry for active image transfer configurations. |
| |
| The cloudstack-agent registers/unregisters transfers via the Unix domain |
| control socket. The HTTP handler looks up configs through get(). |
| |
| Each transfer may specify idle_timeout_seconds (default DEFAULT_IDLE_TIMEOUT_SECONDS). |
| After no in-flight HTTP requests have completed for that idle period, the transfer |
| is removed (same effect as unregister). |
| """ |
| |
| def __init__(self) -> None: |
| self._lock = threading.Lock() |
| self._cv = threading.Condition(self._lock) |
| self._transfers: Dict[str, Dict[str, Any]] = {} |
| self._last_activity: Dict[str, float] = {} |
| self._inflight: Dict[str, int] = {} |
| |
| def register(self, transfer_id: str, config: Dict[str, Any]) -> bool: |
| safe_id = safe_transfer_id(transfer_id) |
| if safe_id is None: |
| logging.error("register rejected invalid transfer_id=%r", transfer_id) |
| return False |
| with self._lock: |
| self._transfers[safe_id] = config |
| self._last_activity[safe_id] = time.monotonic() |
| self._inflight.pop(safe_id, None) |
| logging.info("registered transfer_id=%s active=%d", safe_id, len(self._transfers)) |
| return True |
| |
| def unregister(self, transfer_id: str) -> int: |
| """Remove a transfer and return the number of remaining active transfers.""" |
| safe_id = safe_transfer_id(transfer_id) |
| if safe_id is None: |
| logging.error("unregister rejected invalid transfer_id=%r", transfer_id) |
| with self._lock: |
| return len(self._transfers) |
| with self._cv: |
| while self._inflight.get(safe_id, 0) > 0: |
| self._cv.wait() |
| self._transfers.pop(safe_id, None) |
| self._last_activity.pop(safe_id, None) |
| self._inflight.pop(safe_id, None) |
| remaining = len(self._transfers) |
| logging.info("unregistered transfer_id=%s active=%d", safe_id, remaining) |
| return remaining |
| |
| def get(self, transfer_id: str) -> Optional[Dict[str, Any]]: |
| safe_id = safe_transfer_id(transfer_id) |
| if safe_id is None: |
| return None |
| with self._lock: |
| return self._transfers.get(safe_id) |
| |
| def active_count(self) -> int: |
| with self._lock: |
| return len(self._transfers) |
| |
| @contextmanager |
| def request_lifecycle(self, transfer_id: str) -> Iterator[None]: |
| """ |
| Track an HTTP request for idle-timeout purposes. |
| |
| Expiry is based on time since the last request *completed* (all in-flight |
| work for this transfer_id finished). Transfers with active requests are |
| never expired. |
| """ |
| safe_id = safe_transfer_id(transfer_id) |
| if safe_id is None: |
| yield |
| return |
| with self._lock: |
| if safe_id not in self._transfers: |
| yield |
| return |
| self._inflight[safe_id] = self._inflight.get(safe_id, 0) + 1 |
| try: |
| yield |
| finally: |
| now = time.monotonic() |
| with self._cv: |
| count = self._inflight.get(safe_id, 1) - 1 |
| if count <= 0: |
| self._inflight.pop(safe_id, None) |
| if safe_id in self._transfers: |
| self._last_activity[safe_id] = now |
| self._cv.notify_all() |
| else: |
| self._inflight[safe_id] = count |
| |
| def sweep_expired_transfers(self) -> None: |
| """Remove transfers that exceeded idle_timeout_seconds with no in-flight HTTP work.""" |
| now = time.monotonic() |
| with self._lock: |
| expired: List[str] = [] |
| for tid, cfg in list(self._transfers.items()): |
| if self._inflight.get(tid, 0) > 0: |
| continue |
| timeout = int(cfg.get("idle_timeout_seconds", DEFAULT_IDLE_TIMEOUT_SECONDS)) |
| last = self._last_activity.get(tid, now) |
| if now - last >= timeout: |
| expired.append(tid) |
| for tid in expired: |
| self._transfers.pop(tid, None) |
| self._last_activity.pop(tid, None) |
| self._inflight.pop(tid, None) |
| logging.info( |
| "idle expiry: unregistered transfer_id=%s active=%d", |
| tid, |
| len(self._transfers), |
| ) |