mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
- Change initial API version from 1.40 to 1.44 to support Docker 29.3+ (API 1.54) - Add automatic fallback to API 1.40 for older Docker versions (20.10, API 1.41) - Fixes "client version 1.40 is too old. Minimum supported API version is 1.44" error - Ensures compatibility across Docker versions 20.10 (API 1.41) to 29.3+ (API 1.54+) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
292 lines
11 KiB
Python
292 lines
11 KiB
Python
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
#
|
|
# Copyright (C) 2026 YueGuobin
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
"""
|
|
Web Wireshark - Docker HTTP API client
|
|
|
|
This module handles Docker API operations via aiohttp Unix socket.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
import aiohttp
|
|
|
|
from gns3server.utils import parse_version
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Docker API configuration
|
|
DOCKER_SOCKET = "/var/run/docker.sock"
|
|
DOCKER_MINIMUM_API_VERSION = "1.40"
|
|
DOCKER_PREFERRED_API_VERSION = "1.44"
|
|
|
|
|
|
class DockerHTTPClient:
|
|
"""Docker HTTP API client using aiohttp with Unix socket connection."""
|
|
|
|
# API request timeout (seconds)
|
|
REQUEST_TIMEOUT = 10
|
|
|
|
def __init__(self):
|
|
self._connector = None
|
|
self._session = None
|
|
self._connected = False
|
|
# Start with preferred API version (1.44) to support newer Docker
|
|
# Will fallback to minimum version (1.40) if server doesn't support it
|
|
self._api_version = DOCKER_PREFERRED_API_VERSION
|
|
|
|
async def _get_connector(self):
|
|
"""Get or create Unix socket connector."""
|
|
if self._connector is None or self._connector.closed:
|
|
try:
|
|
self._connector = aiohttp.connector.UnixConnector(DOCKER_SOCKET, limit=None)
|
|
except (aiohttp.ClientError, FileNotFoundError):
|
|
raise RuntimeError(f"Can't connect to Docker daemon at {DOCKER_SOCKET}")
|
|
return self._connector
|
|
|
|
async def _get_session(self):
|
|
"""Get or create aiohttp session."""
|
|
if self._session is None or self._session.closed:
|
|
connector = await self._get_connector()
|
|
self._session = aiohttp.ClientSession(connector=connector)
|
|
return self._session
|
|
|
|
async def close(self):
|
|
"""Close connections."""
|
|
if self._session and not self._session.closed:
|
|
await self._session.close()
|
|
if self._connector and not self._connector.closed:
|
|
await self._connector.close()
|
|
self._connected = False
|
|
|
|
async def _check_connection(self):
|
|
"""Check Docker connection and detect API version."""
|
|
if not self._connected:
|
|
try:
|
|
# Get Docker version info
|
|
docker_info = await self._request("GET", "version", check_connection=False)
|
|
self._connected = True
|
|
|
|
# Parse API version
|
|
api_version = parse_version(docker_info['ApiVersion'])
|
|
docker_version = docker_info["Version"]
|
|
|
|
logger.info(f"Connected to Docker {docker_version}, API {api_version}")
|
|
|
|
# Check minimum version requirement
|
|
if api_version < parse_version(DOCKER_MINIMUM_API_VERSION):
|
|
raise RuntimeError(
|
|
f"Docker version is {docker_version}. "
|
|
f"GNS3 requires a minimum API version of {DOCKER_MINIMUM_API_VERSION}"
|
|
)
|
|
|
|
# Use preferred API version if supported
|
|
preferred_api_version = parse_version(DOCKER_PREFERRED_API_VERSION)
|
|
if api_version >= preferred_api_version:
|
|
self._api_version = DOCKER_PREFERRED_API_VERSION
|
|
logger.info(f"Using Docker API version {self._api_version}")
|
|
else:
|
|
# Use Docker daemon's actual API version
|
|
self._api_version = docker_info['ApiVersion']
|
|
logger.info(f"Using Docker API version {self._api_version} (daemon native)")
|
|
|
|
except (aiohttp.ClientError, FileNotFoundError) as e:
|
|
self._connected = False
|
|
raise RuntimeError(f"Can't connect to Docker daemon: {e}") from e
|
|
except KeyError as e:
|
|
raise RuntimeError(f"Unexpected Docker API response: missing {e}") from e
|
|
|
|
async def _request(self, method: str, endpoint: str, **kwargs):
|
|
"""
|
|
Send request to Docker API.
|
|
|
|
Args:
|
|
method: HTTP method
|
|
endpoint: API endpoint (without version prefix)
|
|
**kwargs: Other parameters for aiohttp.request
|
|
|
|
Returns:
|
|
Response JSON data
|
|
"""
|
|
# Check connection and version on first request
|
|
check_connection = kwargs.pop('check_connection', True)
|
|
if check_connection and not self._connected:
|
|
await self._check_connection()
|
|
|
|
session = await self._get_session()
|
|
url = f"http://docker/v{self._api_version}/{endpoint}"
|
|
|
|
try:
|
|
async with asyncio.timeout(self.REQUEST_TIMEOUT):
|
|
async with session.request(method, url, **kwargs) as response:
|
|
if response.status >= 300:
|
|
error_text = await response.text()
|
|
raise RuntimeError(f"Docker API error {response.status}: {error_text}")
|
|
if response.status == 204: # No Content
|
|
return None
|
|
return await response.json()
|
|
except asyncio.TimeoutError:
|
|
raise RuntimeError(f"Docker API timeout after {self.REQUEST_TIMEOUT}s for {endpoint}")
|
|
except aiohttp.ClientError as e:
|
|
raise RuntimeError(f"Docker connection error: {e}") from e
|
|
except RuntimeError as e:
|
|
# Retry with lower API version if Docker daemon doesn't support current version
|
|
error_msg = str(e)
|
|
if ("400" in error_msg or "not found" in error_msg.lower()) and self._api_version == DOCKER_PREFERRED_API_VERSION:
|
|
logger.warning(f"Docker daemon doesn't support API version {self._api_version}, falling back to {DOCKER_MINIMUM_API_VERSION}")
|
|
self._api_version = DOCKER_MINIMUM_API_VERSION
|
|
return await self._request(method, endpoint, check_connection=False, **kwargs)
|
|
raise
|
|
|
|
async def create_network(self, name: str, driver: str = "bridge", subnet: str = None):
|
|
"""Create Docker network."""
|
|
data = {
|
|
"Name": name,
|
|
"Driver": driver
|
|
}
|
|
if subnet:
|
|
data["IPAM"] = {
|
|
"Config": [{"Subnet": subnet}]
|
|
}
|
|
await self._request("POST", "networks/create", json=data)
|
|
|
|
async def get_network(self, name: str):
|
|
"""Get network info."""
|
|
try:
|
|
return await self._request("GET", f"networks/{name}")
|
|
except RuntimeError as e:
|
|
if "404" in str(e):
|
|
return None
|
|
raise
|
|
|
|
async def create_container(self, name: str, image: str, **kwargs):
|
|
"""Create container."""
|
|
data = {
|
|
"Image": image,
|
|
"name": name,
|
|
"HostConfig": {},
|
|
"NetworkingConfig": {}
|
|
}
|
|
|
|
# Handle network config
|
|
if "network" in kwargs:
|
|
network_name = kwargs.pop("network")
|
|
data["NetworkingConfig"]["EndpointsConfig"] = {
|
|
network_name: {}
|
|
}
|
|
|
|
# Handle environment variables
|
|
if "environment" in kwargs:
|
|
data["Env"] = [f"{k}={v}" for k, v in kwargs.pop("environment").items()]
|
|
|
|
# Handle resource limits
|
|
if "host_config" in kwargs:
|
|
data["HostConfig"].update(kwargs.pop("host_config"))
|
|
else:
|
|
# Legacy parameter format
|
|
host_config = {}
|
|
if "mem_limit" in kwargs:
|
|
host_config["Memory"] = kwargs.pop("mem_limit")
|
|
if "cpu_quota" in kwargs:
|
|
host_config["NanoCpus"] = kwargs.pop("cpu_quota")
|
|
if "pids_limit" in kwargs:
|
|
host_config["PidsLimit"] = kwargs.pop("pids_limit")
|
|
if "restart_policy" in kwargs:
|
|
host_config["RestartPolicy"] = kwargs.pop("restart_policy")
|
|
data["HostConfig"].update(host_config)
|
|
|
|
# Handle health check
|
|
if "health_config" in kwargs:
|
|
data["HealthCheck"] = kwargs.pop("health_config")
|
|
|
|
# Create container
|
|
result = await self._request("POST", "containers/create", params={"name": name}, json=data)
|
|
return result["Id"]
|
|
|
|
async def start_container(self, container_id: str):
|
|
"""Start container."""
|
|
await self._request("POST", f"containers/{container_id}/start")
|
|
|
|
async def get_container(self, name: str):
|
|
"""Get container info."""
|
|
try:
|
|
return await self._request("GET", f"containers/{name}/json")
|
|
except RuntimeError as e:
|
|
if "404" in str(e):
|
|
return None
|
|
raise
|
|
|
|
async def stop_container(self, container_id: str, timeout: int = 0):
|
|
"""Stop container immediately (force kill, no graceful shutdown)."""
|
|
await self._request("POST", f"containers/{container_id}/stop", params={"t": timeout})
|
|
|
|
async def remove_container(self, container_id: str, force: bool = False):
|
|
"""Remove container."""
|
|
params = {"force": "true"} if force else {}
|
|
await self._request("DELETE", f"containers/{container_id}", params=params)
|
|
|
|
async def list_processes(self, container_name: str) -> list:
|
|
"""Get process list from container.
|
|
|
|
Args:
|
|
container_name: Container name (e.g., "gns3-wireshark-xxx")
|
|
|
|
Returns:
|
|
List of process dicts with keys: PID, USER, COMMAND, etc.
|
|
"""
|
|
if not self._connected:
|
|
await self._check_connection()
|
|
|
|
session = await self._get_session()
|
|
url = f"http://docker/v{self._api_version}/containers/{container_name}/top?ps_args=aux"
|
|
|
|
try:
|
|
async with asyncio.timeout(self.REQUEST_TIMEOUT):
|
|
async with session.get(url) as response:
|
|
if response.status >= 300:
|
|
error_text = await response.text()
|
|
raise RuntimeError(f"Docker API error {response.status}: {error_text}")
|
|
result = await response.json()
|
|
except asyncio.TimeoutError:
|
|
raise RuntimeError(f"Docker API timeout after {self.REQUEST_TIMEOUT}s for containers/{container_name}/top")
|
|
except aiohttp.ClientError as e:
|
|
raise RuntimeError(f"Docker connection error: {e}") from e
|
|
|
|
# Docker API returns JSON format:
|
|
# {"Processes": [["yueguob+", "259847", ...], [...]], "Titles": ["USER", "PID", "%CPU", ...]}
|
|
# Headers are in "Titles" key, data rows are in "Processes"
|
|
|
|
headers = result.get("Titles", [])
|
|
processes_data = result.get("Processes", [])
|
|
if not headers or not processes_data:
|
|
return []
|
|
|
|
processes = []
|
|
for values in processes_data:
|
|
if not values or len(values) < len(headers):
|
|
continue
|
|
|
|
proc = {}
|
|
for i, header in enumerate(headers):
|
|
if i < len(values):
|
|
proc[header] = values[i]
|
|
|
|
processes.append(proc)
|
|
|
|
return processes
|