#!/usr/bin/env python
#
# Copyright (C) 2020 GNS3 Technologies Inc.
#
# 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 .
import asyncio
from unittest.mock import MagicMock, patch
import pytest
import pytest_asyncio
from gns3server.compute.docker import DOCKER_MINIMUM_API_VERSION, DOCKER_PREFERRED_API_VERSION, Docker
from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error
from tests.utils import AsyncioMagicMock, asyncio_patch
@pytest_asyncio.fixture
async def vm():
vm = Docker()
vm._connected = True
vm._session = MagicMock()
vm._session.closed = False
return vm
@pytest.mark.asyncio
async def test_query_success(vm):
response = MagicMock()
response.status = 200
response.headers = {"CONTENT-TYPE": "application/json"}
async def read():
return b'{"c": false}'
response.read.side_effect = read
vm._session.request = AsyncioMagicMock(return_value=response)
data = await vm.query("POST", "test", data={"a": True}, params={"b": 1})
vm._session.request.assert_called_with(
"POST",
f"http://docker/v{DOCKER_MINIMUM_API_VERSION}/test",
data='{"a": true}',
headers={"content-type": "application/json"},
params={"b": 1},
timeout=300,
)
assert data == {"c": False}
@pytest.mark.asyncio
async def test_query_error(vm):
response = MagicMock()
response.status = 404
async def read():
return b"NOT FOUND"
response.read.side_effect = read
vm._session.request = AsyncioMagicMock(return_value=response)
with pytest.raises(DockerError):
await vm.query("POST", "test", data={"a": True}, params={"b": 1})
vm._session.request.assert_called_with(
"POST",
f"http://docker/v{DOCKER_MINIMUM_API_VERSION}/test",
data='{"a": true}',
headers={"content-type": "application/json"},
params={"b": 1},
timeout=300,
)
@pytest.mark.asyncio
async def test_query_error_json(vm):
response = MagicMock()
response.status = 404
async def read():
return b'{"message": "Error"}'
response.read.side_effect = read
vm._session.request = AsyncioMagicMock(return_value=response)
with pytest.raises(DockerError):
await vm.query("POST", "test", data={"a": True}, params={"b": 1})
vm._session.request.assert_called_with(
"POST",
f"http://docker/v{DOCKER_MINIMUM_API_VERSION}/test",
data='{"a": true}',
headers={"content-type": "application/json"},
params={"b": 1},
timeout=300,
)
@pytest.mark.asyncio
async def test_list_images():
response = [
{
"RepoTags": ["ubuntu:12.04", "ubuntu:precise", "ubuntu:latest"],
"Id": "8dbd9e392a964056420e5d58ca5cc376ef18e2de93b5cc90e868a1bbc8318c1c",
"Created": 1365714795,
"Size": 131506275,
"VirtualSize": 131506275,
},
{
"RepoTags": ["ubuntu:12.10", "ubuntu:quantal", ":"],
"ParentId": "27cf784147099545",
"Id": "b750fe79269d2ec9a3c593ef05b4332b1d1a02a62b4accb2c21d589ff2f5f2dc",
"Created": 1364102658,
"Size": 24653,
"VirtualSize": 180116135,
},
]
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock:
images = await Docker.instance().list_images()
mock.assert_called_with("GET", "images/json", params={"all": 0})
assert len(images) == 5
assert {"image": "ubuntu:12.04"} in images
assert {"image": "ubuntu:precise"} in images
assert {"image": "ubuntu:latest"} in images
assert {"image": "ubuntu:12.10"} in images
assert {"image": "ubuntu:quantal"} in images
@pytest.mark.asyncio
async def test_pull_image():
class Response:
"""
Simulate a response split in multiple packets
"""
def __init__(self):
self._read = -1
async def read(self, size):
self._read += 1
if self._read == 0:
return b'{"progress": "0/100",'
elif self._read == 1:
return '"id": 42}'
else:
None
mock_query = MagicMock()
mock_query.content.return_value = Response()
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=mock_query) as mock:
await Docker.instance().pull_image("ubuntu")
mock.assert_called_with("POST", "images/create", params={"fromImage": "ubuntu"}, timeout=None)
@pytest.mark.asyncio
async def test_pull_image_skips_image_available_locally():
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value={"Id": "existing"}) as query_mock:
with asyncio_patch("gns3server.compute.docker.Docker.http_query") as pull_mock:
await Docker.instance().pull_image("ubuntu")
query_mock.assert_called_once_with("GET", "images/ubuntu/json")
pull_mock.assert_not_called()
@pytest.mark.asyncio
async def test_force_pull_image():
response = MagicMock()
response.content.read = AsyncioMagicMock(return_value=b"")
with asyncio_patch("gns3server.compute.docker.Docker.query") as query_mock:
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response) as pull_mock:
await Docker.instance().pull_image("ubuntu", force=True)
query_mock.assert_not_called()
pull_mock.assert_called_with("POST", "images/create", params={"fromImage": "ubuntu"}, timeout=None)
@pytest.mark.asyncio
async def test_pull_image_error():
class Content:
def __init__(self):
self._chunks = [b'{"error": "image not found"}', b""]
async def read(self, size):
return self._chunks.pop(0)
response = MagicMock()
response.content = Content()
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
with pytest.raises(DockerError, match="image not found"):
await Docker.instance().pull_image("missing")
response.close.assert_called_once()
@pytest.mark.asyncio
async def test_pull_image_rejects_incomplete_response():
class Content:
def __init__(self):
self._read = False
async def read(self, size):
if self._read:
return b""
self._read = True
return b'{"status": "Pulling"'
response = MagicMock()
response.content = Content()
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
with pytest.raises(DockerError, match="Invalid response"):
await Docker.instance().pull_image("ubuntu")
response.close.assert_called_once()
@pytest.mark.asyncio
async def test_pull_image_propagates_timeout():
class Content:
async def read(self, size):
raise asyncio.TimeoutError
response = MagicMock()
response.content = Content()
with asyncio_patch("gns3server.compute.docker.Docker.query", side_effect=DockerHttp404Error("404")):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
with pytest.raises(DockerError, match="Timeout while pulling"):
await Docker.instance().pull_image("ubuntu")
response.close.assert_called_once()
@pytest.mark.asyncio
async def test_load_image():
class Content:
def __init__(self):
self._chunks = [b'{"stream": "Loaded image ID: sha256:e90e34656806"}', b""]
async def read(self, size):
return self._chunks.pop(0)
response = MagicMock()
response.content = Content()
async def tar_stream():
yield b"tar-bytes"
stream = tar_stream()
messages = []
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response) as mock:
await Docker.instance().load_image(stream, progress_callback=messages.append)
mock.assert_called_with("POST", "images/load", data=stream, timeout=None)
response.close.assert_called_once()
assert any("Loaded image" in message for message in messages)
@pytest.mark.asyncio
async def test_load_image_records_image_id_translation():
"""
The digest pinned by the controller is the id of its own daemon and is not
comparable with the id this daemon reports for the same content, so the pair
is remembered to accept the pin during node creation.
"""
class Content:
def __init__(self):
self._chunks = [b'{"stream": "Loaded image: ubuntu:latest"}', b""]
async def read(self, size):
return self._chunks.pop(0)
response = MagicMock()
response.content = Content()
async def tar_stream():
yield b"tar-bytes"
controller_digest = "sha256:" + "a" * 64
local_id = "sha256:" + "b" * 64
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value={"Id": local_id}) as query_mock:
await Docker.instance().load_image(tar_stream(), image="ubuntu:latest", digest=controller_digest)
query_mock.assert_called_once_with("GET", "images/ubuntu:latest/json")
assert Docker.instance().image_id_translation("ubuntu:latest") == (controller_digest, local_id)
assert Docker.instance().image_id_translation("nginx:latest") is None
@pytest.mark.asyncio
async def test_load_image_error():
class Content:
def __init__(self):
self._chunks = [b'{"error": "invalid tar file"}', b""]
async def read(self, size):
return self._chunks.pop(0)
response = MagicMock()
response.content = Content()
async def tar_stream():
yield b"not-a-tar"
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
with pytest.raises(DockerError, match="invalid tar file"):
await Docker.instance().load_image(tar_stream())
response.close.assert_called_once()
@pytest.mark.asyncio
async def test_docker_check_connection_docker_minimum_version(vm):
response = {"ApiVersion": "1.01", "Version": "1.12"}
with (
patch("gns3server.compute.docker.Docker.connector"),
asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response),
):
vm._connected = False
with pytest.raises(DockerError):
await vm._check_connection()
@pytest.mark.asyncio
async def test_docker_check_connection_docker_preferred_version_against_newer(vm):
response = {
"ApiVersion": "1.52",
"Version": "29.0.1",
}
with (
patch("gns3server.compute.docker.Docker.connector"),
asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response),
):
vm._connected = False
await vm._check_connection()
assert vm._api_version == DOCKER_PREFERRED_API_VERSION
@pytest.mark.asyncio
async def test_docker_check_connection_docker_preferred_version_against_older(vm):
response = {"ApiVersion": "1.43", "Version": "24.0.2", "MinAPIVersion": "1.40"}
with (
patch("gns3server.compute.docker.Docker.connector"),
asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response),
):
vm._connected = False
await vm._check_connection()
assert vm._api_version == DOCKER_MINIMUM_API_VERSION
@pytest.mark.asyncio
async def test_docker_check_connection_docker_unsupported_version(vm):
response = {
"ApiVersion": "1.25",
"Version": "1.13.1",
}
with (
patch("gns3server.compute.docker.Docker.connector"),
asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response),
):
vm._connected = False
with pytest.raises(DockerError) as e:
await vm._check_connection()
@pytest.mark.asyncio
async def test_install_busybox():
mock_process = MagicMock()
mock_process.returncode = 1 # means that busybox is not dynamically linked
mock_process.communicate = AsyncioMagicMock(return_value=(b"", b"not a dynamic executable"))
with patch("gns3server.compute.docker.os.path.isfile", return_value=False):
with patch("gns3server.compute.docker.shutil.which", return_value="/usr/bin/busybox"):
with asyncio_patch(
"gns3server.compute.docker.asyncio.create_subprocess_exec", return_value=mock_process
) as create_subprocess_mock:
with patch("gns3server.compute.docker.shutil.copy2") as copy2_mock:
dst_dir = Docker.resources_path()
await Docker.install_busybox(dst_dir)
create_subprocess_mock.assert_called_with(
"ldd",
"/usr/bin/busybox",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
assert copy2_mock.called
@pytest.mark.asyncio
async def test_install_busybox_dynamic_linked():
mock_process = MagicMock()
mock_process.returncode = 0 # means that busybox is dynamically linked
mock_process.communicate = AsyncioMagicMock(return_value=(b"Dynamically linked library", b""))
with patch("os.path.isfile", return_value=False):
with patch(
"gns3server.compute.docker.shutil.which",
side_effect=lambda name: "/usr/bin/busybox" if name == "busybox" else None,
):
with asyncio_patch("gns3server.compute.docker.asyncio.create_subprocess_exec", return_value=mock_process):
with pytest.raises(DockerError) as e:
dst_dir = Docker.resources_path()
await Docker.install_busybox(dst_dir)
assert (
str(e.value)
== "No busybox executable could be found, please install busybox (apt install busybox-static on Debian/Ubuntu) and make sure it is in your PATH"
)
@pytest.mark.asyncio
async def test_install_busybox_no_executables():
with patch("gns3server.compute.docker.os.path.isfile", return_value=False):
with patch("gns3server.compute.docker.shutil.which", return_value=None):
with pytest.raises(DockerError) as e:
dst_dir = Docker.resources_path()
await Docker.install_busybox(dst_dir)
assert (
str(e.value)
== "No busybox executable could be found, please install busybox (apt install busybox-static on Debian/Ubuntu) and make sure it is in your PATH"
)
@pytest.mark.asyncio
async def test_check_host_readiness_warns_when_low(caplog):
import logging
from io import StringIO
docker = Docker()
files = {
"/proc/sys/fs/inotify/max_user_instances": "128",
"/proc/sys/fs/inotify/max_user_watches": "8192",
"/proc/sys/fs/file-max": "100000",
"/proc/filesystems": "nodev ext4\nnodev tmpfs\n",
}
def fake_open(path, *args, **kwargs):
return StringIO(files[path])
with patch("builtins.open", side_effect=fake_open):
with caplog.at_level(logging.WARNING, logger="gns3server.compute.docker"):
docker._check_host_readiness()
message = " ".join(r.message for r in caplog.records)
assert "max_user_instances=128" in message
assert "sudo sysctl -w" in message
assert "modprobe fuse" in message # FUSE missing -> warned
@pytest.mark.asyncio
async def test_check_host_readiness_silent_when_ok(caplog):
import logging
from io import StringIO
docker = Docker()
files = {
"/proc/sys/fs/inotify/max_user_instances": "64000",
"/proc/sys/fs/inotify/max_user_watches": "524288",
"/proc/sys/fs/file-max": "1000000",
"/proc/filesystems": "nodev ext4\nnodev fuse\n",
}
def fake_open(path, *args, **kwargs):
return StringIO(files[path])
with patch("builtins.open", side_effect=fake_open):
with caplog.at_level(logging.WARNING, logger="gns3server.compute.docker"):
docker._check_host_readiness()
assert not [r for r in caplog.records if r.levelno == logging.WARNING]
@pytest.mark.asyncio
async def test_check_host_readiness_continues_past_unreadable_key(caplog):
"""
One unreadable /proc/sys key must not discard the warnings already
collected nor skip the FUSE check (that was a mid-loop return).
"""
import logging
from io import StringIO
docker = Docker()
files = {
"/proc/sys/fs/inotify/max_user_instances": "128", # low -> must warn
# max_user_watches and fs.file-max: unreadable -> skipped
"/proc/filesystems": "nodev ext4\n", # no fuse -> must warn
}
def fake_open(path, *args, **kwargs):
if path not in files:
raise OSError("masked")
return StringIO(files[path])
with patch("builtins.open", side_effect=fake_open):
with caplog.at_level(logging.WARNING, logger="gns3server.compute.docker"):
docker._check_host_readiness()
message = " ".join(r.message for r in caplog.records)
assert "max_user_instances=128" in message # collected before the gap
assert "modprobe fuse" in message # FUSE check still ran