From 86c0b90951a382d42cea84b8a27e66e06de0edea Mon Sep 17 00:00:00 2001 From: grossmj Date: Fri, 27 Oct 2023 14:42:22 +1000 Subject: [PATCH 01/14] Use Python 3.8 to publish API doc --- .github/workflows/publish-api-documentation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-api-documentation.yml b/.github/workflows/publish-api-documentation.yml index 30beb62f..68bd91cd 100644 --- a/.github/workflows/publish-api-documentation.yml +++ b/.github/workflows/publish-api-documentation.yml @@ -18,7 +18,7 @@ jobs: ref: "gh-pages" - uses: actions/setup-python@v3 with: - python-version: 3.7 + python-version: 3.8 - name: Merge changes from 3.0 branch run: | git config user.name github-actions From cbc7e59d3f149ebd705cb6cffeb4fb1ec56db6a3 Mon Sep 17 00:00:00 2001 From: Dustin Date: Mon, 30 Oct 2023 11:00:45 -0400 Subject: [PATCH 02/14] Update remote-install.sh Removed an extra slash at the end when setting the user home directory. This was causing unexpected behavior for other scrips as ~ was aliased to /opt/gns3/ instead of the expected /opt/gns3. This caused an extra / to appear in commands unexpectedly. --- scripts/remote-install.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/remote-install.sh b/scripts/remote-install.sh index 4664cd04..6c68a640 100644 --- a/scripts/remote-install.sh +++ b/scripts/remote-install.sh @@ -163,9 +163,9 @@ log "Install GNS3 packages" apt-get install -y gns3-server log "Create user GNS3 with /opt/gns3 as home directory" -if [ ! -d "/opt/gns3/" ] +if [ ! -d "/opt/gns3" ] then - useradd -m -d /opt/gns3/ gns3 + useradd -m -d /opt/gns3 gns3 fi @@ -462,4 +462,4 @@ NEEDRESTART_MODE=a apt-get upgrade python3 -c 'import sys; sys.path.append("/usr/local/bin/"); import welcome; ws = welcome.Welcome_dialog(); ws.repair_remote_install()' cd /opt/gns3 su gns3 -fi \ No newline at end of file +fi From 7ad3afbdef96640e056eac062ba7dc3da8286553 Mon Sep 17 00:00:00 2001 From: Dustin Date: Sun, 5 Nov 2023 13:35:06 -0500 Subject: [PATCH 03/14] Update welcome.py Fixed an issue where the shell option in dialog failed to drop you back to bash. --- scripts/welcome.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/welcome.py b/scripts/welcome.py index 821e4f97..d7e0cf4c 100644 --- a/scripts/welcome.py +++ b/scripts/welcome.py @@ -438,7 +438,8 @@ Images and projects are located in /opt/gns3 self.display.clear() if code == Dialog.OK: if tag == "Shell": - os.execvp("bash", ['/bin/bash']) + print("Type: 'welcome.py' to get back to the dialog menu.") + sys.exit(0) elif tag == "Version": self.mode() elif tag == "Restore": From 76bd5921c5ab2daa325a5b9a123673907561d632 Mon Sep 17 00:00:00 2001 From: Xatrekak Date: Mon, 6 Nov 2023 19:02:29 -0500 Subject: [PATCH 04/14] Fixed updating system and GNS3. --- scripts/remote-install.sh | 5 +++++ scripts/welcome.py | 41 ++++++++++++++++++++++++++++----------- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/scripts/remote-install.sh b/scripts/remote-install.sh index 6c68a640..289ad335 100644 --- a/scripts/remote-install.sh +++ b/scripts/remote-install.sh @@ -304,6 +304,11 @@ log "GNS3 installed with success" if [ $WELCOME_SETUP == 1 ] then +cat < /etc/sudoers.d/gns3 +gns3 ALL = (ALL) NOPASSWD: /usr/bin/apt-key +gns3 ALL = (ALL) NOPASSWD: /usr/bin/apt-get +gns3 ALL = (ALL) NOPASSWD: /usr/sbin/reboot +EOFI NEEDRESTART_MODE=a apt-get install -y net-tools NEEDRESTART_MODE=a apt-get install -y python3-pip NEEDRESTART_MODE=a apt-get install -y dialog diff --git a/scripts/welcome.py b/scripts/welcome.py index d7e0cf4c..77de8d75 100644 --- a/scripts/welcome.py +++ b/scripts/welcome.py @@ -163,19 +163,38 @@ class Welcome_dialog: def update(self, force=False): if not force: - if self.display.yesno("PLEASE SNAPSHOT THE VM BEFORE RUNNING THE UPGRADE IN CASE OF FAILURE. The server will reboot at the end of the upgrade process. Continue?") != self.display.OK: + if self.display.yesno("It is recommended to ensure all Nodes are shutdown before upgrading. Continue?") != self.display.OK: return - release = self.get_release() - if release == "2.2": - if self.display.yesno("It is recommended to run GNS3 version 2.2 with lastest GNS3 VM based on Ubuntu 18.04 LTS, please download this VM from our website or continue at your own risk!") != self.display.OK: + code, option = self.display.menu("Select an option", + choices=[("Upgrade GNS3", "Upgrades only the GNS3 pakage and dependences."), + ("Upgrade All", "Upgrades all avaiable packages"), + ("Dist Upgrade", "Upgrades all avaiable packages and the Linux Kernel. Requires a reboot.")]) + if code == Dialog.OK: + if option == "Upgrade GNS3": + ret = os.system( + "sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys A2E3EF7B \ + && sudo apt-get update \ + && sudo apt-get install -y --only-upgrade gns3-server" + ) + elif option == "Upgrade All": + ret = os.system( + 'sudo apt-key adv --refresh-keys --keyserver keyserver.ubuntu.com \ + && sudo apt-get update \ + && sudo apt-get upgrade --yes --force-yes -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold"' + ) + elif option == "Dist Upgrade": + ret = os.system( + 'sudo apt-key adv --refresh-keys --keyserver keyserver.ubuntu.com \ + && sudo apt-get update \ + && sudo apt-get dist-upgrade --yes --force-yes -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold"' + ) + if ret != 0: + print("ERROR DURING UPGRADE PROCESS PLEASE TAKE A SCREENSHOT IF YOU NEED SUPPORT") + time.sleep(15) return - if release.endswith("dev"): - ret = os.system("curl -Lk https://raw.githubusercontent.com/GNS3/gns3-vm/unstable/scripts/update_{}.sh > /tmp/update.sh && bash -x /tmp/update.sh".format(release)) - else: - ret = os.system("curl -Lk https://raw.githubusercontent.com/GNS3/gns3-vm/master/scripts/update_{}.sh > /tmp/update.sh && bash -x /tmp/update.sh".format(release)) - if ret != 0: - print("ERROR DURING UPGRADE PROCESS PLEASE TAKE A SCREENSHOT IF YOU NEED SUPPORT") - time.sleep(15) + if option == "Dist Upgrade": + if self.display.yesno("Reboot now?") == self.display.OK: + os.system("sudo reboot now") def migrate(self): From ac86717bc07fd817b7cdf5b320f2058deb7e5f35 Mon Sep 17 00:00:00 2001 From: John Fleming <31658656+spikefishjohn@users.noreply.github.com> Date: Tue, 23 Jan 2024 13:15:17 -0500 Subject: [PATCH 05/14] Update telnet_server.py Set tcp keepalive timers to 60 seconds. Seems to default to 2 hours on ubuntu 22. Most firewalls will age out an idle tcp session at 1 hour. Will not address telnet console failing after a tcp session has failed (TimeoutError). --- gns3server/utils/asyncio/telnet_server.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gns3server/utils/asyncio/telnet_server.py b/gns3server/utils/asyncio/telnet_server.py index 0f6152e1..14742e00 100644 --- a/gns3server/utils/asyncio/telnet_server.py +++ b/gns3server/utils/asyncio/telnet_server.py @@ -190,6 +190,11 @@ class AsyncioTelnetServer: sock = network_writer.get_extra_info("socket") sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + # 60 sec keep alives, close tcp session after 4 missed + # Will keep a firewall from aging out telnet console. + writer_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60) + writer_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) + writer_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 4) #log.debug("New connection from {}".format(sock.getpeername())) # Keep track of connected clients From 54abf85523d266dd69cc5275f0c581ec04984b0d Mon Sep 17 00:00:00 2001 From: John Fleming <31658656+spikefishjohn@users.noreply.github.com> Date: Thu, 25 Jan 2024 01:41:57 -0500 Subject: [PATCH 06/14] Update telnet_server.py Maybe use the correct object name this time for the socket objects. --- gns3server/utils/asyncio/telnet_server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gns3server/utils/asyncio/telnet_server.py b/gns3server/utils/asyncio/telnet_server.py index 14742e00..b8829847 100644 --- a/gns3server/utils/asyncio/telnet_server.py +++ b/gns3server/utils/asyncio/telnet_server.py @@ -192,9 +192,9 @@ class AsyncioTelnetServer: sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # 60 sec keep alives, close tcp session after 4 missed # Will keep a firewall from aging out telnet console. - writer_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60) - writer_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) - writer_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 4) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 4) #log.debug("New connection from {}".format(sock.getpeername())) # Keep track of connected clients From 763ef241080bcb7f9c3be800989d5b0a4f2043f5 Mon Sep 17 00:00:00 2001 From: John Fleming Date: Fri, 2 Feb 2024 22:09:31 -0500 Subject: [PATCH 07/14] Address the telnet console bug. Add wait_for for drain() call. If we're stuck on drain then the buffer isn't getting emptied. 5 seconds after drain() blocks, exception will be thrown and client will be removed from connection table and will no longer be a problem. --- gns3server/utils/asyncio/telnet_server.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/gns3server/utils/asyncio/telnet_server.py b/gns3server/utils/asyncio/telnet_server.py index b8829847..50f7defc 100644 --- a/gns3server/utils/asyncio/telnet_server.py +++ b/gns3server/utils/asyncio/telnet_server.py @@ -297,9 +297,17 @@ class AsyncioTelnetServer: reader_read = await self._get_reader(network_reader) # Replicate the output on all clients - for connection in self._connections.values(): - connection.writer.write(data) - await connection.writer.drain() + for connection_key in list(self._connections.keys()): + client_info = connection_key.get_extra_info("socket").getpeername() + connection = self._connections[connection_key] + + try: + connection.writer.write(data) + await asyncio.wait_for(connection.writer.drain(), timeout=10) + except: + log.debug(f"Timeout while sending data to client: {client_info}, closing and removing from connection table.") + connection.close() + del self._connections[connection_key] async def _read(self, cmd, buffer, location, reader): """ Reads next op from the buffer or reader""" From 1fb0260ae6969a0734b34fed9d4cba0901f73268 Mon Sep 17 00:00:00 2001 From: grossmj Date: Fri, 9 Feb 2024 16:28:23 +1100 Subject: [PATCH 08/14] Drop support for Python 3.6 --- requirements.txt | 15 +++++---------- setup.py | 9 ++++----- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/requirements.txt b/requirements.txt index f0657c38..af05afa2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,8 @@ -jsonschema>=4.17.3,<4.18; python_version >= '3.7' # v4.17.3 is the last version to support Python 3.7 -jsonschema==3.2.0; python_version < '3.7' # v3.2.0 is the last version to support Python 3.6 -aiohttp>=3.8.5,<3.9; python_version <= '3.7' -aiohttp>=3.9.0,<3.10; python_version > '3.7' +jsonschema>=4.17.3,<4.18 # v4.17.3 is the last version to support Python 3.7 +aiohttp>=3.9.0,<3.10 aiohttp-cors>=0.7.0,<0.8 -aiofiles>=23.2.1,<23.3; python_version >= '3.7' -aiofiles==0.8.0; python_version < '3.7' # v0.8.0 is the last version to support Python 3.6 -Jinja2>=3.1.2,<3.2; python_version >= '3.7' -Jinja2==3.0.3; python_version < '3.7' # v3.0.3 is the last version to support Python 3.6 +aiofiles>=23.2.1,<23.3 +Jinja2>=3.1.2,<3.2 sentry-sdk==1.39.2,<1.40 psutil==5.9.8 async-timeout>=4.0.2,<4.1 @@ -15,5 +11,4 @@ py-cpuinfo>=9.0.0,<10.0 platformdirs>=2.4.0 importlib-resources>=1.3; python_version < '3.9' truststore>=0.8.0; python_version >= '3.10' -setuptools>=60.8.1; python_version >= '3.7' -setuptools==59.6.0; python_version < '3.7' # v59.6.0 is the last version to support Python 3.6 +setuptools>=60.8.1 diff --git a/setup.py b/setup.py index 5ee8f8be..4fdee869 100644 --- a/setup.py +++ b/setup.py @@ -23,9 +23,9 @@ import subprocess from setuptools import setup, find_packages from setuptools.command.test import test as TestCommand -# we only support Python 3 version >= 3.5.3 -if len(sys.argv) >= 2 and sys.argv[1] == "install" and sys.version_info < (3, 5, 3): - raise SystemExit("Python 3.5.3 or higher is required") +# we only support Python 3 version >= 3.7 +if len(sys.argv) >= 2 and sys.argv[1] == "install" and sys.version_info < (3, 7): + raise SystemExit("Python 3.7 or higher is required") class PyTest(TestCommand): @@ -89,7 +89,7 @@ setup( include_package_data=True, zip_safe=False, platforms="any", - python_requires='>=3.6.0', + python_requires='>=3.7', setup_requires=["setuptools>=17.1"], classifiers=[ "Development Status :: 5 - Production/Stable", @@ -103,7 +103,6 @@ setup( "Operating System :: Microsoft :: Windows", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", From 93520b4d6c83a9cbee62e681d73299005dd61d0d Mon Sep 17 00:00:00 2001 From: grossmj Date: Fri, 9 Feb 2024 16:34:44 +1100 Subject: [PATCH 09/14] Do not test with Python 3.6 --- .github/workflows/testing.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 6763ce51..e75eb4c2 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -13,10 +13,10 @@ on: jobs: build: - runs-on: ubuntu-20.04 # Downgrade Ubuntu to 20.04 to fix missing Python 3.6 + runs-on: ubuntu-22.04 strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 From c93aafc9af38fcc29988a928d29b178998aa8c26 Mon Sep 17 00:00:00 2001 From: grossmj Date: Fri, 9 Feb 2024 16:45:46 +1100 Subject: [PATCH 10/14] Fix aiohttp dependency for Python 3.7 --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index af05afa2..8191f4c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ jsonschema>=4.17.3,<4.18 # v4.17.3 is the last version to support Python 3.7 -aiohttp>=3.9.0,<3.10 +aiohttp>=3.8.6,<3.9; python_version == '3.7' # v3.8.6 is the last version to support Python 3.7 +aiohttp>=3.9.0,<3.10; python_version > '3.7' aiohttp-cors>=0.7.0,<0.8 aiofiles>=23.2.1,<23.3 Jinja2>=3.1.2,<3.2 From f050fc7e009b82c4f5133df5926a83475b6b2b2c Mon Sep 17 00:00:00 2001 From: grossmj Date: Fri, 9 Feb 2024 16:49:58 +1100 Subject: [PATCH 11/14] Change runtime checks for Python version --- gns3server/run.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gns3server/run.py b/gns3server/run.py index 672cc357..c15d976e 100644 --- a/gns3server/run.py +++ b/gns3server/run.py @@ -235,9 +235,9 @@ def run(): return log.info("HTTP authentication is enabled with username '{}'".format(user)) - # we only support Python 3 version >= 3.6 - if sys.version_info < (3, 6, 0): - raise SystemExit("Python 3.6 or higher is required") + # we only support Python 3 version >= 3.7 + if sys.version_info < (3, 7, 0): + raise SystemExit("Python 3.7 or higher is required") user_log.info("Running with Python {major}.{minor}.{micro} and has PID {pid}".format(major=sys.version_info[0], minor=sys.version_info[1], micro=sys.version_info[2], pid=os.getpid())) From 3ced41633f5ee132a8ffba432c432c12dff2df1a Mon Sep 17 00:00:00 2001 From: grossmj Date: Fri, 9 Feb 2024 17:07:35 +1100 Subject: [PATCH 12/14] Upgrade dependencies --- requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8191f4c4..3d293866 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,12 @@ jsonschema>=4.17.3,<4.18 # v4.17.3 is the last version to support Python 3.7 aiohttp>=3.8.6,<3.9; python_version == '3.7' # v3.8.6 is the last version to support Python 3.7 -aiohttp>=3.9.0,<3.10; python_version > '3.7' +aiohttp>=3.9.3,<3.10; python_version > '3.7' aiohttp-cors>=0.7.0,<0.8 aiofiles>=23.2.1,<23.3 -Jinja2>=3.1.2,<3.2 +Jinja2>=3.1.3,<3.2 sentry-sdk==1.39.2,<1.40 psutil==5.9.8 -async-timeout>=4.0.2,<4.1 +async-timeout>=4.0.3,<4.1 distro>=1.9.0 py-cpuinfo>=9.0.0,<10.0 platformdirs>=2.4.0 From 1f5085608ccc8a5494c1b6488f68652ba4f46d99 Mon Sep 17 00:00:00 2001 From: grossmj Date: Wed, 14 Feb 2024 15:40:19 +0800 Subject: [PATCH 13/14] Use Docker API v1.24 to get version. --- gns3server/compute/docker/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gns3server/compute/docker/__init__.py b/gns3server/compute/docker/__init__.py index cc82daf9..7e0cd195 100644 --- a/gns3server/compute/docker/__init__.py +++ b/gns3server/compute/docker/__init__.py @@ -135,7 +135,7 @@ class Docker(BaseManager): timeout = 60 * 60 * 24 * 31 # One month timeout if path == 'version': - url = "http://docker/v1.12/" + path # API of docker v1.0 + url = "http://docker/v1.24/" + path else: url = "http://docker/v" + DOCKER_MINIMUM_API_VERSION + "/" + path try: From 1a53c9aabf5ae60358496325f06550388ac12c65 Mon Sep 17 00:00:00 2001 From: grossmj Date: Wed, 14 Feb 2024 16:13:45 +0800 Subject: [PATCH 14/14] Backport from v3: install Docker resources in a writable location at runtime. --- gns3server/compute/docker/__init__.py | 60 ++++++++++++++++++++++++++ gns3server/compute/docker/docker_vm.py | 13 ++++-- gns3server/controller/__init__.py | 9 ++-- setup.py | 22 ---------- tests/compute/docker/test_docker.py | 50 +++++++++++++++++++++ tests/compute/docker/test_docker_vm.py | 36 ++++++++-------- 6 files changed, 142 insertions(+), 48 deletions(-) diff --git a/gns3server/compute/docker/__init__.py b/gns3server/compute/docker/__init__.py index 7e0cd195..7a31fb3b 100644 --- a/gns3server/compute/docker/__init__.py +++ b/gns3server/compute/docker/__init__.py @@ -19,11 +19,15 @@ Docker server module. """ +import os import sys import json import asyncio import logging import aiohttp +import shutil +import platformdirs + from gns3server.utils import parse_version from gns3server.utils.asyncio import locking from gns3server.compute.base_manager import BaseManager @@ -55,6 +59,62 @@ class Docker(BaseManager): self._session = None self._api_version = DOCKER_MINIMUM_API_VERSION + @staticmethod + async def install_busybox(dst_dir): + + dst_busybox = os.path.join(dst_dir, "bin", "busybox") + if os.path.isfile(dst_busybox): + return + for busybox_exec in ("busybox-static", "busybox.static", "busybox"): + busybox_path = shutil.which(busybox_exec) + if busybox_path: + try: + # check that busybox is statically linked + # (dynamically linked busybox will fail to run in a container) + proc = await asyncio.create_subprocess_exec( + "ldd", + busybox_path, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL + ) + stdout, _ = await proc.communicate() + if proc.returncode == 1: + # ldd returns 1 if the file is not a dynamic executable + log.info(f"Installing busybox from '{busybox_path}' to '{dst_busybox}'") + shutil.copy2(busybox_path, dst_busybox, follow_symlinks=True) + return + else: + log.warning(f"Busybox '{busybox_path}' is dynamically linked\n" + f"{stdout.decode('utf-8', errors='ignore').strip()}") + except OSError as e: + raise DockerError(f"Could not install busybox: {e}") + raise DockerError("No busybox executable could be found") + + @staticmethod + def resources_path(): + """ + Get the Docker resources storage directory + """ + + appname = vendor = "GNS3" + docker_resources_dir = os.path.join(platformdirs.user_data_dir(appname, vendor, roaming=True), "docker", "resources") + os.makedirs(docker_resources_dir, exist_ok=True) + return docker_resources_dir + + async def install_resources(self): + """ + Copy the necessary resources to a writable location and install busybox + """ + + try: + dst_path = self.resources_path() + log.info(f"Installing Docker resources in '{dst_path}'") + from gns3server.controller import Controller + Controller.instance().install_resource_files(dst_path, "compute/docker/resources") + await self.install_busybox(dst_path) + except OSError as e: + raise DockerError(f"Could not install Docker resources to {dst_path}: {e}") + async def _check_connection(self): if not self._connected: diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 500e526d..d1ad1456 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -242,10 +242,13 @@ class DockerVM(BaseNode): :returns: Return the path that we need to map to local folders """ - resources = get_resource("compute/docker/resources") - if not os.path.exists(resources): - raise DockerError("{} is missing can't start Docker containers".format(resources)) - binds = ["{}:/gns3:ro".format(resources)] + try: + resources_path = self.manager.resources_path() + except OSError as e: + raise DockerError(f"Cannot access resources: {e}") + + log.info(f'Mount resources from "{resources_path}"') + binds = ["{}:/gns3:ro".format(resources_path)] # We mount our own etc/network try: @@ -460,6 +463,8 @@ class DockerVM(BaseNode): Starts this Docker container. """ + await self.manager.install_resources() + try: state = await self._get_container_state() except DockerHttp404Error: diff --git a/gns3server/controller/__init__.py b/gns3server/controller/__init__.py index 4a66fe2d..34ddf273 100644 --- a/gns3server/controller/__init__.py +++ b/gns3server/controller/__init__.py @@ -297,9 +297,12 @@ class Controller: else: for entry in importlib_resources.files('gns3server').joinpath(resource_name).iterdir(): full_path = os.path.join(dst_path, entry.name) - if entry.is_file() and not os.path.exists(full_path): - log.debug(f'Installing {resource_name} resource file "{entry.name}" to "{full_path}"') - shutil.copy(str(entry), os.path.join(dst_path, entry.name)) + if not os.path.exists(full_path): + if entry.is_file(): + log.debug(f'Installing {resource_name} resource file "{entry.name}" to "{full_path}"') + shutil.copy(str(entry), os.path.join(dst_path, entry.name)) + elif entry.is_dir(): + os.makedirs(full_path, exist_ok=True) def _install_base_configs(self): """ diff --git a/setup.py b/setup.py index 4fdee869..167d64d0 100644 --- a/setup.py +++ b/setup.py @@ -43,28 +43,6 @@ class PyTest(TestCommand): sys.exit(errcode) -BUSYBOX_PATH = "gns3server/compute/docker/resources/bin/busybox" - - -def copy_busybox(): - if not sys.platform.startswith("linux"): - return - if os.path.isfile(BUSYBOX_PATH): - return - for bb_cmd in ("busybox-static", "busybox.static", "busybox"): - bb_path = shutil.which(bb_cmd) - if bb_path: - if subprocess.call(["ldd", bb_path], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL): - shutil.copy2(bb_path, BUSYBOX_PATH, follow_symlinks=True) - break - else: - raise SystemExit("No static busybox found") - - -copy_busybox() dependencies = open("requirements.txt", "r").read().splitlines() setup( diff --git a/tests/compute/docker/test_docker.py b/tests/compute/docker/test_docker.py index 2e9193ad..76a30ce5 100644 --- a/tests/compute/docker/test_docker.py +++ b/tests/compute/docker/test_docker.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import asyncio import pytest from unittest.mock import MagicMock, patch @@ -200,3 +201,52 @@ async def test_docker_check_connection_docker_preferred_version_against_older(vm vm._connected = False await vm._check_connection() assert vm._api_version == DOCKER_MINIMUM_API_VERSION + + +@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", return_value="/usr/bin/busybox"): + 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" + + +@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" diff --git a/tests/compute/docker/test_docker_vm.py b/tests/compute/docker/test_docker_vm.py index 3a395a56..097130bf 100644 --- a/tests/compute/docker/test_docker_vm.py +++ b/tests/compute/docker/test_docker_vm.py @@ -25,10 +25,8 @@ from tests.utils import asyncio_patch, AsyncioMagicMock from gns3server.ubridge.ubridge_error import UbridgeNamespaceError from gns3server.compute.docker.docker_vm import DockerVM -from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error, DockerHttp304Error +from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error from gns3server.compute.docker import Docker -from gns3server.utils.get_resource import get_resource - from unittest.mock import patch, MagicMock, call @@ -101,7 +99,7 @@ async def test_create(compute_project, manager): { "CapAdd": ["ALL"], "Binds": [ - "{}:/gns3:ro".format(get_resource("compute/docker/resources")), + "{}:/gns3:ro".format(Docker.resources_path()), "{}:/gns3volumes/etc/network".format(os.path.join(vm.working_dir, "etc", "network")) ], "Privileged": True @@ -139,7 +137,7 @@ async def test_create_with_tag(compute_project, manager): { "CapAdd": ["ALL"], "Binds": [ - "{}:/gns3:ro".format(get_resource("compute/docker/resources")), + "{}:/gns3:ro".format(Docker.resources_path()), "{}:/gns3volumes/etc/network".format(os.path.join(vm.working_dir, "etc", "network")) ], "Privileged": True @@ -180,7 +178,7 @@ async def test_create_vnc(compute_project, manager): { "CapAdd": ["ALL"], "Binds": [ - "{}:/gns3:ro".format(get_resource("compute/docker/resources")), + "{}:/gns3:ro".format(Docker.resources_path()), "{}:/gns3volumes/etc/network".format(os.path.join(vm.working_dir, "etc", "network")), "/tmp/.X11-unix/X{0}:/tmp/.X11-unix/X{0}:ro".format(vm._display) ], @@ -310,7 +308,7 @@ async def test_create_start_cmd(compute_project, manager): { "CapAdd": ["ALL"], "Binds": [ - "{}:/gns3:ro".format(get_resource("compute/docker/resources")), + "{}:/gns3:ro".format(Docker.resources_path()), "{}:/gns3volumes/etc/network".format(os.path.join(vm.working_dir, "etc", "network")) ], "Privileged": True @@ -408,7 +406,7 @@ async def test_create_image_not_available(compute_project, manager): { "CapAdd": ["ALL"], "Binds": [ - "{}:/gns3:ro".format(get_resource("compute/docker/resources")), + "{}:/gns3:ro".format(Docker.resources_path()), "{}:/gns3volumes/etc/network".format(os.path.join(vm.working_dir, "etc", "network")) ], "Privileged": True @@ -451,7 +449,7 @@ async def test_create_with_user(compute_project, manager): { "CapAdd": ["ALL"], "Binds": [ - "{}:/gns3:ro".format(get_resource("compute/docker/resources")), + "{}:/gns3:ro".format(Docker.resources_path()), "{}:/gns3volumes/etc/network".format(os.path.join(vm.working_dir, "etc", "network")) ], "Privileged": True @@ -533,7 +531,7 @@ async def test_create_with_extra_volumes_duplicate_1_image(compute_project, mana { "CapAdd": ["ALL"], "Binds": [ - "{}:/gns3:ro".format(get_resource("compute/docker/resources")), + "{}:/gns3:ro".format(Docker.resources_path()), "{}:/gns3volumes/etc/network".format(os.path.join(vm.working_dir, "etc", "network")), "{}:/gns3volumes/vol/1".format(os.path.join(vm.working_dir, "vol", "1")), ], @@ -572,7 +570,7 @@ async def test_create_with_extra_volumes_duplicate_2_user(compute_project, manag { "CapAdd": ["ALL"], "Binds": [ - "{}:/gns3:ro".format(get_resource("compute/docker/resources")), + "{}:/gns3:ro".format(Docker.resources_path()), "{}:/gns3volumes/etc/network".format(os.path.join(vm.working_dir, "etc", "network")), "{}:/gns3volumes/vol/1".format(os.path.join(vm.working_dir, "vol", "1")), ], @@ -611,7 +609,7 @@ async def test_create_with_extra_volumes_duplicate_3_subdir(compute_project, man { "CapAdd": ["ALL"], "Binds": [ - "{}:/gns3:ro".format(get_resource("compute/docker/resources")), + "{}:/gns3:ro".format(Docker.resources_path()), "{}:/gns3volumes/etc/network".format(os.path.join(vm.working_dir, "etc", "network")), "{}:/gns3volumes/vol".format(os.path.join(vm.working_dir, "vol")), ], @@ -650,7 +648,7 @@ async def test_create_with_extra_volumes_duplicate_4_backslash(compute_project, { "CapAdd": ["ALL"], "Binds": [ - "{}:/gns3:ro".format(get_resource("compute/docker/resources")), + "{}:/gns3:ro".format(Docker.resources_path()), "{}:/gns3volumes/etc/network".format(os.path.join(vm.working_dir, "etc", "network")), "{}:/gns3volumes/vol".format(os.path.join(vm.working_dir, "vol")), ], @@ -689,7 +687,7 @@ async def test_create_with_extra_volumes_duplicate_5_subdir_issue_1595(compute_p { "CapAdd": ["ALL"], "Binds": [ - "{}:/gns3:ro".format(get_resource("compute/docker/resources")), + "{}:/gns3:ro".format(Docker.resources_path()), "{}:/gns3volumes/etc".format(os.path.join(vm.working_dir, "etc")), ], "Privileged": True @@ -727,7 +725,7 @@ async def test_create_with_extra_volumes_duplicate_6_subdir_issue_1595(compute_p { "CapAdd": ["ALL"], "Binds": [ - "{}:/gns3:ro".format(get_resource("compute/docker/resources")), + "{}:/gns3:ro".format(Docker.resources_path()), "{}:/gns3volumes/etc".format(os.path.join(vm.working_dir, "etc")), ], "Privileged": True @@ -771,7 +769,7 @@ async def test_create_with_extra_volumes(compute_project, manager): { "CapAdd": ["ALL"], "Binds": [ - "{}:/gns3:ro".format(get_resource("compute/docker/resources")), + "{}:/gns3:ro".format(Docker.resources_path()), "{}:/gns3volumes/etc/network".format(os.path.join(vm.working_dir, "etc", "network")), "{}:/gns3volumes/vol/1".format(os.path.join(vm.working_dir, "vol", "1")), "{}:/gns3volumes/vol/2".format(os.path.join(vm.working_dir, "vol", "2")), @@ -996,7 +994,7 @@ async def test_update(vm): { "CapAdd": ["ALL"], "Binds": [ - "{}:/gns3:ro".format(get_resource("compute/docker/resources")), + "{}:/gns3:ro".format(Docker.resources_path()), "{}:/gns3volumes/etc/network".format(os.path.join(vm.working_dir, "etc", "network")) ], "Privileged": True @@ -1064,7 +1062,7 @@ async def test_update_running(vm): { "CapAdd": ["ALL"], "Binds": [ - "{}:/gns3:ro".format(get_resource("compute/docker/resources")), + "{}:/gns3:ro".format(Docker.resources_path()), "{}:/gns3volumes/etc/network".format(os.path.join(vm.working_dir, "etc", "network")) ], "Privileged": True @@ -1325,7 +1323,7 @@ async def test_mount_binds(vm): dst = os.path.join(vm.working_dir, "test/experimental") assert vm._mount_binds(image_infos) == [ - "{}:/gns3:ro".format(get_resource("compute/docker/resources")), + "{}:/gns3:ro".format(Docker.resources_path()), "{}:/gns3volumes/etc/network".format(os.path.join(vm.working_dir, "etc", "network")), "{}:/gns3volumes{}".format(dst, "/test/experimental") ]