From d5c194b503770ccd2944e86141de2d4339d5276c Mon Sep 17 00:00:00 2001 From: Callahan Date: Thu, 30 Nov 2023 09:39:23 -0600 Subject: [PATCH] feat(lxd): store and check PID when launching instances (#464) Signed-off-by: Callahan Kovacs --- craft_providers/lxd/launcher.py | 121 ++++++++++---- craft_providers/lxd/lxc.py | 18 ++- craft_providers/lxd/lxd_instance.py | 8 + tests/integration/lxd/test_lxc.py | 30 +++- tests/integration/lxd/test_lxd_instance.py | 6 + tests/unit/lxd/test_launcher.py | 175 +++++++++++++++------ tests/unit/lxd/test_lxc.py | 28 +++- tests/unit/lxd/test_lxd_instance.py | 12 ++ 8 files changed, 311 insertions(+), 87 deletions(-) diff --git a/craft_providers/lxd/launcher.py b/craft_providers/lxd/launcher.py index 0d3413a2..ebb17233 100644 --- a/craft_providers/lxd/launcher.py +++ b/craft_providers/lxd/launcher.py @@ -25,6 +25,7 @@ import threading import time from datetime import datetime, timedelta, timezone +from pathlib import Path from typing import Optional from craft_providers import Base, ProviderError, bases @@ -264,35 +265,26 @@ def _formulate_base_instance_name( return "-".join(["base-instance", compatibility_tag, image_remote, image_name]) -def _is_valid( - *, - instance_name: str, - project: str, - remote: str, - lxc: LXC, - expiration: timedelta, -) -> bool: +def _is_valid(*, instance: LXDInstance, expiration: timedelta) -> bool: """Check if an instance is valid. - Instances are valid if they are not expired (too old). An instance's age is measured - by it's creation date. For example, if the expiration is 90 days, then the instance - will expire 91 days after it was created. + Instances are valid if they are ready and not expired (too old). + An instance is ready if it is set up and stopped. + An instance's age is measured by its creation date. For example, if the expiration + is 90 days, then the instance will expire 91 days after it was created. If errors occur during the validity check, the instance is assumed to be invalid. - :param instance_name: Name of instance to check the validity of. - :param project: LXD project name to create. - :param remote: LXD remote to create project on. - :param lxc: LXC client. + :param instance: LXD instance to check the validity of. :param expiration: How long an instance will be valid from its creation date. :returns: True if the instance is valid. False otherwise. """ - logger.debug("Checking validity of instance %r.", instance_name) + logger.debug("Checking validity of instance %r.", instance.instance_name) # capture instance info try: - info = lxc.info(instance_name=instance_name, project=project, remote=remote) + info = instance.info() except LXDError as raised: # if the base instance info can't be retrieved, consider it invalid logger.debug("Could not get instance info with error: %s", raised) @@ -322,6 +314,12 @@ def _is_valid( ) return False + try: + _wait_for_instance_ready(instance) + except LXDError as err: + logger.debug("Instance is not valid: %s", err) + return False + logger.debug("Instance is valid.") return True @@ -560,6 +558,80 @@ def _set_timezone(instance: LXDInstance, project: str, remote: str, lxc: LXC) -> ) +def _wait_for_instance_ready(instance: LXDInstance) -> None: + """Wait for an instance to be ready. + + If another process created an instance and is still running, then wait for + the other process to finish setting up the instance. + + :param instance: LXD instance to wait for. + + :raises LXDError: + - If the instance is not ready and the process that created the + instance is no longer running. + - If the function times out while waiting for another process + to set up the instance. + - On any failure to get config data or info from the instance. + """ + instance_state = instance.info().get("Status") + instance_status = instance.config_get("user.craft_providers.status") + + # check if the instance is ready + if ( + instance_status == ProviderInstanceStatus.FINISHED.value + and instance_state == "STOPPED" + ): + logger.debug("Instance %r is ready.", instance.instance_name) + return + + logger.debug( + "Instance %r is not ready (State: %s, Status: %s)", + instance.instance_name, + instance_state, + instance_status, + ) + + # LXD is linux-only, but verify the platform anyways + if sys.platform == "linux": + # get the PID of the process that created the instance + pid = instance.config_get("user.craft_providers.pid") + + # an empty string means there was no value set + if pid == "": + raise LXDError( + brief="Instance is not ready.", + details=( + f"Instance {instance.instance_name!r} is not ready and does not " + "have the pid of the process that created the instance." + ), + ) + + # check if the PID is active + if Path(f"/proc/{pid}").exists(): + logger.debug( + "Process that created the instance %r is still running (pid %s).", + instance.instance_name, + pid, + ) + else: + raise LXDError( + brief="Instance is not ready.", + details=( + f"Instance {instance.instance_name!r} is not ready and the process " + f"(pid {pid}) that created the instance is inactive." + ), + ) + else: + logger.debug("Skipping PID check because system is not linux") + + # if the PID is active, wait for the instance to be ready + instance.lxc.check_instance_status( + instance_name=instance.instance_name, + project=instance.project, + remote=instance.remote, + ) + + def launch( name: str, *, @@ -728,13 +800,7 @@ def launch( # the base instance exists but is not valid, so delete it then create a new # instance and base instance - if not _is_valid( - instance_name=base_instance.instance_name, - project=project, - remote=remote, - lxc=lxc, - expiration=expiration, - ): + if not _is_valid(instance=base_instance, expiration=expiration): logger.debug( "Base instance %r is not valid. Deleting base instance.", base_instance.instance_name, @@ -755,13 +821,6 @@ def launch( ) return instance - # check if the base instance is still being created - base_instance.lxc.check_instance_status( - instance_name=base_instance.instance_name, - project=project, - remote=remote, - ) - # at this point, there is a valid base instance to be copied to a new instance logger.info("Creating instance from base instance") logger.debug( diff --git a/craft_providers/lxd/lxc.py b/craft_providers/lxd/lxc.py index 5cc274e7..e1bb29fe 100644 --- a/craft_providers/lxd/lxc.py +++ b/craft_providers/lxd/lxc.py @@ -19,6 +19,7 @@ import contextlib import enum import logging +import os import pathlib import shlex import subprocess @@ -579,6 +580,7 @@ def launch( # noqa: PLR0912 _default_instance_metadata: Dict[str, str] = { "user.craft_providers.status": ProviderInstanceStatus.STARTING.value, "user.craft_providers.timer": datetime.now(timezone.utc).isoformat(), + "user.craft_providers.pid": str(os.getpid()), } retry_count: int = 0 if config_keys: @@ -1098,7 +1100,12 @@ def check_instance_status( project: str = "default", remote: str = "local", ) -> None: - """Check build status of instance. + """Repeatedly check if an instance is ready until the function times out. + + If another process is setting up an instance, the instance's timer will keep + incrementing. This function will wait until the other process finishes setting + up the instance or times out and stops incrementing the timer. In the latter + case, a timeout will occur after 60 seconds of the timer not being incremented. The possible status are: - None: Either the instance is downloading or old that this is not set. @@ -1109,6 +1116,8 @@ def check_instance_status( was interrupted or failed. - FINISHED: Instance is ready, all configuration and installation is successful. When it also STOPPED, then the instance is ready to be copied. + + :raises LXDError: If the instance is not ready. """ instance_status: Optional[str] = None instance_info: Dict[str, Any] = {"Status": ""} @@ -1117,8 +1126,9 @@ def check_instance_status( # 20 * 3 seconds = 1 minute no change in timer timer_queue: deque = deque([-2, -1], maxlen=20) - # Retry unless the timer queue is all the same + # retry until the instance's timer hasn't changed for the last 20 iterations while len(set(timer_queue)) > 1: + logger.debug("Checking if instance is ready.") try: # Get instance info instance_info = self.info( @@ -1159,7 +1169,7 @@ def check_instance_status( logger.debug("Instance %s is ready.", instance_name) return + logger.debug("Instance is not ready.") time.sleep(3) - # No timer change for 1 minute and the instance is still not ready. - raise LXDError("Instance setup failed. Check LXD logs for more details.") + raise LXDError(brief="Timed out waiting for instance to be ready.") diff --git a/craft_providers/lxd/lxd_instance.py b/craft_providers/lxd/lxd_instance.py index 8b4fd064..47d02e32 100644 --- a/craft_providers/lxd/lxd_instance.py +++ b/craft_providers/lxd/lxd_instance.py @@ -639,3 +639,11 @@ def config_set(self, key: str, value: str) -> None: project=self.project, remote=self.remote, ) + + def info(self) -> Dict[str, Any]: + """Get info for an instance.""" + return self.lxc.info( + instance_name=self.instance_name, + project=self.project, + remote=self.remote, + ) diff --git a/tests/integration/lxd/test_lxc.py b/tests/integration/lxd/test_lxc.py index 060d2ea0..91fdfceb 100644 --- a/tests/integration/lxd/test_lxc.py +++ b/tests/integration/lxd/test_lxc.py @@ -17,9 +17,10 @@ import pathlib import subprocess +from datetime import datetime import pytest -from craft_providers.lxd import LXDError +from craft_providers.lxd import LXDError, lxd_instance_status from . import conftest @@ -32,6 +33,33 @@ def instance(instance_name, session_project): yield tmp_instance +def test_launch_default_config(instance, lxc, session_project): + """Verify default config values when launching.""" + status = lxc.config_get( + instance_name=instance, + key="user.craft_providers.status", + project=session_project, + ) + timer = lxc.config_get( + instance_name=instance, + key="user.craft_providers.timer", + project=session_project, + ) + pid = lxc.config_get( + instance_name=instance, + key="user.craft_providers.pid", + project=session_project, + ) + + assert status in [ + status.value for status in lxd_instance_status.ProviderInstanceStatus + ] + # assert timer is a valid ISO datetime + datetime.fromisoformat(timer) + # assert PID is an integer + int(pid) + + def test_exec(instance, lxc, session_project): proc = lxc.exec( instance_name=instance, diff --git a/tests/integration/lxd/test_lxd_instance.py b/tests/integration/lxd/test_lxd_instance.py index ee4eb126..e2443c22 100644 --- a/tests/integration/lxd/test_lxd_instance.py +++ b/tests/integration/lxd/test_lxd_instance.py @@ -264,3 +264,9 @@ def test_start_stop(reusable_instance): reusable_instance.start() assert reusable_instance.is_running() is True + + +def test_info(reusable_instance): + info = reusable_instance.info() + + assert info.get("Name") == reusable_instance.instance_name diff --git a/tests/unit/lxd/test_launcher.py b/tests/unit/lxd/test_launcher.py index 67015e5d..70d9ea1e 100644 --- a/tests/unit/lxd/test_launcher.py +++ b/tests/unit/lxd/test_launcher.py @@ -19,11 +19,12 @@ import sys from datetime import timedelta +from pathlib import Path from unittest.mock import MagicMock, Mock, call import pytest from craft_providers import Base, ProviderError, bases, lxd -from craft_providers.lxd import LXDError +from craft_providers.lxd import LXDError, lxd_instance_status from freezegun import freeze_time from logassert import Exact # type: ignore @@ -362,15 +363,7 @@ def test_launch_use_existing_base_instance( ), ] assert fake_instance.mock_calls == [call.exists(), call.is_running(), call.start()] - assert fake_base_instance.mock_calls == [ - call.exists(), - call.lxc.check_instance_status( - instance_name=fake_base_instance.instance_name, - project=fake_base_instance.project, - remote=fake_base_instance.remote, - ), - call.is_running(), - ] + assert fake_base_instance.mock_calls == [call.exists(), call.is_running()] assert mock_base_configuration.mock_calls == [ call.get_command_environment(), call.get_command_environment(), @@ -415,15 +408,7 @@ def test_launch_use_existing_base_instance_already_running( call.stop(), call.start(), ] - assert fake_base_instance.mock_calls == [ - call.exists(), - call.lxc.check_instance_status( - instance_name=fake_base_instance.instance_name, - project=fake_base_instance.project, - remote=fake_base_instance.remote, - ), - call.is_running(), - ] + assert fake_base_instance.mock_calls == [call.exists(), call.is_running()] def test_launch_existing_base_instance_invalid( @@ -1147,15 +1132,15 @@ def test_use_snapshots_deprecated( "2022/12/08 11:05 UTC", # 1 day in the future (improbable but valid) ], ) -def test_is_valid(creation_date, mocker, mock_lxc): +def test_is_valid(creation_date, fake_instance): """Instances younger than the expiration date (inclusive) are valid.""" - mock_lxc.info.return_value = {"Created": creation_date} + fake_instance.info.return_value = {"Created": creation_date, "Status": "STOPPED"} + fake_instance.config_get.return_value = ( + lxd_instance_status.ProviderInstanceStatus.FINISHED.value + ) is_valid = lxd.launcher._is_valid( - instance_name="test-name", - project="test-project", - remote="test-remote", - lxc=mock_lxc, + instance=fake_instance, expiration=timedelta(days=90), ) @@ -1163,31 +1148,31 @@ def test_is_valid(creation_date, mocker, mock_lxc): @freeze_time("2022/12/07 11:05:00 UTC") -def test_is_valid_expired(mocker, mock_lxc): +def test_is_valid_expired(fake_instance, mock_lxc): """Instances older than the expiration date are not valid.""" # 91 days old - mock_lxc.info.return_value = {"Created": "2022/09/07 11:05 UTC"} + fake_instance.info.return_value = { + "Created": "2022/09/07 11:05 UTC", + "Status": "STOPPED", + } + fake_instance.config_get.return_value = ( + lxd_instance_status.ProviderInstanceStatus.FINISHED.value + ) is_valid = lxd.launcher._is_valid( - instance_name="test-name", - project="test-project", - remote="test-remote", - lxc=mock_lxc, + instance=fake_instance, expiration=timedelta(days=90), ) assert not is_valid -def test_is_valid_lxd_error(logs, mocker, mock_lxc): +def test_is_valid_lxd_error(logs, fake_instance): """Warn if the instance's info cannot be retrieved.""" - mock_lxc.info.side_effect = lxd.LXDError("test error") + fake_instance.info.side_effect = lxd.LXDError("test error") is_valid = lxd.launcher._is_valid( - instance_name="test-name", - project="test-project", - remote="test-remote", - lxc=mock_lxc, + instance=fake_instance, expiration=timedelta(days=1), ) @@ -1195,15 +1180,12 @@ def test_is_valid_lxd_error(logs, mocker, mock_lxc): assert Exact("Could not get instance info with error: test error") in logs.debug -def test_is_valid_key_error(logs, mocker, mock_lxc): +def test_is_valid_key_error(logs, fake_instance): """Warn if the instance does not have a creation date.""" - mock_lxc.info.return_value = {} + fake_instance.info.return_value = {} is_valid = lxd.launcher._is_valid( - instance_name="test-name", - project="test-project", - remote="test-remote", - lxc=mock_lxc, + instance=fake_instance, expiration=timedelta(days=1), ) @@ -1211,15 +1193,12 @@ def test_is_valid_key_error(logs, mocker, mock_lxc): assert Exact("Instance does not have a 'Created' date.") in logs.debug -def test_is_valid_value_error(logs, mocker, mock_lxc): +def test_is_valid_value_error(logs, fake_instance): """Warn if the instance's creation date cannot be parsed.""" - mock_lxc.info.return_value = {"Created": "bad-datetime-value"} + fake_instance.info.return_value = {"Created": "bad-datetime-value"} is_valid = lxd.launcher._is_valid( - instance_name="test-name", - project="test-project", - remote="test-remote", - lxc=mock_lxc, + instance=fake_instance, expiration=timedelta(days=1), ) @@ -1233,6 +1212,31 @@ def test_is_valid_value_error(logs, mocker, mock_lxc): ) +@freeze_time("2022/12/07 11:05:00 UTC") +def test_is_valid_wait_for_ready_error(logs, fake_instance, mocker): + """Warn if the instance's creation date cannot be parsed.""" + mocker.patch( + "craft_providers.lxd.launcher._wait_for_instance_ready", + side_effect=LXDError("test error"), + ) + fake_instance.info.return_value = { + "Created": "2022/12/08 11:05 UTC", + "Status": "STOPPED", + } + + fake_instance.config_get.return_value = ( + lxd_instance_status.ProviderInstanceStatus.FINISHED.value + ) + + is_valid = lxd.launcher._is_valid( + instance=fake_instance, + expiration=timedelta(days=1), + ) + + assert not is_valid + assert "Instance is not valid: test error" in logs.debug + + @pytest.mark.skipif(sys.platform == "win32", reason="unsupported on windows") def test_set_id_map_default(fake_base_instance, mock_lxc, mocker): """Verify `_set_id_map()` sets the id map with default arguments.""" @@ -1405,3 +1409,76 @@ def test_timer_error_ignore(fake_instance, fake_process, mock_lxc, mocker): timer.stop() assert fake_instance.config_set.call_count > 0 + + +def test_wait_for_instance_ready(fake_instance, logs): + """Return if the instance is ready.""" + fake_instance.info.return_value = {"Status": "STOPPED"} + fake_instance.config_get.return_value = "FINISHED" + + lxd.launcher._wait_for_instance_ready(fake_instance) + + assert "Instance 'test-instance-fa2d407652a1c51f6019' is ready." in logs.debug + + +def test_wait_for_instance_pid_active(fake_instance, mocker): + """If the instance is not ready and the pid is active, then check the status.""" + fake_instance.info.return_value = {"Status": "STOPPED"} + # first call returns status, second returns the pid + fake_instance.config_get.side_effect = ["PREPARING", "123"] + # mock for the call `Path("/proc/123").exists() + mocker.patch.object(Path, "exists", return_value=True) + + lxd.launcher._wait_for_instance_ready(fake_instance) + + fake_instance.lxc.check_instance_status.assert_called_once() + + +@pytest.mark.parametrize("platform", ["win32", "darwin", "other"]) +def test_wait_for_instance_skip_pid_check(platform, fake_instance, mocker, logs): + """Do not check for the pid if not on linux.""" + mocker.patch("sys.platform", platform) + fake_instance.info.return_value = {"Status": "STOPPED"} + # first call returns status, second returns the pid + fake_instance.config_get.side_effect = ["PREPARING", "123"] + + lxd.launcher._wait_for_instance_ready(fake_instance) + + assert "Skipping PID check because system is not linux" in logs.debug + fake_instance.lxc.check_instance_status.assert_called_once() + + +@pytest.mark.usefixtures("mock_platform") +def test_wait_for_instance_no_pid(fake_instance): + """Raise an error if there is no pid in the config.""" + fake_instance.info.return_value = {"Status": "STOPPED"} + # first call returns status, second returns an empty string for the pid + fake_instance.config_get.side_effect = ["PREPARING", ""] + + with pytest.raises(LXDError) as raised: + lxd.launcher._wait_for_instance_ready(fake_instance) + + assert raised.value.brief == "Instance is not ready." + assert raised.value.details == ( + "Instance 'test-instance-fa2d407652a1c51f6019' is not ready and does not " + "have the pid of the process that created the instance." + ) + + +@pytest.mark.usefixtures("mock_platform") +def test_wait_for_instance_pid_inactive(fake_instance, mocker): + """Raise an error if the instance is not ready and the pid is inactive.""" + fake_instance.info.return_value = {"Status": "STOPPED"} + # first call returns status, second returns the pid + fake_instance.config_get.side_effect = ["PREPARING", "123"] + # mock for the call `Path("/proc/123").exists() + mocker.patch.object(Path, "exists", return_value=False) + + with pytest.raises(LXDError) as raised: + lxd.launcher._wait_for_instance_ready(fake_instance) + + assert raised.value.brief == "Instance is not ready." + assert raised.value.details == ( + "Instance 'test-instance-fa2d407652a1c51f6019' is not ready and " + "the process (pid 123) that created the instance is inactive." + ) diff --git a/tests/unit/lxd/test_lxc.py b/tests/unit/lxd/test_lxc.py index a2b2a977..e8906a21 100644 --- a/tests/unit/lxd/test_lxc.py +++ b/tests/unit/lxd/test_lxc.py @@ -26,6 +26,11 @@ from freezegun import freeze_time +@pytest.fixture() +def mock_getpid(mocker): + return mocker.patch("os.getpid", return_value=123) + + def test_lxc_run_default(mocker, tmp_path): """Test _lxc_run with default arguments.""" mock_run = mocker.patch("subprocess.run") @@ -911,6 +916,7 @@ def test_info_parse_error(fake_process): ) +@pytest.mark.usefixtures("mock_getpid") def test_launch(fake_process): fake_process.register_subprocess( [ @@ -924,6 +930,8 @@ def test_launch(fake_process): "user.craft_providers.status=STARTING", "--config", "user.craft_providers.timer=2023-01-01T00:00:00+00:00", + "--config", + "user.craft_providers.pid=123", ] ) @@ -952,6 +960,7 @@ def test_launch(fake_process): assert len(fake_process.calls) == 1 +@pytest.mark.usefixtures("mock_getpid") def test_launch_failed_retry_check(fake_process, mocker): """Test that we use check_instance_status if launch fails.""" mock_launch = mocker.patch("craft_providers.lxd.lxc.LXC._run_lxc") @@ -980,6 +989,8 @@ def test_launch_failed_retry_check(fake_process, mocker): "user.craft_providers.status=STARTING", "--config", "user.craft_providers.timer=2023-01-01T00:00:00+00:00", + "--config", + "user.craft_providers.pid=123", ], capture_output=True, stdin=StdinType.INTERACTIVE, @@ -994,6 +1005,7 @@ def test_launch_failed_retry_check(fake_process, mocker): ] +@pytest.mark.usefixtures("mock_getpid") def test_launch_failed_retry_failed(fake_process, mocker): """Test that we retry launching an instance if it fails, but failed more than 3 times.""" mock_launch = mocker.patch("craft_providers.lxd.lxc.LXC._run_lxc") @@ -1022,6 +1034,8 @@ def test_launch_failed_retry_failed(fake_process, mocker): "user.craft_providers.status=STARTING", "--config", "user.craft_providers.timer=2023-01-01T00:00:00+00:00", + "--config", + "user.craft_providers.pid=123", ], capture_output=True, stdin=StdinType.INTERACTIVE, @@ -1041,6 +1055,8 @@ def test_launch_failed_retry_failed(fake_process, mocker): "user.craft_providers.status=STARTING", "--config", "user.craft_providers.timer=2023-01-01T00:00:00+00:00", + "--config", + "user.craft_providers.pid=123", ], capture_output=True, stdin=StdinType.INTERACTIVE, @@ -1060,6 +1076,8 @@ def test_launch_failed_retry_failed(fake_process, mocker): "user.craft_providers.status=STARTING", "--config", "user.craft_providers.timer=2023-01-01T00:00:00+00:00", + "--config", + "user.craft_providers.pid=123", ], capture_output=True, stdin=StdinType.INTERACTIVE, @@ -1068,6 +1086,7 @@ def test_launch_failed_retry_failed(fake_process, mocker): ] +@pytest.mark.usefixtures("mock_getpid") def test_launch_all_opts(fake_process): fake_process.register_subprocess( [ @@ -1086,6 +1105,8 @@ def test_launch_all_opts(fake_process): "user.craft_providers.status=STARTING", "--config", "user.craft_providers.timer=2023-01-01T00:00:00+00:00", + "--config", + "user.craft_providers.pid=123", ] ) @@ -1116,6 +1137,7 @@ def test_launch_all_opts(fake_process): assert len(fake_process.calls) == 1 +@pytest.mark.usefixtures("mock_getpid") def test_launch_error(fake_process, mocker): fake_process.register_subprocess( [ @@ -1129,6 +1151,8 @@ def test_launch_error(fake_process, mocker): "user.craft_providers.status=STARTING", "--config", "user.craft_providers.timer=2023-01-01T00:00:00+00:00", + "--config", + "user.craft_providers.pid=123", ], returncode=1, occurrences=4, @@ -1165,7 +1189,7 @@ def test_launch_error(fake_process, mocker): assert exc_info.value == LXDError( brief="Failed to launch instance 'test-instance'.", - details="* Command that failed: 'lxc --project test-project launch test-image-remote:test-image test-remote:test-instance --config user.craft_providers.status=STARTING --config user.craft_providers.timer=2023-01-01T00:00:00+00:00'\n* Command exit code: 1", + details="* Command that failed: 'lxc --project test-project launch test-image-remote:test-image test-remote:test-instance --config user.craft_providers.status=STARTING --config user.craft_providers.timer=2023-01-01T00:00:00+00:00 --config user.craft_providers.pid=123'\n* Command exit code: 1", resolution=None, ) @@ -1493,7 +1517,7 @@ def test_check_instance_status_error_timeout(fake_process, mocker): ) assert exc_info.value == LXDError( - brief="Instance setup failed. Check LXD logs for more details.", + brief="Timed out waiting for instance to be ready." ) diff --git a/tests/unit/lxd/test_lxd_instance.py b/tests/unit/lxd/test_lxd_instance.py index b7c0d961..a8679289 100644 --- a/tests/unit/lxd/test_lxd_instance.py +++ b/tests/unit/lxd/test_lxd_instance.py @@ -136,6 +136,18 @@ def test_config_set(mock_lxc, instance): ] +def test_info(mock_lxc, instance): + instance.info() + + assert mock_lxc.mock_calls == [ + call.info( + instance_name="test-instance-fa2d407652a1c51f6019", + project="default", + remote="local", + ) + ] + + def test_push_file_io( mock_lxc, mock_named_temporary_file,