Repository: canonical/snapcraft

Issue:
SNAPCRAFT_BUILD_ENVIRONMENT apparently takes precedence over "--use-lxd"
### Bug Description

Despite what is printed here, https://snapcraft.io/docs/build-providers, if I set the env var SNAPCRAFT_BUILD_ENVIRONMENT=host, that appears to take priority over using "--use-lxd" on the command line.

### To Reproduce

Set env var SNAPCRAFT_BUILD_ENVIRONMENT=host, then try overriding that with "--use-lxd".

### Environment

Ubuntu 22.04

### snapcraft.yaml

```shell
Not relevant.
```


### Relevant log output

```shell
Build is done on host in destructive mode.
```


### Additional context

_No response_


Discussion:
this is indeed a bug

Resolving patch:
diff --git a/snapcraft/parts/lifecycle.py b/snapcraft/parts/lifecycle.py
--- a/snapcraft/parts/lifecycle.py
+++ b/snapcraft/parts/lifecycle.py
@@ -251,7 +251,7 @@ def _run_command(
                 permanent=True,
             )
 
-    if (
+    if parsed_args.use_lxd or (
         not managed_mode
         and not parsed_args.destructive_mode
         and not os.getenv("SNAPCRAFT_BUILD_ENVIRONMENT") == "host"


Test patch:
diff --git a/tests/spread/providers/use-lxd/task.yaml b/tests/spread/providers/use-lxd/task.yaml
new file mode 100644
--- /dev/null
+++ b/tests/spread/providers/use-lxd/task.yaml
@@ -0,0 +1,29 @@
+summary: Test --use-lxd takes priority over environment variables
+systems:
+  - ubuntu-22.04*
+
+prepare: |
+  snapcraft init
+
+restore: |
+  rm -rf ./*.snap
+
+execute: |
+  export SNAPCRAFT_BUILD_ENVIRONMENT="host"
+
+  snapcraft pull --use-lxd
+
+  if [[ -d parts ]]; then
+    echo "snapcraft did not run inside a lxd instance"
+    exit 1
+  fi
+
+  unset SNAPCRAFT_BUILD_ENVIRONMENT
+  export SNAPCRAFT_MANAGED_MODE=1
+
+  snapcraft pull --use-lxd
+
+  if [[ -d parts ]]; then
+    echo "snapcraft did not run inside a lxd instance"
+    exit 1
+  fi
diff --git a/tests/unit/parts/test_lifecycle.py b/tests/unit/parts/test_lifecycle.py
--- a/tests/unit/parts/test_lifecycle.py
+++ b/tests/unit/parts/test_lifecycle.py
@@ -306,11 +306,33 @@ def test_lifecycle_run_command_step(
     assert run_mock.mock_calls == [call(step, **call_args)]
 
 
+@pytest.mark.parametrize("managed_mode", [True, False])
+@pytest.mark.parametrize("build_env", [None, "host", "multipass", "lxd", "other"])
 @pytest.mark.parametrize("cmd", ["pack", "snap"])
-def test_lifecycle_run_command_pack(cmd, snapcraft_yaml, project_vars, new_dir, mocker):
+def test_lifecycle_run_local_destructive_mode(
+    managed_mode,
+    build_env,
+    cmd,
+    snapcraft_yaml,
+    project_vars,
+    new_dir,
+    mocker,
+    monkeypatch,
+):
+    """Run the lifecycle locally when destructive_mode is True."""
     project = Project.unmarshal(snapcraft_yaml(base="core22"))
+    run_in_provider_mock = mocker.patch("snapcraft.parts.lifecycle._run_in_provider")
     run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run")
     pack_mock = mocker.patch("snapcraft.pack.pack_snap")
+    mocker.patch("snapcraft.utils.is_managed_mode", return_value=managed_mode)
+    mocker.patch(
+        "snapcraft.utils.get_managed_environment_home_path",
+        return_value=new_dir / "home",
+    )
+    if build_env:
+        monkeypatch.setenv("SNAPCRAFT_BUILD_ENVIRONMENT", build_env)
+    else:
+        monkeypatch.delenv("SNAPCRAFT_BUILD_ENVIRONMENT", raising=False)
 
     parts_lifecycle._run_command(
         cmd,
@@ -333,10 +355,11 @@ def test_lifecycle_run_command_pack(cmd, snapcraft_yaml, project_vars, new_dir,
         ),
     )
 
+    assert run_in_provider_mock.mock_calls == []
     assert run_mock.mock_calls == [call("prime", shell=False, shell_after=False)]
     assert pack_mock.mock_calls[:1] == [
         call(
-            new_dir / "prime",
+            new_dir / "home/prime" if managed_mode else new_dir / "prime",
             output=None,
             compression="xz",
             name="mytest",
@@ -346,10 +369,20 @@ def test_lifecycle_run_command_pack(cmd, snapcraft_yaml, project_vars, new_dir,
     ]
 
 
+@pytest.mark.parametrize("destructive_mode", [True, False])
+@pytest.mark.parametrize("build_env", [None, "host", "multipass", "lxd", "other"])
 @pytest.mark.parametrize("cmd", ["pack", "snap"])
-def test_lifecycle_pack_destructive_mode(
-    cmd, snapcraft_yaml, project_vars, new_dir, mocker
+def test_lifecycle_run_local_managed_mode(
+    destructive_mode,
+    build_env,
+    cmd,
+    snapcraft_yaml,
+    project_vars,
+    new_dir,
+    mocker,
+    monkeypatch,
 ):
+    """Run the lifecycle locally when managed_mode is True."""
     project = Project.unmarshal(snapcraft_yaml(base="core22"))
     run_in_provider_mock = mocker.patch("snapcraft.parts.lifecycle._run_in_provider")
     run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run")
@@ -359,6 +392,10 @@ def test_lifecycle_pack_destructive_mode(
         "snapcraft.utils.get_managed_environment_home_path",
         return_value=new_dir / "home",
     )
+    if build_env:
+        monkeypatch.setenv("SNAPCRAFT_BUILD_ENVIRONMENT", build_env)
+    else:
+        monkeypatch.delenv("SNAPCRAFT_BUILD_ENVIRONMENT", raising=False)
 
     parts_lifecycle._run_command(
         cmd,
@@ -372,7 +409,7 @@ def test_lifecycle_pack_destructive_mode(
             output=None,
             debug=False,
             enable_manifest=False,
-            destructive_mode=True,
+            destructive_mode=destructive_mode,
             shell=False,
             shell_after=False,
             use_lxd=False,
@@ -395,17 +432,30 @@ def test_lifecycle_pack_destructive_mode(
     ]
 
 
+@pytest.mark.parametrize("managed_mode", [True, False])
+@pytest.mark.parametrize("destructive_mode", [True, False])
 @pytest.mark.parametrize("cmd", ["pack", "snap"])
-def test_lifecycle_pack_managed(cmd, snapcraft_yaml, project_vars, new_dir, mocker):
+def test_lifecycle_run_local_build_env(
+    managed_mode,
+    destructive_mode,
+    cmd,
+    monkeypatch,
+    snapcraft_yaml,
+    project_vars,
+    new_dir,
+    mocker,
+):
+    """Run the lifecycle locally when the build environment is 'host'."""
     project = Project.unmarshal(snapcraft_yaml(base="core22"))
     run_in_provider_mock = mocker.patch("snapcraft.parts.lifecycle._run_in_provider")
     run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run")
     pack_mock = mocker.patch("snapcraft.pack.pack_snap")
-    mocker.patch("snapcraft.utils.is_managed_mode", return_value=True)
+    mocker.patch("snapcraft.utils.is_managed_mode", return_value=managed_mode)
     mocker.patch(
         "snapcraft.utils.get_managed_environment_home_path",
         return_value=new_dir / "home",
     )
+    monkeypatch.setenv("SNAPCRAFT_BUILD_ENVIRONMENT", "host")
 
     parts_lifecycle._run_command(
         cmd,
@@ -422,7 +472,7 @@ def test_lifecycle_pack_managed(cmd, snapcraft_yaml, project_vars, new_dir, mock
             build_for=None,
             enable_manifest=False,
             manifest_image_information=None,
-            destructive_mode=False,
+            destructive_mode=destructive_mode,
             shell=False,
             shell_after=False,
             use_lxd=False,
@@ -435,7 +485,7 @@ def test_lifecycle_pack_managed(cmd, snapcraft_yaml, project_vars, new_dir, mock
     assert run_mock.mock_calls == [call("prime", shell=False, shell_after=False)]
     assert pack_mock.mock_calls[:1] == [
         call(
-            new_dir / "home/prime",
+            new_dir / "home/prime" if managed_mode else new_dir / "prime",
             output=None,
             compression="xz",
             name="mytest",
@@ -445,12 +495,21 @@ def test_lifecycle_pack_managed(cmd, snapcraft_yaml, project_vars, new_dir, mock
     ]
 
 
+@pytest.mark.parametrize("build_env", [None, "lxd", "multipass", "other"])
 @pytest.mark.parametrize("cmd", ["pack", "snap"])
-def test_lifecycle_pack_not_managed(cmd, snapcraft_yaml, new_dir, mocker):
+def test_lifecycle_run_in_provider_by_default(
+    build_env, cmd, snapcraft_yaml, new_dir, mocker, monkeypatch
+):
+    """Run lifecycle in a provider when not in managed_mode, not in destructive_mode,
+    and the build environment is not 'host'."""
     project = Project.unmarshal(snapcraft_yaml(base="core22"))
     run_in_provider_mock = mocker.patch("snapcraft.parts.lifecycle._run_in_provider")
     run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run")
     mocker.patch("snapcraft.utils.is_managed_mode", return_value=False)
+    if build_env:
+        monkeypatch.setenv("SNAPCRAFT_BUILD_ENVIRONMENT", build_env)
+    else:
+        monkeypatch.delenv("SNAPCRAFT_BUILD_ENVIRONMENT", raising=False)
 
     parts_lifecycle._run_command(
         cmd,
@@ -484,6 +543,78 @@ def test_lifecycle_pack_not_managed(cmd, snapcraft_yaml, new_dir, mocker):
     ]
 
 
+@pytest.mark.parametrize("managed_mode", [True, False])
+@pytest.mark.parametrize("destructive_mode", [True, False])
+@pytest.mark.parametrize("build_env", [None, "host", "lxd", "multipass", "other"])
+@pytest.mark.parametrize("cmd", ["pack", "snap"])
+def test_lifecycle_run_in_provider_use_lxd(
+    managed_mode,
+    destructive_mode,
+    build_env,
+    cmd,
+    mocker,
+    monkeypatch,
+    new_dir,
+    project_vars,
+    snapcraft_yaml,
+):
+    """Run the lifecycle in a provider when `use_lxd` is true."""
+    project = Project.unmarshal(snapcraft_yaml(base="core22"))
+    run_in_provider_mock = mocker.patch("snapcraft.parts.lifecycle._run_in_provider")
+    run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run")
+    mocker.patch("snapcraft.pack.pack_snap")
+    mocker.patch(
+        "snapcraft.utils.get_managed_environment_home_path",
+        return_value=new_dir / "home",
+    )
+    mocker.patch("snapcraft.utils.is_managed_mode", return_value=managed_mode)
+    if build_env:
+        monkeypatch.setenv("SNAPCRAFT_BUILD_ENVIRONMENT", build_env)
+    else:
+        monkeypatch.delenv("SNAPCRAFT_BUILD_ENVIRONMENT", raising=False)
+
+    parts_lifecycle._run_command(
+        cmd,
+        project=project,
+        parse_info={},
+        assets_dir=Path(),
+        start_time=datetime.now(),
+        parallel_build_count=8,
+        parsed_args=argparse.Namespace(
+            directory=None,
+            output=None,
+            debug=False,
+            enable_manifest=False,
+            destructive_mode=destructive_mode,
+            shell=False,
+            shell_after=False,
+            use_lxd=True,
+            ua_token=None,
+            parts=[],
+        ),
+    )
+
+    assert run_mock.mock_calls == []
+    assert run_in_provider_mock.mock_calls == [
+        call(
+            project,
+            cmd,
+            argparse.Namespace(
+                directory=None,
+                output=None,
+                debug=False,
+                enable_manifest=False,
+                destructive_mode=destructive_mode,
+                shell=False,
+                shell_after=False,
+                use_lxd=True,
+                ua_token=None,
+                parts=[],
+            ),
+        )
+    ]
+
+
 @pytest.mark.parametrize("cmd", ["pack", "snap"])
 def test_lifecycle_pack_metadata_error(cmd, snapcraft_yaml, new_dir, mocker):
     project = Project.unmarshal(snapcraft_yaml(base="core22"))
@@ -1703,6 +1834,7 @@ def test_lifecycle_write_metadata(
     parsed_args = argparse.Namespace(
         debug=False,
         destructive_mode=True,
+        use_lxd=False,
         enable_manifest=True,
         ua_token=None,
         parts=[],